@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,366 @@
1
+ import { matchesTrailPattern } from '@ontrails/core';
2
+
3
+ import {
4
+ extractStringOrTemplateLiteral,
5
+ findConfigProperty,
6
+ getNodeArgument,
7
+ getNodeElements,
8
+ getNodeExpression,
9
+ getNodeId,
10
+ getNodeInit,
11
+ getNodeKey,
12
+ getNodeName,
13
+ getNodeProperties,
14
+ getNodeTypeAnnotation,
15
+ getNodeValue,
16
+ getNodeValueNode,
17
+ getPropertyName,
18
+ offsetToLine,
19
+ parse,
20
+ walk,
21
+ } from '@ontrails/source';
22
+ import type { AstNode } from '@ontrails/source';
23
+ import type { WardenDiagnostic, WardenRule } from './types.js';
24
+
25
+ const RULE_NAME = 'surface-trailhead-coherence';
26
+
27
+ interface TrailheadSelector {
28
+ readonly trailheadId: string;
29
+ readonly node: AstNode;
30
+ readonly value: string;
31
+ }
32
+
33
+ const unwrapExpression = (node: AstNode | undefined): AstNode | undefined => {
34
+ let current = node;
35
+ while (
36
+ current?.type === 'TSAsExpression' ||
37
+ current?.type === 'TSSatisfiesExpression'
38
+ ) {
39
+ current = getNodeExpression(current) ?? getNodeArgument(current);
40
+ }
41
+ return current;
42
+ };
43
+
44
+ const objectProperties = (node: AstNode): readonly AstNode[] =>
45
+ node.type === 'ObjectExpression' ? (getNodeProperties(node) ?? []) : [];
46
+
47
+ const propertyValue = (property: AstNode): AstNode | undefined =>
48
+ property.type === 'Property' ? getNodeValueNode(property) : undefined;
49
+
50
+ const literalBooleanValue = (node: AstNode | undefined): boolean | null => {
51
+ if (node?.type !== 'Literal') {
52
+ return null;
53
+ }
54
+ const value = getNodeValue(node);
55
+ return typeof value === 'boolean' ? value : null;
56
+ };
57
+
58
+ const diagnostic = (
59
+ sourceCode: string,
60
+ filePath: string,
61
+ node: AstNode,
62
+ message: string
63
+ ): WardenDiagnostic => ({
64
+ filePath,
65
+ line: offsetToLine(sourceCode, node.start),
66
+ message,
67
+ rule: RULE_NAME,
68
+ severity: 'warn',
69
+ });
70
+
71
+ const isTrailheadDefinition = (node: AstNode): boolean =>
72
+ node.type === 'ObjectExpression' &&
73
+ findConfigProperty(node, 'trails') !== null;
74
+
75
+ const isTrailheadMapCandidate = (node: AstNode): boolean =>
76
+ objectProperties(node).some((property) => {
77
+ const value = unwrapExpression(propertyValue(property));
78
+ return value !== undefined && isTrailheadDefinition(value);
79
+ });
80
+
81
+ const isTrailheadMapBindingName = (name: string | null): boolean =>
82
+ name !== null &&
83
+ (name === 'trailheads' ||
84
+ name.endsWith('Trailheads') ||
85
+ name.endsWith('TrailheadMap'));
86
+
87
+ const hasTrailheadMapTypeAnnotation = (
88
+ sourceCode: string,
89
+ node: AstNode
90
+ ): boolean => {
91
+ const typeAnnotation = getNodeTypeAnnotation(node);
92
+ return (
93
+ typeAnnotation !== undefined &&
94
+ /\b(?:McpSurfaceTrailheadMap|TrailheadMap)\b/.test(
95
+ sourceCode.slice(typeAnnotation.start, typeAnnotation.end)
96
+ )
97
+ );
98
+ };
99
+
100
+ const selectorNodes = (trailsNode: AstNode): readonly AstNode[] | null => {
101
+ const value = unwrapExpression(trailsNode);
102
+ if (!value) {
103
+ return null;
104
+ }
105
+ if (value.type === 'ArrayExpression') {
106
+ return getNodeElements(value).filter((element) => element !== null);
107
+ }
108
+ return [value];
109
+ };
110
+
111
+ const collectLiteralSelectors = (
112
+ sourceCode: string,
113
+ filePath: string,
114
+ trailheadId: string,
115
+ trailsProp: AstNode,
116
+ diagnostics: WardenDiagnostic[]
117
+ ): readonly TrailheadSelector[] => {
118
+ const trailsValue = propertyValue(trailsProp);
119
+ const nodes = trailsValue ? selectorNodes(trailsValue) : null;
120
+ if (nodes === null || nodes.length === 0) {
121
+ diagnostics.push(
122
+ diagnostic(
123
+ sourceCode,
124
+ filePath,
125
+ trailsProp,
126
+ `Surface trailhead "${trailheadId}" uses a dynamic trails selector. Keep trailhead selectors as string literals so Warden can check overlap and drift.`
127
+ )
128
+ );
129
+ return [];
130
+ }
131
+
132
+ const selectors: TrailheadSelector[] = [];
133
+ for (const node of nodes) {
134
+ const selectorValue = extractStringOrTemplateLiteral(node);
135
+ if (selectorValue === null) {
136
+ diagnostics.push(
137
+ diagnostic(
138
+ sourceCode,
139
+ filePath,
140
+ node,
141
+ `Surface trailhead "${trailheadId}" uses a dynamic trails selector. Keep trailhead selectors as string literals so Warden can check overlap and drift.`
142
+ )
143
+ );
144
+ continue;
145
+ }
146
+ selectors.push({ node, trailheadId, value: selectorValue });
147
+ }
148
+ return selectors;
149
+ };
150
+
151
+ const hasNonEmptyDescription = (definition: AstNode): boolean => {
152
+ const descriptionProp = findConfigProperty(definition, 'description');
153
+ const value = unwrapExpression(propertyValue(descriptionProp ?? definition));
154
+ const description = extractStringOrTemplateLiteral(value);
155
+ return typeof description === 'string' && description.trim().length > 0;
156
+ };
157
+
158
+ const literalStringProperty = (
159
+ definition: AstNode,
160
+ propertyName: string
161
+ ): string | null => {
162
+ const prop = findConfigProperty(definition, propertyName);
163
+ if (!prop) {
164
+ return null;
165
+ }
166
+ return extractStringOrTemplateLiteral(unwrapExpression(propertyValue(prop)));
167
+ };
168
+
169
+ const literalBooleanProperty = (
170
+ definition: AstNode,
171
+ propertyName: string
172
+ ): boolean | null => {
173
+ const prop = findConfigProperty(definition, propertyName);
174
+ if (!prop) {
175
+ return null;
176
+ }
177
+ return literalBooleanValue(unwrapExpression(propertyValue(prop)));
178
+ };
179
+
180
+ const selectorsMayOverlap = (
181
+ first: TrailheadSelector,
182
+ second: TrailheadSelector
183
+ ): boolean =>
184
+ first.value === second.value ||
185
+ matchesTrailPattern(first.value, second.value) ||
186
+ matchesTrailPattern(second.value, first.value);
187
+
188
+ const diagnoseTrailheadDefinition = (
189
+ sourceCode: string,
190
+ filePath: string,
191
+ trailheadId: string,
192
+ definition: AstNode
193
+ ): {
194
+ readonly diagnostics: readonly WardenDiagnostic[];
195
+ readonly selectors: readonly TrailheadSelector[];
196
+ } => {
197
+ const diagnostics: WardenDiagnostic[] = [];
198
+ const trailsProp = findConfigProperty(definition, 'trails');
199
+ const selectors =
200
+ trailsProp === null
201
+ ? []
202
+ : collectLiteralSelectors(
203
+ sourceCode,
204
+ filePath,
205
+ trailheadId,
206
+ trailsProp,
207
+ diagnostics
208
+ );
209
+
210
+ if (!hasNonEmptyDescription(definition)) {
211
+ diagnostics.push(
212
+ diagnostic(
213
+ sourceCode,
214
+ filePath,
215
+ definition,
216
+ `Surface trailhead "${trailheadId}" needs a non-empty description so MCP clients and agents can choose it without guessing.`
217
+ )
218
+ );
219
+ }
220
+
221
+ const visibility = literalStringProperty(definition, 'visibility');
222
+ const wideningAccepted = literalBooleanProperty(
223
+ definition,
224
+ 'visibilityWideningAccepted'
225
+ );
226
+ if (visibility === 'public' && wideningAccepted !== true) {
227
+ diagnostics.push(
228
+ diagnostic(
229
+ sourceCode,
230
+ filePath,
231
+ definition,
232
+ `Surface trailhead "${trailheadId}" explicitly sets public visibility without visibilityWideningAccepted: true. Trailheads must not accidentally widen hidden trails.`
233
+ )
234
+ );
235
+ }
236
+
237
+ if (
238
+ wideningAccepted === true &&
239
+ !literalStringProperty(definition, 'descriptionStableThrough')
240
+ ) {
241
+ diagnostics.push(
242
+ diagnostic(
243
+ sourceCode,
244
+ filePath,
245
+ definition,
246
+ `Surface trailhead "${trailheadId}" accepts visibility widening but does not record descriptionStableThrough review metadata.`
247
+ )
248
+ );
249
+ }
250
+
251
+ return { diagnostics, selectors };
252
+ };
253
+
254
+ const diagnoseTrailheadMap = (
255
+ sourceCode: string,
256
+ filePath: string,
257
+ trailheadMap: AstNode
258
+ ): readonly WardenDiagnostic[] => {
259
+ const diagnostics: WardenDiagnostic[] = [];
260
+ const selectors: TrailheadSelector[] = [];
261
+
262
+ for (const property of objectProperties(trailheadMap)) {
263
+ const trailheadId = getPropertyName(getNodeKey(property));
264
+ const value = unwrapExpression(propertyValue(property));
265
+ if (!trailheadId || value === undefined || !isTrailheadDefinition(value)) {
266
+ continue;
267
+ }
268
+ const result = diagnoseTrailheadDefinition(
269
+ sourceCode,
270
+ filePath,
271
+ trailheadId,
272
+ value
273
+ );
274
+ diagnostics.push(...result.diagnostics);
275
+ selectors.push(...result.selectors);
276
+ }
277
+
278
+ for (let i = 0; i < selectors.length; i += 1) {
279
+ const first = selectors[i];
280
+ if (!first) {
281
+ continue;
282
+ }
283
+ for (let j = i + 1; j < selectors.length; j += 1) {
284
+ const second = selectors[j];
285
+ if (
286
+ !second ||
287
+ first.trailheadId === second.trailheadId ||
288
+ !selectorsMayOverlap(first, second)
289
+ ) {
290
+ continue;
291
+ }
292
+ diagnostics.push(
293
+ diagnostic(
294
+ sourceCode,
295
+ filePath,
296
+ second.node,
297
+ `Surface trailhead selector "${second.value}" in "${second.trailheadId}" overlaps selector "${first.value}" in "${first.trailheadId}". Narrow one trailhead so each public trail has one MCP owner.`
298
+ )
299
+ );
300
+ }
301
+ }
302
+
303
+ return diagnostics;
304
+ };
305
+
306
+ export const surfaceTrailheadCoherence: WardenRule = {
307
+ check(sourceCode, filePath) {
308
+ const ast = parse(filePath, sourceCode);
309
+ if (!ast) {
310
+ return [];
311
+ }
312
+
313
+ const seen = new Set<number>();
314
+ const diagnostics: WardenDiagnostic[] = [];
315
+ const diagnoseCandidate = (node: AstNode | undefined): void => {
316
+ const unwrapped = unwrapExpression(node);
317
+ if (
318
+ unwrapped === undefined ||
319
+ unwrapped.type !== 'ObjectExpression' ||
320
+ seen.has(unwrapped.start) ||
321
+ !isTrailheadMapCandidate(unwrapped)
322
+ ) {
323
+ return;
324
+ }
325
+ seen.add(unwrapped.start);
326
+ diagnostics.push(
327
+ ...diagnoseTrailheadMap(sourceCode, filePath, unwrapped)
328
+ );
329
+ };
330
+
331
+ walk(ast, (node) => {
332
+ if (node.type === 'Property') {
333
+ const propertyName = getPropertyName(getNodeKey(node));
334
+ if (propertyName === 'trailheads') {
335
+ diagnoseCandidate(propertyValue(node));
336
+ }
337
+ return;
338
+ }
339
+
340
+ if (node.type === 'VariableDeclarator') {
341
+ const bindingName = getNodeName(getNodeId(node));
342
+ if (
343
+ typeof bindingName === 'string' &&
344
+ isTrailheadMapBindingName(bindingName)
345
+ ) {
346
+ diagnoseCandidate(getNodeInit(node) ?? undefined);
347
+ }
348
+ return;
349
+ }
350
+
351
+ if (
352
+ (node.type === 'TSAsExpression' ||
353
+ node.type === 'TSSatisfiesExpression') &&
354
+ hasTrailheadMapTypeAnnotation(sourceCode, node)
355
+ ) {
356
+ diagnoseCandidate(node);
357
+ }
358
+ });
359
+
360
+ return diagnostics;
361
+ },
362
+ description:
363
+ 'Coach trailhead maps away from selector overlap, hidden visibility widening, and drift-prone dynamic selectors.',
364
+ name: RULE_NAME,
365
+ severity: 'warn',
366
+ };