@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,343 @@
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
+ shadowedType === shadowingType
240
+ ? `Remove or consolidate the duplicate "${shadowedType}" detour — only the first can ever run.`
241
+ : `Move the "${shadowedType}" detour before "${shadowingType}" so the more specific recovery runs, or remove it if the broader detour should own this case.`
242
+ }`,
243
+ rule: 'unreachable-detour-shadowing',
244
+ severity: 'error',
245
+ });
246
+
247
+ const findShadowingDetour = (
248
+ detours: readonly DetourOnType[],
249
+ index: number,
250
+ localParents: ReadonlyMap<string, string>
251
+ ): DetourOnType | null => {
252
+ const detour = detours[index];
253
+ if (!detour) {
254
+ return null;
255
+ }
256
+
257
+ for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
258
+ const previous = detours[previousIndex];
259
+ if (
260
+ previous &&
261
+ isSameOrSubtype(detour.onType, previous.onType, localParents)
262
+ ) {
263
+ return previous;
264
+ }
265
+ }
266
+
267
+ return null;
268
+ };
269
+
270
+ const buildTrailDiagnostics = (
271
+ trailId: string,
272
+ detours: readonly DetourOnType[],
273
+ filePath: string,
274
+ localParents: ReadonlyMap<string, string>
275
+ ): readonly WardenDiagnostic[] => {
276
+ const diagnostics: WardenDiagnostic[] = [];
277
+
278
+ for (let index = 1; index < detours.length; index += 1) {
279
+ const detour = detours[index];
280
+ const shadowing = findShadowingDetour(detours, index, localParents);
281
+ if (!detour || !shadowing) {
282
+ continue;
283
+ }
284
+
285
+ diagnostics.push(
286
+ buildDiagnostic(
287
+ trailId,
288
+ detour.onType,
289
+ shadowing.onType,
290
+ filePath,
291
+ detour.line
292
+ )
293
+ );
294
+ }
295
+
296
+ return diagnostics;
297
+ };
298
+
299
+ const buildDiagnostics = (
300
+ ast: AstNode,
301
+ sourceCode: string,
302
+ filePath: string
303
+ ): readonly WardenDiagnostic[] => {
304
+ const aliases = collectKnownErrorAliases(ast);
305
+ const localParents = collectLocalErrorParents(ast, aliases);
306
+ const diagnostics: WardenDiagnostic[] = [];
307
+
308
+ for (const definition of findTrailDefinitions(ast)) {
309
+ if (definition.kind !== 'trail') {
310
+ continue;
311
+ }
312
+
313
+ diagnostics.push(
314
+ ...buildTrailDiagnostics(
315
+ definition.id,
316
+ extractDetourOnTypes(definition.config, sourceCode, aliases),
317
+ filePath,
318
+ localParents
319
+ )
320
+ );
321
+ }
322
+
323
+ return diagnostics;
324
+ };
325
+
326
+ export const unreachableDetourShadowing: 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 buildDiagnostics(ast, sourceCode, filePath);
338
+ },
339
+ description:
340
+ 'Detect later detours whose on: error type is already matched by an earlier same or broader detour.',
341
+ name: 'unreachable-detour-shadowing',
342
+ severity: 'error',
343
+ };
@@ -0,0 +1,224 @@
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';
13
+ import type {
14
+ ProjectAwareWardenRule,
15
+ ProjectContext,
16
+ WardenDiagnostic,
17
+ } from './types.js';
18
+
19
+ const SEE_PATTERN = /@see\s+([A-Za-z0-9_.-]+)/g;
20
+
21
+ interface DescribeRef {
22
+ readonly line: number;
23
+ readonly ref: string;
24
+ }
25
+
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') {
92
+ return null;
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
+ };
103
+
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');
121
+ };
122
+
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)
132
+ );
133
+ };
134
+
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;
144
+ };
145
+
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
+ };
179
+
180
+ const checkDescribeRefs = (
181
+ sourceCode: string,
182
+ filePath: string,
183
+ knownTrailIds: ReadonlySet<string>
184
+ ): readonly WardenDiagnostic[] => {
185
+ if (isTestFile(filePath)) {
186
+ return [];
187
+ }
188
+
189
+ const ast = parse(filePath, sourceCode);
190
+ if (!ast) {
191
+ return [];
192
+ }
193
+
194
+ return collectDescribeRefs(ast, sourceCode)
195
+ .filter(({ ref }) => !knownTrailIds.has(ref))
196
+ .map(({ line, ref }) => ({
197
+ filePath,
198
+ line,
199
+ message: `@see reference "${ref}" does not resolve to a defined trail.`,
200
+ rule: 'valid-describe-refs',
201
+ severity: 'warn' as const,
202
+ }));
203
+ };
204
+
205
+ /**
206
+ * Warns when @see references inside Zod .describe() strings point at unknown
207
+ * trails.
208
+ */
209
+ export const validDescribeRefs: ProjectAwareWardenRule = {
210
+ check(sourceCode: string, filePath: string): readonly WardenDiagnostic[] {
211
+ return checkDescribeRefs(sourceCode, filePath, collectTrailIds(sourceCode));
212
+ },
213
+ checkWithContext(
214
+ sourceCode: string,
215
+ filePath: string,
216
+ context: ProjectContext
217
+ ): readonly WardenDiagnostic[] {
218
+ return checkDescribeRefs(sourceCode, filePath, context.knownTrailIds);
219
+ },
220
+ description:
221
+ 'Ensure @see tags inside schema .describe() strings reference defined trails.',
222
+ name: 'valid-describe-refs',
223
+ severity: 'warn',
224
+ };
@@ -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)}. Pass the error class itself, such as on: TimeoutError — not an error instance, a string name, or an error category.`,
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
+ };