@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,262 @@
1
+ /**
2
+ * Coach the app-authored `surfaces` overlay toward coherent bindings.
3
+ *
4
+ * Reads the `surfaces` namespace from the serialized graph overlays and
5
+ * checks, per surface key, that every binding selector matches at least one
6
+ * trail, that grouped bindings do not overlap on expanded members, and that
7
+ * binding names do not shadow real surface entries (single-segment CLI
8
+ * routes, derived MCP tool names).
9
+ *
10
+ * Activation note: standard `trails warden` runs fire this rule. Warden's
11
+ * fresh topo loading collects the app-module overlays export through the
12
+ * shared `resolveTrailsOverlays` channel compile uses (TRL-1209 drift
13
+ * symmetry), so the topo-aware rule context graph carries the same overlays
14
+ * the committed lock embeds. Callers that supply a precomputed graph
15
+ * (committed locks, tests, the rule trail) keep working unchanged.
16
+ */
17
+
18
+ import {
19
+ SURFACES_OVERLAY_NAMESPACE,
20
+ classifySurfaceBinding,
21
+ deriveMcpToolName,
22
+ matchesTrailPattern,
23
+ surfaceBindingsFromLockOverlays,
24
+ } from '@ontrails/core';
25
+ import type {
26
+ SurfaceBindings,
27
+ SurfaceOverlayBindings,
28
+ Topo,
29
+ } from '@ontrails/core';
30
+ import type { TopoGraph } from '@ontrails/topography';
31
+
32
+ import type { TopoAwareWardenRule, WardenDiagnostic } from './types.js';
33
+
34
+ const RULE_NAME = 'surface-overlay-coherence';
35
+ const TOPO_FILE = '<topo>';
36
+
37
+ const SURFACE_KEYS = ['cli', 'http', 'mcp', 'ws'] as const;
38
+ type SurfaceKey = (typeof SURFACE_KEYS)[number];
39
+
40
+ const warn = (message: string): WardenDiagnostic => ({
41
+ filePath: TOPO_FILE,
42
+ line: 1,
43
+ message,
44
+ rule: RULE_NAME,
45
+ severity: 'warn',
46
+ });
47
+
48
+ const memberRefs = (value: SurfaceBindings[string]): readonly string[] => {
49
+ const shape = classifySurfaceBinding(value);
50
+ return shape.kind === 'synonym' ? [shape.trail] : shape.members;
51
+ };
52
+
53
+ const expandRefs = (
54
+ refs: readonly string[],
55
+ trailIds: readonly string[]
56
+ ): ReadonlySet<string> => {
57
+ const matched = new Set<string>();
58
+ for (const ref of refs) {
59
+ for (const trailId of trailIds) {
60
+ if (matchesTrailPattern(trailId, ref)) {
61
+ matched.add(trailId);
62
+ }
63
+ }
64
+ }
65
+ return matched;
66
+ };
67
+
68
+ const zeroMatchDiagnostics = (
69
+ surfaceKey: SurfaceKey,
70
+ bindings: SurfaceBindings,
71
+ trailIds: readonly string[]
72
+ ): readonly WardenDiagnostic[] => {
73
+ const diagnostics: WardenDiagnostic[] = [];
74
+ for (const [name, value] of Object.entries(bindings)) {
75
+ for (const ref of memberRefs(value)) {
76
+ if (!trailIds.some((trailId) => matchesTrailPattern(trailId, ref))) {
77
+ diagnostics.push(
78
+ warn(
79
+ `Surface overlay binding "${name}" on "${surfaceKey}" references "${ref}", which matches no trails in the topo. Point the binding at an existing trail id or dotted trail-id glob.`
80
+ )
81
+ );
82
+ }
83
+ }
84
+ }
85
+ return diagnostics;
86
+ };
87
+
88
+ interface ExpandedGroup {
89
+ readonly name: string;
90
+ readonly trailIds: ReadonlySet<string>;
91
+ }
92
+
93
+ const groupOverlapDiagnostics = (
94
+ surfaceKey: SurfaceKey,
95
+ bindings: SurfaceBindings,
96
+ trailIds: readonly string[]
97
+ ): readonly WardenDiagnostic[] => {
98
+ const groups: ExpandedGroup[] = [];
99
+ for (const [name, value] of Object.entries(bindings)) {
100
+ const shape = classifySurfaceBinding(value);
101
+ if (shape.kind === 'group') {
102
+ groups.push({ name, trailIds: expandRefs(shape.members, trailIds) });
103
+ }
104
+ }
105
+
106
+ const diagnostics: WardenDiagnostic[] = [];
107
+ for (let i = 0; i < groups.length; i += 1) {
108
+ const first = groups[i];
109
+ if (!first) {
110
+ continue;
111
+ }
112
+ for (let j = i + 1; j < groups.length; j += 1) {
113
+ const second = groups[j];
114
+ if (!second) {
115
+ continue;
116
+ }
117
+ const [sharedId] = [...second.trailIds]
118
+ .filter((trailId) => first.trailIds.has(trailId))
119
+ .toSorted();
120
+ if (sharedId !== undefined) {
121
+ diagnostics.push(
122
+ warn(
123
+ `Surface overlay group "${second.name}" on "${surfaceKey}" overlaps group "${first.name}" on trail "${sharedId}". Narrow one group so each trail has one grouped owner per surface.`
124
+ )
125
+ );
126
+ }
127
+ }
128
+ }
129
+ return diagnostics;
130
+ };
131
+
132
+ const cliCollisionDiagnostics = (
133
+ bindings: SurfaceBindings,
134
+ graph: TopoGraph
135
+ ): readonly WardenDiagnostic[] => {
136
+ const routeByName = new Map<
137
+ string,
138
+ { readonly kind: string; readonly trailId: string }
139
+ >();
140
+ for (const entry of graph.entries) {
141
+ if (entry.kind !== 'trail') {
142
+ continue;
143
+ }
144
+ const path = entry.cli?.path;
145
+ const segment = path?.length === 1 ? path[0] : undefined;
146
+ if (segment !== undefined) {
147
+ routeByName.set(segment, { kind: 'canonical', trailId: entry.id });
148
+ }
149
+ for (const route of entry.cli?.routes ?? []) {
150
+ // Surface-sourced alias routes are the projections of the overlay
151
+ // bindings themselves (TRL-1207), so a binding can never "shadow"
152
+ // one — only canonical paths and trail-owned aliases are real
153
+ // entries a binding name could collide with.
154
+ if (route.source === 'surface') {
155
+ continue;
156
+ }
157
+ const routeSegment = route.path.length === 1 ? route.path[0] : undefined;
158
+ if (routeSegment !== undefined && !routeByName.has(routeSegment)) {
159
+ routeByName.set(routeSegment, {
160
+ kind: route.kind,
161
+ trailId: entry.id,
162
+ });
163
+ }
164
+ }
165
+ }
166
+
167
+ const diagnostics: WardenDiagnostic[] = [];
168
+ for (const name of Object.keys(bindings)) {
169
+ const route = routeByName.get(name);
170
+ if (route !== undefined) {
171
+ diagnostics.push(
172
+ warn(
173
+ `Surface overlay binding "${name}" on "cli" shadows the ${route.kind} CLI route "${name}" for trail "${route.trailId}". Rename the binding so it does not shadow a real entry.`
174
+ )
175
+ );
176
+ }
177
+ }
178
+ return diagnostics;
179
+ };
180
+
181
+ const mcpCollisionDiagnostics = (
182
+ bindings: SurfaceBindings,
183
+ appName: string,
184
+ graph: TopoGraph
185
+ ): readonly WardenDiagnostic[] => {
186
+ const trailIdByToolName = new Map<string, string>();
187
+ for (const entry of graph.entries) {
188
+ if (entry.kind === 'trail') {
189
+ trailIdByToolName.set(deriveMcpToolName(appName, entry.id), entry.id);
190
+ }
191
+ }
192
+
193
+ const diagnostics: WardenDiagnostic[] = [];
194
+ for (const name of Object.keys(bindings)) {
195
+ const trailId = trailIdByToolName.get(name);
196
+ if (trailId !== undefined) {
197
+ diagnostics.push(
198
+ warn(
199
+ `Surface overlay binding "${name}" on "mcp" shadows the derived MCP tool name "${name}" for trail "${trailId}". Rename the binding so it does not shadow a real entry.`
200
+ )
201
+ );
202
+ }
203
+ }
204
+ return diagnostics;
205
+ };
206
+
207
+ const collectSurfaceDiagnostics = (
208
+ topo: Topo,
209
+ graph: TopoGraph,
210
+ overlayBindings: SurfaceOverlayBindings
211
+ ): readonly WardenDiagnostic[] => {
212
+ const trailIds = [...topo.trails.keys()];
213
+ const diagnostics: WardenDiagnostic[] = [];
214
+ for (const surfaceKey of SURFACE_KEYS) {
215
+ const bindings = overlayBindings[surfaceKey];
216
+ if (bindings === undefined) {
217
+ continue;
218
+ }
219
+ diagnostics.push(
220
+ ...zeroMatchDiagnostics(surfaceKey, bindings, trailIds),
221
+ ...groupOverlapDiagnostics(surfaceKey, bindings, trailIds)
222
+ );
223
+ if (surfaceKey === 'cli') {
224
+ diagnostics.push(...cliCollisionDiagnostics(bindings, graph));
225
+ }
226
+ if (surfaceKey === 'mcp') {
227
+ diagnostics.push(...mcpCollisionDiagnostics(bindings, topo.name, graph));
228
+ }
229
+ }
230
+ return diagnostics;
231
+ };
232
+
233
+ export const surfaceOverlayCoherence: TopoAwareWardenRule = {
234
+ checkTopo(topo, context) {
235
+ const graph = context?.graph;
236
+ if (graph?.overlays === undefined) {
237
+ return [];
238
+ }
239
+
240
+ let overlayBindings: SurfaceOverlayBindings | undefined;
241
+ try {
242
+ overlayBindings = surfaceBindingsFromLockOverlays(graph.overlays);
243
+ } catch (error: unknown) {
244
+ return [
245
+ warn(
246
+ `The "${SURFACES_OVERLAY_NAMESPACE}" overlay in the serialized graph is invalid: ${
247
+ error instanceof Error ? error.message : String(error)
248
+ }`
249
+ ),
250
+ ];
251
+ }
252
+ if (overlayBindings === undefined) {
253
+ return [];
254
+ }
255
+
256
+ return collectSurfaceDiagnostics(topo, graph, overlayBindings);
257
+ },
258
+ description:
259
+ 'Keep app-authored surface overlay bindings pointed at real trails without group overlap or canonical-entry shadowing.',
260
+ name: RULE_NAME,
261
+ severity: 'warn',
262
+ };
@@ -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
+ };