@ontrails/warden 1.0.0-beta.4 → 1.0.0-beta.41

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 (279) hide show
  1. package/CHANGELOG.md +835 -13
  2. package/README.md +133 -26
  3. package/bin/warden.ts +51 -0
  4. package/package.json +27 -6
  5. package/src/adapter-check.ts +136 -0
  6. package/src/cli.ts +1570 -105
  7. package/src/command.ts +986 -0
  8. package/src/config.ts +193 -0
  9. package/src/draft.ts +22 -0
  10. package/src/drift.ts +233 -23
  11. package/src/fix.ts +126 -0
  12. package/src/formatters.ts +78 -13
  13. package/src/guide.ts +245 -0
  14. package/src/index.ts +247 -15
  15. package/src/project-context.ts +446 -0
  16. package/src/project-rules.ts +290 -0
  17. package/src/resolve.ts +531 -0
  18. package/src/rules/activation-orphan.ts +97 -0
  19. package/src/rules/captured-kernel.ts +375 -0
  20. package/src/rules/circular-refs.ts +150 -0
  21. package/src/rules/cli-command-route-coherence.ts +177 -0
  22. package/src/rules/composes-declarations.ts +839 -0
  23. package/src/rules/context-no-surface-types.ts +79 -15
  24. package/src/rules/dead-internal-trail.ts +161 -0
  25. package/src/rules/dead-public-trail.ts +258 -0
  26. package/src/rules/draft-file-marking.ts +155 -0
  27. package/src/rules/draft-visible-debt.ts +82 -0
  28. package/src/rules/duplicate-exported-symbol.ts +211 -0
  29. package/src/rules/duplicate-public-contract.ts +137 -0
  30. package/src/rules/entity-exists.ts +254 -0
  31. package/src/rules/entity-ids.ts +15 -0
  32. package/src/rules/error-mapping-completeness.ts +290 -0
  33. package/src/rules/example-valid.ts +395 -0
  34. package/src/rules/fires-declarations.ts +740 -0
  35. package/src/rules/governed-symbol-residue.ts +438 -0
  36. package/src/rules/implementation-returns-result.ts +1409 -166
  37. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  38. package/src/rules/incomplete-crud.ts +583 -0
  39. package/src/rules/index.ts +277 -10
  40. package/src/rules/intent-propagation.ts +125 -0
  41. package/src/rules/layer-field-name-drift.ts +102 -0
  42. package/src/rules/library-projection-coherence.ts +100 -0
  43. package/src/rules/metadata.ts +871 -0
  44. package/src/rules/missing-reconcile.ts +97 -0
  45. package/src/rules/missing-visibility.ts +111 -0
  46. package/src/rules/no-destructured-compose.ts +196 -0
  47. package/src/rules/no-dev-permit-in-source.ts +99 -0
  48. package/src/rules/no-direct-implementation-call.ts +12 -7
  49. package/src/rules/no-legacy-cli-alias-export.ts +247 -0
  50. package/src/rules/no-legacy-layer-imports.ts +211 -0
  51. package/src/rules/no-native-error-result.ts +118 -0
  52. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  53. package/src/rules/no-retired-cross-vocabulary.ts +204 -0
  54. package/src/rules/no-sync-result-assumption.ts +1141 -98
  55. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  56. package/src/rules/no-throw-in-implementation.ts +15 -8
  57. package/src/rules/no-top-level-surface.ts +371 -0
  58. package/src/rules/on-references-exist.ts +194 -0
  59. package/src/rules/orphaned-signal.ts +149 -0
  60. package/src/rules/owner-projection-parity.ts +143 -0
  61. package/src/rules/permit-governance.ts +25 -0
  62. package/src/rules/public-export-example-coverage.ts +573 -0
  63. package/src/rules/public-internal-deep-imports.ts +456 -0
  64. package/src/rules/public-output-schema.ts +29 -0
  65. package/src/rules/public-union-output-discriminants.ts +150 -0
  66. package/src/rules/read-intent-fires.ts +188 -0
  67. package/src/rules/reference-exists.ts +97 -0
  68. package/src/rules/registry-names.ts +167 -0
  69. package/src/rules/resolved-import-boundary.ts +146 -0
  70. package/src/rules/resource-declarations.ts +697 -0
  71. package/src/rules/resource-exists.ts +181 -0
  72. package/src/rules/resource-id-grammar.ts +65 -0
  73. package/src/rules/resource-mock-coverage.ts +115 -0
  74. package/src/rules/retired-vocabulary.ts +633 -0
  75. package/src/rules/scan.ts +38 -25
  76. package/src/rules/scheduled-destroy-intent.ts +44 -0
  77. package/src/rules/signal-graph-coaching.ts +220 -0
  78. package/src/rules/source/composition.ts +165 -0
  79. package/src/rules/source/drafts.ts +164 -0
  80. package/src/rules/source/entities.ts +618 -0
  81. package/src/rules/source/pragmas.ts +45 -0
  82. package/src/rules/source/resources.ts +64 -0
  83. package/src/rules/source/signals.ts +397 -0
  84. package/src/rules/source/stores.ts +310 -0
  85. package/src/rules/specs.ts +9 -5
  86. package/src/rules/static-resource-accessor-preference.ts +653 -0
  87. package/src/rules/surface-overlay-coherence.ts +262 -0
  88. package/src/rules/surface-trailhead-coherence.ts +366 -0
  89. package/src/rules/trail-fork-coaching.ts +625 -0
  90. package/src/rules/trail-versioning-source.ts +1076 -0
  91. package/src/rules/trail-versioning-topo.ts +172 -0
  92. package/src/rules/trailhead-override-divergence.ts +356 -0
  93. package/src/rules/types.ts +354 -8
  94. package/src/rules/unmaterialized-activation-source.ts +85 -0
  95. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  96. package/src/rules/valid-describe-refs.ts +162 -32
  97. package/src/rules/valid-detour-contract.ts +78 -0
  98. package/src/rules/warden-export-symmetry.ts +540 -0
  99. package/src/rules/warden-rules-use-ast.ts +1109 -0
  100. package/src/rules/webhook-route-collision.ts +306 -0
  101. package/src/trails/activation-orphan.trail.ts +84 -0
  102. package/src/trails/captured-kernel.trail.ts +108 -0
  103. package/src/trails/circular-refs.trail.ts +29 -0
  104. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  105. package/src/trails/composes-declarations.trail.ts +22 -0
  106. package/src/trails/context-no-surface-types.trail.ts +21 -0
  107. package/src/trails/dead-internal-trail.trail.ts +26 -0
  108. package/src/trails/dead-public-trail.trail.ts +31 -0
  109. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  110. package/src/trails/draft-file-marking.trail.ts +16 -0
  111. package/src/trails/draft-visible-debt.trail.ts +16 -0
  112. package/src/trails/duplicate-exported-symbol.trail.ts +48 -0
  113. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  114. package/src/trails/entity-exists.trail.ts +21 -0
  115. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  116. package/src/trails/example-valid.trail.ts +25 -0
  117. package/src/trails/fires-declarations.trail.ts +23 -0
  118. package/src/trails/fork-without-preserved-implementation.trail.ts +31 -0
  119. package/src/trails/governed-symbol-residue.trail.ts +24 -0
  120. package/src/trails/implementation-returns-result.trail.ts +20 -0
  121. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  122. package/src/trails/incomplete-crud.trail.ts +39 -0
  123. package/src/trails/index.ts +89 -0
  124. package/src/trails/intent-propagation.trail.ts +30 -0
  125. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  126. package/src/trails/library-projection-coherence.trail.ts +43 -0
  127. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  128. package/src/trails/missing-reconcile.trail.ts +33 -0
  129. package/src/trails/missing-visibility.trail.ts +22 -0
  130. package/src/trails/no-destructured-compose.trail.ts +44 -0
  131. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  132. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  133. package/src/trails/no-legacy-cli-alias-export.trail.ts +41 -0
  134. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  135. package/src/trails/no-native-error-result.trail.ts +18 -0
  136. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  137. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  138. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  139. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  140. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  141. package/src/trails/no-top-level-surface.trail.ts +43 -0
  142. package/src/trails/on-references-exist.trail.ts +21 -0
  143. package/src/trails/orphaned-signal.trail.ts +36 -0
  144. package/src/trails/owner-projection-parity.trail.ts +26 -0
  145. package/src/trails/pending-force.trail.ts +21 -0
  146. package/src/trails/permit-governance.trail.ts +51 -0
  147. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  148. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  149. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  150. package/src/trails/public-output-schema.trail.ts +55 -0
  151. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  152. package/src/trails/read-intent-fires.trail.ts +20 -0
  153. package/src/trails/reference-exists.trail.ts +25 -0
  154. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  155. package/src/trails/resource-declarations.trail.ts +25 -0
  156. package/src/trails/resource-exists.trail.ts +27 -0
  157. package/src/trails/resource-id-grammar.trail.ts +39 -0
  158. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  159. package/src/trails/run.ts +160 -0
  160. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  161. package/src/trails/schema.ts +237 -0
  162. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  163. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  164. package/src/trails/surface-overlay-coherence.trail.ts +24 -0
  165. package/src/trails/surface-trailhead-coherence.trail.ts +25 -0
  166. package/src/trails/topo.ts +6 -0
  167. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  168. package/src/trails/trailhead-override-divergence.trail.ts +47 -0
  169. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  170. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  171. package/src/trails/valid-describe-refs.trail.ts +18 -0
  172. package/src/trails/valid-detour-contract.trail.ts +71 -0
  173. package/src/trails/version-gap.trail.ts +35 -0
  174. package/src/trails/version-pinned-compose.trail.ts +23 -0
  175. package/src/trails/version-without-examples.trail.ts +38 -0
  176. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  177. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  178. package/src/trails/webhook-route-collision.trail.ts +50 -0
  179. package/src/trails/wrap-rule.ts +224 -0
  180. package/src/workspaces.ts +199 -0
  181. package/.turbo/turbo-build.log +0 -1
  182. package/.turbo/turbo-lint.log +0 -3
  183. package/.turbo/turbo-typecheck.log +0 -1
  184. package/dist/cli.d.ts +0 -46
  185. package/dist/cli.d.ts.map +0 -1
  186. package/dist/cli.js +0 -218
  187. package/dist/cli.js.map +0 -1
  188. package/dist/drift.d.ts +0 -26
  189. package/dist/drift.d.ts.map +0 -1
  190. package/dist/drift.js +0 -27
  191. package/dist/drift.js.map +0 -1
  192. package/dist/formatters.d.ts +0 -29
  193. package/dist/formatters.d.ts.map +0 -1
  194. package/dist/formatters.js +0 -87
  195. package/dist/formatters.js.map +0 -1
  196. package/dist/index.d.ts +0 -26
  197. package/dist/index.d.ts.map +0 -1
  198. package/dist/index.js +0 -26
  199. package/dist/index.js.map +0 -1
  200. package/dist/rules/ast.d.ts +0 -41
  201. package/dist/rules/ast.d.ts.map +0 -1
  202. package/dist/rules/ast.js +0 -161
  203. package/dist/rules/ast.js.map +0 -1
  204. package/dist/rules/context-no-surface-types.d.ts +0 -12
  205. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  206. package/dist/rules/context-no-surface-types.js +0 -96
  207. package/dist/rules/context-no-surface-types.js.map +0 -1
  208. package/dist/rules/implementation-returns-result.d.ts +0 -13
  209. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  210. package/dist/rules/implementation-returns-result.js +0 -277
  211. package/dist/rules/implementation-returns-result.js.map +0 -1
  212. package/dist/rules/index.d.ts +0 -15
  213. package/dist/rules/index.d.ts.map +0 -1
  214. package/dist/rules/index.js +0 -34
  215. package/dist/rules/index.js.map +0 -1
  216. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  217. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  218. package/dist/rules/no-direct-impl-in-route.js +0 -47
  219. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  220. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  221. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  222. package/dist/rules/no-direct-implementation-call.js +0 -39
  223. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  224. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  225. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  226. package/dist/rules/no-sync-result-assumption.js +0 -98
  227. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  228. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  229. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  230. package/dist/rules/no-throw-in-detour-target.js +0 -87
  231. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  232. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  233. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  234. package/dist/rules/no-throw-in-implementation.js +0 -34
  235. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  236. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  237. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  238. package/dist/rules/prefer-schema-inference.js +0 -86
  239. package/dist/rules/prefer-schema-inference.js.map +0 -1
  240. package/dist/rules/scan.d.ts +0 -8
  241. package/dist/rules/scan.d.ts.map +0 -1
  242. package/dist/rules/scan.js +0 -32
  243. package/dist/rules/scan.js.map +0 -1
  244. package/dist/rules/specs.d.ts +0 -29
  245. package/dist/rules/specs.d.ts.map +0 -1
  246. package/dist/rules/specs.js +0 -192
  247. package/dist/rules/specs.js.map +0 -1
  248. package/dist/rules/structure.d.ts +0 -13
  249. package/dist/rules/structure.d.ts.map +0 -1
  250. package/dist/rules/structure.js +0 -142
  251. package/dist/rules/structure.js.map +0 -1
  252. package/dist/rules/types.d.ts +0 -52
  253. package/dist/rules/types.d.ts.map +0 -1
  254. package/dist/rules/types.js +0 -2
  255. package/dist/rules/types.js.map +0 -1
  256. package/dist/rules/valid-describe-refs.d.ts +0 -7
  257. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  258. package/dist/rules/valid-describe-refs.js +0 -51
  259. package/dist/rules/valid-describe-refs.js.map +0 -1
  260. package/dist/rules/valid-detour-refs.d.ts +0 -6
  261. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  262. package/dist/rules/valid-detour-refs.js +0 -116
  263. package/dist/rules/valid-detour-refs.js.map +0 -1
  264. package/src/__tests__/cli.test.ts +0 -198
  265. package/src/__tests__/drift.test.ts +0 -74
  266. package/src/__tests__/formatters.test.ts +0 -157
  267. package/src/__tests__/implementation-returns-result.test.ts +0 -129
  268. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  269. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  270. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  271. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  272. package/src/__tests__/rules.test.ts +0 -227
  273. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  274. package/src/rules/ast.ts +0 -213
  275. package/src/rules/no-direct-impl-in-route.ts +0 -81
  276. package/src/rules/no-throw-in-detour-target.ts +0 -150
  277. package/src/rules/valid-detour-refs.ts +0 -187
  278. package/tsconfig.json +0 -9
  279. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,339 @@
1
+ import { TrailsError, errorClasses } from '@ontrails/core';
2
+
3
+ import {
4
+ extractStringLiteral,
5
+ findConfigProperty,
6
+ findTrailDefinitions,
7
+ getNodeId,
8
+ getNodeImported,
9
+ getNodeInit,
10
+ getNodeLocal,
11
+ getNodeSource,
12
+ getNodeSpecifiers,
13
+ getNodeSuperClass,
14
+ identifierName,
15
+ offsetToLine,
16
+ parse,
17
+ walk,
18
+ } from '@ontrails/source';
19
+ import type { AstNode } from '@ontrails/source';
20
+ import { isTestFile } from './scan.js';
21
+ import type { WardenDiagnostic, WardenRule } from './types.js';
22
+
23
+ interface ErrorTypeShape {
24
+ readonly name: string;
25
+ readonly prototype: TrailsError;
26
+ }
27
+
28
+ interface DetourOnType {
29
+ readonly line: number;
30
+ readonly onType: string;
31
+ }
32
+
33
+ const knownErrorConstructors = new Map<string, ErrorTypeShape>([
34
+ [TrailsError.name, TrailsError],
35
+ ...errorClasses.map(({ ctor, name }) => [name, ctor] as const),
36
+ ]);
37
+
38
+ const knownErrorParents = new Map<string, string | null>(
39
+ [...knownErrorConstructors.entries()].map(([name, ctor]) => {
40
+ const parent = Object.getPrototypeOf(ctor.prototype)?.constructor;
41
+ const parentName =
42
+ typeof parent?.name === 'string' &&
43
+ knownErrorConstructors.has(parent.name)
44
+ ? parent.name
45
+ : null;
46
+ return [name, parentName];
47
+ })
48
+ );
49
+
50
+ const resolveKnownErrorName = (
51
+ name: string,
52
+ aliases: ReadonlyMap<string, string>
53
+ ): string => aliases.get(name) ?? name;
54
+
55
+ const coreImportSource = (node: AstNode): string | null =>
56
+ extractStringLiteral(getNodeSource(node));
57
+
58
+ const collectImportSpecifierAliases = (
59
+ specifiers: readonly AstNode[] | undefined,
60
+ aliases: Map<string, string>
61
+ ): void => {
62
+ for (const specifier of specifiers ?? []) {
63
+ if (specifier.type !== 'ImportSpecifier') {
64
+ continue;
65
+ }
66
+
67
+ const localName = identifierName(getNodeLocal(specifier));
68
+ const importedName =
69
+ identifierName(getNodeImported(specifier)) ?? localName;
70
+
71
+ if (localName && importedName && knownErrorConstructors.has(importedName)) {
72
+ aliases.set(localName, importedName);
73
+ }
74
+ }
75
+ };
76
+
77
+ const collectKnownErrorAliases = (
78
+ ast: AstNode
79
+ ): ReadonlyMap<string, string> => {
80
+ const aliases = new Map<string, string>();
81
+
82
+ walk(ast, (node) => {
83
+ if (node.type !== 'ImportDeclaration') {
84
+ return;
85
+ }
86
+
87
+ if (coreImportSource(node) !== '@ontrails/core') {
88
+ return;
89
+ }
90
+
91
+ const specifiers = getNodeSpecifiers(node);
92
+ collectImportSpecifierAliases(specifiers, aliases);
93
+ });
94
+
95
+ return aliases;
96
+ };
97
+
98
+ const recordLocalErrorParent = (
99
+ parents: Map<string, string>,
100
+ aliases: ReadonlyMap<string, string>,
101
+ className: string | null,
102
+ parentName: string | null
103
+ ): void => {
104
+ if (!className || !parentName) {
105
+ return;
106
+ }
107
+
108
+ parents.set(className, resolveKnownErrorName(parentName, aliases));
109
+ };
110
+
111
+ const collectClassExpressionParent = (
112
+ node: AstNode,
113
+ parents: Map<string, string>,
114
+ aliases: ReadonlyMap<string, string>
115
+ ): void => {
116
+ if (node.type !== 'VariableDeclarator') {
117
+ return;
118
+ }
119
+
120
+ const init = getNodeInit(node);
121
+ if (!init || init.type !== 'ClassExpression') {
122
+ return;
123
+ }
124
+
125
+ const className = identifierName(getNodeId(node));
126
+ const parentName = identifierName(getNodeSuperClass(init));
127
+ recordLocalErrorParent(parents, aliases, className, parentName);
128
+ };
129
+
130
+ const collectLocalErrorParents = (
131
+ ast: AstNode,
132
+ aliases: ReadonlyMap<string, string>
133
+ ): ReadonlyMap<string, string> => {
134
+ const parents = new Map<string, string>();
135
+
136
+ walk(ast, (node) => {
137
+ if (node.type === 'ClassDeclaration') {
138
+ const className = identifierName(getNodeId(node));
139
+ const parentName = identifierName(getNodeSuperClass(node));
140
+ recordLocalErrorParent(parents, aliases, className, parentName);
141
+ return;
142
+ }
143
+
144
+ collectClassExpressionParent(node, parents, aliases);
145
+ });
146
+
147
+ return parents;
148
+ };
149
+
150
+ /**
151
+ * Return the raw AST elements of a trail's `detours` array.
152
+ *
153
+ * @remarks
154
+ * Spread elements (`...baseDetours`) in the `detours` array are intentionally
155
+ * skipped here and by {@link extractDetourOnTypes}. This makes the ordering
156
+ * analysis best-effort for arrays that contain spreads: only literal inline
157
+ * detour object entries are ordering-checked, so spreads can cause both false
158
+ * negatives and false positives depending on where they sit relative to the
159
+ * literal entries.
160
+ */
161
+ const getDetourElements = (config: AstNode): readonly (AstNode | null)[] => {
162
+ const detoursProp = findConfigProperty(config, 'detours');
163
+ if (!detoursProp) {
164
+ return [];
165
+ }
166
+
167
+ const detoursValue = detoursProp.value as AstNode | undefined;
168
+ if (!detoursValue || detoursValue.type !== 'ArrayExpression') {
169
+ return [];
170
+ }
171
+
172
+ const elements = (detoursValue as AstNode)['elements'] as
173
+ | readonly (AstNode | null)[]
174
+ | undefined;
175
+ return elements ?? [];
176
+ };
177
+
178
+ const extractDetourOnTypes = (
179
+ config: AstNode,
180
+ sourceCode: string,
181
+ aliases: ReadonlyMap<string, string>
182
+ ): readonly DetourOnType[] =>
183
+ getDetourElements(config).flatMap((element) => {
184
+ if (!element || element.type !== 'ObjectExpression') {
185
+ return [];
186
+ }
187
+
188
+ const onProp = findConfigProperty(element, 'on');
189
+ const onNode = onProp?.value as AstNode | undefined;
190
+ const onTypeName = identifierName(onNode);
191
+ if (!onNode || !onTypeName) {
192
+ return [];
193
+ }
194
+
195
+ return [
196
+ {
197
+ line: offsetToLine(sourceCode, onNode.start),
198
+ onType: resolveKnownErrorName(onTypeName, aliases),
199
+ },
200
+ ];
201
+ });
202
+
203
+ const nextParentType = (
204
+ errorType: string,
205
+ localParents: ReadonlyMap<string, string>
206
+ ): string | null =>
207
+ localParents.get(errorType) ?? knownErrorParents.get(errorType) ?? null;
208
+
209
+ const isSameOrSubtype = (
210
+ candidate: string,
211
+ ancestor: string,
212
+ localParents: ReadonlyMap<string, string>
213
+ ): boolean => {
214
+ let current: string | null = candidate;
215
+ const seen = new Set<string>();
216
+
217
+ while (current && !seen.has(current)) {
218
+ if (current === ancestor) {
219
+ return true;
220
+ }
221
+
222
+ seen.add(current);
223
+ current = nextParentType(current, localParents);
224
+ }
225
+
226
+ return false;
227
+ };
228
+
229
+ const buildDiagnostic = (
230
+ trailId: string,
231
+ shadowedType: string,
232
+ shadowingType: string,
233
+ filePath: string,
234
+ line: number
235
+ ): WardenDiagnostic => ({
236
+ filePath,
237
+ line,
238
+ message: `Trail "${trailId}" declares detour on "${shadowedType}" after earlier detour on "${shadowingType}". Because "${shadowingType}" matches "${shadowedType}" first, the later detour is unreachable.`,
239
+ rule: 'unreachable-detour-shadowing',
240
+ severity: 'error',
241
+ });
242
+
243
+ const findShadowingDetour = (
244
+ detours: readonly DetourOnType[],
245
+ index: number,
246
+ localParents: ReadonlyMap<string, string>
247
+ ): DetourOnType | null => {
248
+ const detour = detours[index];
249
+ if (!detour) {
250
+ return null;
251
+ }
252
+
253
+ for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
254
+ const previous = detours[previousIndex];
255
+ if (
256
+ previous &&
257
+ isSameOrSubtype(detour.onType, previous.onType, localParents)
258
+ ) {
259
+ return previous;
260
+ }
261
+ }
262
+
263
+ return null;
264
+ };
265
+
266
+ const buildTrailDiagnostics = (
267
+ trailId: string,
268
+ detours: readonly DetourOnType[],
269
+ filePath: string,
270
+ localParents: ReadonlyMap<string, string>
271
+ ): readonly WardenDiagnostic[] => {
272
+ const diagnostics: WardenDiagnostic[] = [];
273
+
274
+ for (let index = 1; index < detours.length; index += 1) {
275
+ const detour = detours[index];
276
+ const shadowing = findShadowingDetour(detours, index, localParents);
277
+ if (!detour || !shadowing) {
278
+ continue;
279
+ }
280
+
281
+ diagnostics.push(
282
+ buildDiagnostic(
283
+ trailId,
284
+ detour.onType,
285
+ shadowing.onType,
286
+ filePath,
287
+ detour.line
288
+ )
289
+ );
290
+ }
291
+
292
+ return diagnostics;
293
+ };
294
+
295
+ const buildDiagnostics = (
296
+ ast: AstNode,
297
+ sourceCode: string,
298
+ filePath: string
299
+ ): readonly WardenDiagnostic[] => {
300
+ const aliases = collectKnownErrorAliases(ast);
301
+ const localParents = collectLocalErrorParents(ast, aliases);
302
+ const diagnostics: WardenDiagnostic[] = [];
303
+
304
+ for (const definition of findTrailDefinitions(ast)) {
305
+ if (definition.kind !== 'trail') {
306
+ continue;
307
+ }
308
+
309
+ diagnostics.push(
310
+ ...buildTrailDiagnostics(
311
+ definition.id,
312
+ extractDetourOnTypes(definition.config, sourceCode, aliases),
313
+ filePath,
314
+ localParents
315
+ )
316
+ );
317
+ }
318
+
319
+ return diagnostics;
320
+ };
321
+
322
+ export const unreachableDetourShadowing: WardenRule = {
323
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
324
+ if (isTestFile(filePath)) {
325
+ return [];
326
+ }
327
+
328
+ const ast = parse(filePath, sourceCode);
329
+ if (!ast) {
330
+ return [];
331
+ }
332
+
333
+ return buildDiagnostics(ast, sourceCode, filePath);
334
+ },
335
+ description:
336
+ 'Detect later detours whose on: error type is already matched by an earlier same or broader detour.',
337
+ name: 'unreachable-detour-shadowing',
338
+ severity: 'error',
339
+ };
@@ -1,13 +1,20 @@
1
+ import {
2
+ extractStringOrTemplateLiteral,
3
+ getNodeName,
4
+ getNodeProperty,
5
+ getNodeValue,
6
+ offsetToLine,
7
+ parse,
8
+ walk,
9
+ } from '@ontrails/source';
10
+ import type { AstNode } from '@ontrails/source';
11
+ import { isTestFile } from './scan.js';
12
+ import { collectTrailIds } from './specs.js';
1
13
  import type {
2
14
  ProjectAwareWardenRule,
3
15
  ProjectContext,
4
16
  WardenDiagnostic,
5
17
  } from './types.js';
6
- import { isTestFile } from './scan.js';
7
- import { collectTrailIds, parseStringLiteral } from './specs.js';
8
- import { captureBalanced, lineNumberAt } from './structure.js';
9
-
10
- const DESCRIBE_PATTERN = /\.describe\s*\(/g;
11
18
 
12
19
  const SEE_PATTERN = /@see\s+([A-Za-z0-9_.-]+)/g;
13
20
 
@@ -16,41 +23,159 @@ interface DescribeRef {
16
23
  readonly ref: string;
17
24
  }
18
25
 
19
- const describeTextAt = (
20
- sourceCode: string,
21
- matchIndex: number
22
- ): string | null => {
23
- const openParen = sourceCode.indexOf('(', matchIndex);
24
- if (openParen === -1) {
26
+ const STRING_LITERAL_ARG_TYPES: ReadonlySet<string> = new Set([
27
+ 'Literal',
28
+ 'StringLiteral',
29
+ 'TemplateLiteral',
30
+ ]);
31
+
32
+ const MEMBER_CALLEE_TYPES: ReadonlySet<string> = new Set([
33
+ 'MemberExpression',
34
+ 'StaticMemberExpression',
35
+ ]);
36
+
37
+ const isDescribeMemberCallee = (callee: AstNode | undefined): boolean => {
38
+ if (!callee || !MEMBER_CALLEE_TYPES.has(callee.type)) {
39
+ return false;
40
+ }
41
+ const prop = getNodeProperty(callee);
42
+ return prop?.type === 'Identifier' && getNodeName(prop) === 'describe';
43
+ };
44
+
45
+ const hasStringLiteralFirstArg = (node: AstNode): boolean => {
46
+ const args = node['arguments'] as readonly AstNode[] | undefined;
47
+ const firstArg = args?.[0];
48
+ return !!firstArg && STRING_LITERAL_ARG_TYPES.has(firstArg.type);
49
+ };
50
+
51
+ const isDescribeCall = (node: AstNode): boolean => {
52
+ if (node.type !== 'CallExpression') {
53
+ return false;
54
+ }
55
+ if (!isDescribeMemberCallee(node['callee'] as AstNode | undefined)) {
56
+ return false;
57
+ }
58
+ // Narrow to calls whose first argument is a string/template literal.
59
+ // Filters out RxJS-style `.describe(fn)` and other custom APIs whose
60
+ // `.describe()` overloads take non-string arguments. Zod's shape always
61
+ // passes a string literal here.
62
+ return hasStringLiteralFirstArg(node);
63
+ };
64
+
65
+ /**
66
+ * Extract scannable text from a template literal, even when it contains
67
+ * `${...}` expressions. Concatenates the cooked quasi chunks with a NUL
68
+ * sentinel between them — interpolated values are runtime-only and cannot
69
+ * contribute static `@see` tokens, but the surrounding quasi text can. The
70
+ * sentinel prevents phantom tokens that would otherwise appear when a quasi
71
+ * boundary splits the `@see` marker itself (e.g. `\`@s${x}ee missing\``
72
+ * would naively join to `"@seemissing"` and match `@see`).
73
+ *
74
+ * This is intentionally describe-local: the shared
75
+ * {@link extractStringOrTemplateLiteral} helper preserves "plain template
76
+ * literal only" semantics for other rules (e.g. resolving trail/signal IDs)
77
+ * that require a single clean string value. Here we only need to scan for
78
+ * `@see` tokens, so concatenating quasi cooked text is sound.
79
+ *
80
+ * @remarks
81
+ * A quasi's `cooked` value can be `null` in tagged-template positions where
82
+ * the literal contains escape sequences the parser can't decode. `.describe`
83
+ * is a plain method call, not a tagged template, so in practice its quasis
84
+ * always have a `cooked` string today. The `raw` fallback is defensive: if a
85
+ * future refactor wraps `describe(\`...\`)` in a tagged template, we still
86
+ * scan the raw source rather than silently dropping the quasi text and
87
+ * missing an `@see` token.
88
+ */
89
+ const extractQuasiText = (quasi: AstNode): string | null => {
90
+ const value = getNodeValue(quasi);
91
+ if (!value || typeof value !== 'object') {
25
92
  return null;
26
93
  }
94
+ const record = value as Record<string, unknown>;
95
+ if (typeof record['cooked'] === 'string') {
96
+ return record['cooked'];
97
+ }
98
+ if (typeof record['raw'] === 'string') {
99
+ return record['raw'];
100
+ }
101
+ return null;
102
+ };
27
103
 
28
- return captureBalanced(sourceCode, openParen)?.text.slice(1, -1) ?? null;
104
+ const extractTemplateLiteralQuasiText = (node: AstNode): string | null => {
105
+ if (node.type !== 'TemplateLiteral') {
106
+ return null;
107
+ }
108
+ const quasis = (node['quasis'] as readonly AstNode[] | undefined) ?? [];
109
+ const parts: string[] = [];
110
+ for (const quasi of quasis) {
111
+ const text = extractQuasiText(quasi);
112
+ if (text !== null) {
113
+ parts.push(text);
114
+ }
115
+ }
116
+ // Use a NUL sentinel (not a letter / ref character) so interpolation
117
+ // boundaries cannot silently fuse neighbouring quasis into a phantom
118
+ // `@see <ident>` match. `\u0000` cannot appear inside a valid trail ID,
119
+ // so it safely terminates any partial token on either side.
120
+ return parts.join('\u0000');
29
121
  };
30
122
 
31
- const refsInDescription = (
32
- description: string,
33
- line: number
34
- ): readonly DescribeRef[] =>
35
- [...description.matchAll(SEE_PATTERN)].flatMap((see) =>
36
- see[1] ? [{ line, ref: see[1] }] : []
123
+ const extractDescribeDescription = (node: AstNode): string | null => {
124
+ const args = node['arguments'] as readonly AstNode[] | undefined;
125
+ const [firstArg] = args ?? [];
126
+ if (!firstArg) {
127
+ return null;
128
+ }
129
+ return (
130
+ extractStringOrTemplateLiteral(firstArg) ??
131
+ extractTemplateLiteralQuasiText(firstArg)
37
132
  );
133
+ };
38
134
 
39
- const refsForDescribe = (
40
- sourceCode: string,
41
- matchIndex: number
42
- ): readonly DescribeRef[] => {
43
- const args = describeTextAt(sourceCode, matchIndex);
44
- const description = args ? parseStringLiteral(args) : null;
45
- return description === null
46
- ? []
47
- : refsInDescription(description, lineNumberAt(sourceCode, matchIndex));
135
+ /**
136
+ * Anchor the diagnostic on the string argument that actually contains the
137
+ * `@see` token, not on the call-expression start. For multi-line schema
138
+ * chains, the call-expression start can be many lines above the describe
139
+ * argument, which confuses editor tooling.
140
+ */
141
+ const describeAnchorOffset = (node: AstNode): number => {
142
+ const args = node['arguments'] as readonly AstNode[] | undefined;
143
+ return args?.[0]?.start ?? node.start;
48
144
  };
49
145
 
50
- const collectDescribeRefs = (sourceCode: string): readonly DescribeRef[] =>
51
- [...sourceCode.matchAll(DESCRIBE_PATTERN)].flatMap((match) =>
52
- match.index === undefined ? [] : refsForDescribe(sourceCode, match.index)
53
- );
146
+ const collectRefsFromDescription = (
147
+ description: string,
148
+ line: number,
149
+ out: DescribeRef[]
150
+ ): void => {
151
+ for (const match of description.matchAll(SEE_PATTERN)) {
152
+ const [, ref] = match;
153
+ if (ref) {
154
+ out.push({ line, ref });
155
+ }
156
+ }
157
+ };
158
+
159
+ const collectDescribeRefs = (
160
+ ast: AstNode,
161
+ sourceCode: string
162
+ ): readonly DescribeRef[] => {
163
+ const refs: DescribeRef[] = [];
164
+
165
+ walk(ast, (node) => {
166
+ if (!isDescribeCall(node)) {
167
+ return;
168
+ }
169
+ const description = extractDescribeDescription(node);
170
+ if (description === null) {
171
+ return;
172
+ }
173
+ const line = offsetToLine(sourceCode, describeAnchorOffset(node));
174
+ collectRefsFromDescription(description, line, refs);
175
+ });
176
+
177
+ return refs;
178
+ };
54
179
 
55
180
  const checkDescribeRefs = (
56
181
  sourceCode: string,
@@ -61,7 +186,12 @@ const checkDescribeRefs = (
61
186
  return [];
62
187
  }
63
188
 
64
- return collectDescribeRefs(sourceCode)
189
+ const ast = parse(filePath, sourceCode);
190
+ if (!ast) {
191
+ return [];
192
+ }
193
+
194
+ return collectDescribeRefs(ast, sourceCode)
65
195
  .filter(({ ref }) => !knownTrailIds.has(ref))
66
196
  .map(({ line, ref }) => ({
67
197
  filePath,
@@ -0,0 +1,78 @@
1
+ import type { Topo } from '@ontrails/core';
2
+
3
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
4
+
5
+ interface DetourLike {
6
+ readonly on?: unknown;
7
+ readonly recover?: unknown;
8
+ }
9
+
10
+ const isErrorConstructor = (
11
+ value: unknown
12
+ ): value is abstract new (...args: never[]) => Error => {
13
+ if (typeof value !== 'function') {
14
+ return false;
15
+ }
16
+
17
+ const { prototype } = value as { prototype?: unknown };
18
+ return prototype instanceof Error;
19
+ };
20
+
21
+ const describeOnValue = (value: unknown): string => {
22
+ if (typeof value === 'function') {
23
+ const { name } = value as { name?: unknown };
24
+ return typeof name === 'string' && name.length > 0
25
+ ? name
26
+ : '<anonymous constructor>';
27
+ }
28
+
29
+ return String(value);
30
+ };
31
+
32
+ const buildDiagnostic = (message: string, rule: string): WardenDiagnostic => ({
33
+ filePath: '<topo>',
34
+ line: 1,
35
+ message,
36
+ rule,
37
+ severity: 'error',
38
+ });
39
+
40
+ const collectTrailDiagnostics = (topo: Topo): readonly WardenDiagnostic[] => {
41
+ const diagnostics: WardenDiagnostic[] = [];
42
+
43
+ for (const trail of topo.trails.values()) {
44
+ for (const [index, detour] of trail.detours.entries()) {
45
+ const candidate = detour as DetourLike;
46
+
47
+ if (!isErrorConstructor(candidate.on)) {
48
+ diagnostics.push(
49
+ buildDiagnostic(
50
+ `Trail "${trail.id}" detour[${index}] must declare an error constructor in on:. Received ${describeOnValue(candidate.on)}.`,
51
+ 'valid-detour-contract'
52
+ )
53
+ );
54
+ }
55
+
56
+ if (typeof candidate.recover !== 'function') {
57
+ diagnostics.push(
58
+ buildDiagnostic(
59
+ `Trail "${trail.id}" detour[${index}] must declare a callable recover function. Expected recover: (attempt, ctx) => Promise<Result<...>>; inspect attempt.error for the matched error and return Result.err(...) for unrecoverable cases.`,
60
+ 'valid-detour-contract'
61
+ )
62
+ );
63
+ }
64
+ }
65
+ }
66
+
67
+ return diagnostics;
68
+ };
69
+
70
+ export const validDetourContract: TopoAwareWardenRule = {
71
+ checkTopo(topo: Topo): readonly WardenDiagnostic[] {
72
+ return collectTrailDiagnostics(topo);
73
+ },
74
+ description:
75
+ 'Ensure detours use real error constructors and callable recover functions.',
76
+ name: 'valid-detour-contract',
77
+ severity: 'error',
78
+ };