@ontrails/warden 1.0.0-beta.3 → 1.0.0-beta.30

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 (260) hide show
  1. package/CHANGELOG.md +625 -9
  2. package/README.md +111 -26
  3. package/bin/warden.ts +50 -0
  4. package/package.json +28 -5
  5. package/src/adapter-check.ts +136 -0
  6. package/src/ast.ts +137 -0
  7. package/src/cli.ts +1415 -104
  8. package/src/command.ts +943 -0
  9. package/src/config.ts +184 -0
  10. package/src/draft.ts +22 -0
  11. package/src/drift.ts +122 -22
  12. package/src/fix.ts +120 -0
  13. package/src/formatters.ts +79 -9
  14. package/src/guide.ts +245 -0
  15. package/src/index.ts +215 -14
  16. package/src/project-context.ts +163 -0
  17. package/src/project-rules.ts +290 -0
  18. package/src/resolve.ts +531 -0
  19. package/src/rules/activation-orphan.ts +97 -0
  20. package/src/rules/ast.ts +4008 -86
  21. package/src/rules/circular-refs.ts +154 -0
  22. package/src/rules/cli-command-route-coherence.ts +177 -0
  23. package/src/rules/composes-declarations.ts +837 -0
  24. package/src/rules/context-no-surface-types.ts +78 -15
  25. package/src/rules/contour-exists.ts +251 -0
  26. package/src/rules/contour-ids.ts +15 -0
  27. package/src/rules/dead-internal-trail.ts +161 -0
  28. package/src/rules/dead-public-trail.ts +258 -0
  29. package/src/rules/draft-file-marking.ts +160 -0
  30. package/src/rules/draft-visible-debt.ts +87 -0
  31. package/src/rules/duplicate-public-contract.ts +91 -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 +735 -0
  35. package/src/rules/implementation-returns-result.ts +1411 -166
  36. package/src/rules/incomplete-accessor-for-standard-op.ts +272 -0
  37. package/src/rules/incomplete-crud.ts +581 -0
  38. package/src/rules/index.ts +234 -18
  39. package/src/rules/intent-propagation.ts +127 -0
  40. package/src/rules/layer-field-name-drift.ts +102 -0
  41. package/src/rules/library-projection-coherence.ts +100 -0
  42. package/src/rules/metadata.ts +755 -0
  43. package/src/rules/missing-reconcile.ts +98 -0
  44. package/src/rules/missing-visibility.ts +110 -0
  45. package/src/rules/no-destructured-compose.ts +194 -0
  46. package/src/rules/no-dev-permit-in-source.ts +99 -0
  47. package/src/rules/no-direct-implementation-call.ts +7 -7
  48. package/src/rules/no-legacy-layer-imports.ts +211 -0
  49. package/src/rules/no-native-error-result.ts +118 -0
  50. package/src/rules/no-redundant-result-error-wrap.ts +384 -0
  51. package/src/rules/no-retired-cross-vocabulary.ts +194 -0
  52. package/src/rules/no-sync-result-assumption.ts +1135 -98
  53. package/src/rules/no-throw-in-detour-recover.ts +225 -0
  54. package/src/rules/no-throw-in-implementation.ts +10 -9
  55. package/src/rules/no-top-level-surface.ts +371 -0
  56. package/src/rules/on-references-exist.ts +194 -0
  57. package/src/rules/orphaned-signal.ts +150 -0
  58. package/src/rules/owner-projection-parity.ts +143 -0
  59. package/src/rules/permit-governance.ts +25 -0
  60. package/src/rules/public-export-example-coverage.ts +562 -0
  61. package/src/rules/public-internal-deep-imports.ts +517 -0
  62. package/src/rules/public-output-schema.ts +29 -0
  63. package/src/rules/public-union-output-discriminants.ts +150 -0
  64. package/src/rules/read-intent-fires.ts +187 -0
  65. package/src/rules/reference-exists.ts +98 -0
  66. package/src/rules/registry-names.ts +155 -0
  67. package/src/rules/resolved-import-boundary.ts +146 -0
  68. package/src/rules/resource-declarations.ts +693 -0
  69. package/src/rules/resource-exists.ts +179 -0
  70. package/src/rules/resource-id-grammar.ts +65 -0
  71. package/src/rules/resource-mock-coverage.ts +115 -0
  72. package/src/rules/scan.ts +38 -25
  73. package/src/rules/scheduled-destroy-intent.ts +44 -0
  74. package/src/rules/signal-graph-coaching.ts +191 -0
  75. package/src/rules/specs.ts +9 -5
  76. package/src/rules/static-resource-accessor-preference.ts +649 -0
  77. package/src/rules/surface-facet-coherence.ts +362 -0
  78. package/src/rules/trail-fork-coaching.ts +616 -0
  79. package/src/rules/trail-versioning-source.ts +1072 -0
  80. package/src/rules/trail-versioning-topo.ts +172 -0
  81. package/src/rules/types.ts +272 -6
  82. package/src/rules/unmaterialized-activation-source.ts +84 -0
  83. package/src/rules/unreachable-detour-shadowing.ts +339 -0
  84. package/src/rules/valid-describe-refs.ts +162 -32
  85. package/src/rules/valid-detour-contract.ts +78 -0
  86. package/src/rules/warden-export-symmetry.ts +540 -0
  87. package/src/rules/warden-rules-use-ast.ts +1114 -0
  88. package/src/rules/webhook-route-collision.ts +243 -0
  89. package/src/trails/activation-orphan.trail.ts +84 -0
  90. package/src/trails/circular-refs.trail.ts +29 -0
  91. package/src/trails/cli-command-route-coherence.trail.ts +47 -0
  92. package/src/trails/composes-declarations.trail.ts +22 -0
  93. package/src/trails/context-no-surface-types.trail.ts +21 -0
  94. package/src/trails/contour-exists.trail.ts +21 -0
  95. package/src/trails/dead-internal-trail.trail.ts +26 -0
  96. package/src/trails/dead-public-trail.trail.ts +31 -0
  97. package/src/trails/deprecation-without-guidance.trail.ts +21 -0
  98. package/src/trails/draft-file-marking.trail.ts +16 -0
  99. package/src/trails/draft-visible-debt.trail.ts +16 -0
  100. package/src/trails/duplicate-public-contract.trail.ts +47 -0
  101. package/src/trails/error-mapping-completeness.trail.ts +30 -0
  102. package/src/trails/example-valid.trail.ts +25 -0
  103. package/src/trails/fires-declarations.trail.ts +23 -0
  104. package/src/trails/fork-without-preserved-blaze.trail.ts +31 -0
  105. package/src/trails/implementation-returns-result.trail.ts +20 -0
  106. package/src/trails/incomplete-accessor-for-standard-op.trail.ts +76 -0
  107. package/src/trails/incomplete-crud.trail.ts +39 -0
  108. package/src/trails/index.ts +83 -0
  109. package/src/trails/intent-propagation.trail.ts +30 -0
  110. package/src/trails/layer-field-name-drift.trail.ts +39 -0
  111. package/src/trails/library-projection-coherence.trail.ts +43 -0
  112. package/src/trails/marker-schema-unsupported.trail.ts +23 -0
  113. package/src/trails/missing-reconcile.trail.ts +33 -0
  114. package/src/trails/missing-visibility.trail.ts +22 -0
  115. package/src/trails/no-destructured-compose.trail.ts +44 -0
  116. package/src/trails/no-dev-permit-in-source.trail.ts +16 -0
  117. package/src/trails/no-direct-implementation-call.trail.ts +16 -0
  118. package/src/trails/no-legacy-layer-imports.trail.ts +41 -0
  119. package/src/trails/no-native-error-result.trail.ts +18 -0
  120. package/src/trails/no-redundant-result-error-wrap.trail.ts +55 -0
  121. package/src/trails/no-retired-cross-vocabulary.trail.ts +42 -0
  122. package/src/trails/no-sync-result-assumption.trail.ts +19 -0
  123. package/src/trails/no-throw-in-detour-recover.trail.ts +24 -0
  124. package/src/trails/no-throw-in-implementation.trail.ts +20 -0
  125. package/src/trails/no-top-level-surface.trail.ts +43 -0
  126. package/src/trails/on-references-exist.trail.ts +21 -0
  127. package/src/trails/orphaned-signal.trail.ts +36 -0
  128. package/src/trails/owner-projection-parity.trail.ts +26 -0
  129. package/src/trails/pending-force.trail.ts +21 -0
  130. package/src/trails/permit-governance.trail.ts +51 -0
  131. package/src/trails/prefer-schema-inference.trail.ts +21 -0
  132. package/src/trails/public-export-example-coverage.trail.ts +16 -0
  133. package/src/trails/public-internal-deep-imports.trail.ts +94 -0
  134. package/src/trails/public-output-schema.trail.ts +55 -0
  135. package/src/trails/public-union-output-discriminants.trail.ts +33 -0
  136. package/src/trails/read-intent-fires.trail.ts +20 -0
  137. package/src/trails/reference-exists.trail.ts +25 -0
  138. package/src/trails/resolved-import-boundary.trail.ts +109 -0
  139. package/src/trails/resource-declarations.trail.ts +25 -0
  140. package/src/trails/resource-exists.trail.ts +27 -0
  141. package/src/trails/resource-id-grammar.trail.ts +39 -0
  142. package/src/trails/resource-mock-coverage.trail.ts +40 -0
  143. package/src/trails/run.ts +162 -0
  144. package/src/trails/scheduled-destroy-intent.trail.ts +56 -0
  145. package/src/trails/schema.ts +198 -0
  146. package/src/trails/signal-graph-coaching.trail.ts +77 -0
  147. package/src/trails/static-resource-accessor-preference.trail.ts +25 -0
  148. package/src/trails/surface-facet-coherence.trail.ts +25 -0
  149. package/src/trails/topo.ts +6 -0
  150. package/src/trails/trail-fork-coaching.trail.ts +42 -0
  151. package/src/trails/unmaterialized-activation-source.trail.ts +72 -0
  152. package/src/trails/unreachable-detour-shadowing.trail.ts +45 -0
  153. package/src/trails/valid-describe-refs.trail.ts +18 -0
  154. package/src/trails/valid-detour-contract.trail.ts +71 -0
  155. package/src/trails/version-gap.trail.ts +35 -0
  156. package/src/trails/version-pinned-compose.trail.ts +23 -0
  157. package/src/trails/version-without-examples.trail.ts +38 -0
  158. package/src/trails/warden-export-symmetry.trail.ts +16 -0
  159. package/src/trails/warden-rules-use-ast.trail.ts +64 -0
  160. package/src/trails/webhook-route-collision.trail.ts +50 -0
  161. package/src/trails/wrap-rule.ts +214 -0
  162. package/src/workspaces.ts +238 -0
  163. package/.turbo/turbo-build.log +0 -1
  164. package/.turbo/turbo-lint.log +0 -3
  165. package/.turbo/turbo-typecheck.log +0 -1
  166. package/dist/cli.d.ts +0 -46
  167. package/dist/cli.d.ts.map +0 -1
  168. package/dist/cli.js +0 -221
  169. package/dist/cli.js.map +0 -1
  170. package/dist/drift.d.ts +0 -26
  171. package/dist/drift.d.ts.map +0 -1
  172. package/dist/drift.js +0 -27
  173. package/dist/drift.js.map +0 -1
  174. package/dist/formatters.d.ts +0 -29
  175. package/dist/formatters.d.ts.map +0 -1
  176. package/dist/formatters.js +0 -87
  177. package/dist/formatters.js.map +0 -1
  178. package/dist/index.d.ts +0 -26
  179. package/dist/index.d.ts.map +0 -1
  180. package/dist/index.js +0 -26
  181. package/dist/index.js.map +0 -1
  182. package/dist/rules/ast.d.ts +0 -41
  183. package/dist/rules/ast.d.ts.map +0 -1
  184. package/dist/rules/ast.js +0 -163
  185. package/dist/rules/ast.js.map +0 -1
  186. package/dist/rules/context-no-surface-types.d.ts +0 -12
  187. package/dist/rules/context-no-surface-types.d.ts.map +0 -1
  188. package/dist/rules/context-no-surface-types.js +0 -96
  189. package/dist/rules/context-no-surface-types.js.map +0 -1
  190. package/dist/rules/implementation-returns-result.d.ts +0 -13
  191. package/dist/rules/implementation-returns-result.d.ts.map +0 -1
  192. package/dist/rules/implementation-returns-result.js +0 -277
  193. package/dist/rules/implementation-returns-result.js.map +0 -1
  194. package/dist/rules/index.d.ts +0 -22
  195. package/dist/rules/index.d.ts.map +0 -1
  196. package/dist/rules/index.js +0 -41
  197. package/dist/rules/index.js.map +0 -1
  198. package/dist/rules/no-direct-impl-in-route.d.ts +0 -12
  199. package/dist/rules/no-direct-impl-in-route.d.ts.map +0 -1
  200. package/dist/rules/no-direct-impl-in-route.js +0 -46
  201. package/dist/rules/no-direct-impl-in-route.js.map +0 -1
  202. package/dist/rules/no-direct-implementation-call.d.ts +0 -12
  203. package/dist/rules/no-direct-implementation-call.d.ts.map +0 -1
  204. package/dist/rules/no-direct-implementation-call.js +0 -39
  205. package/dist/rules/no-direct-implementation-call.js.map +0 -1
  206. package/dist/rules/no-sync-result-assumption.d.ts +0 -6
  207. package/dist/rules/no-sync-result-assumption.d.ts.map +0 -1
  208. package/dist/rules/no-sync-result-assumption.js +0 -98
  209. package/dist/rules/no-sync-result-assumption.js.map +0 -1
  210. package/dist/rules/no-throw-in-detour-target.d.ts +0 -12
  211. package/dist/rules/no-throw-in-detour-target.d.ts.map +0 -1
  212. package/dist/rules/no-throw-in-detour-target.js +0 -87
  213. package/dist/rules/no-throw-in-detour-target.js.map +0 -1
  214. package/dist/rules/no-throw-in-implementation.d.ts +0 -9
  215. package/dist/rules/no-throw-in-implementation.d.ts.map +0 -1
  216. package/dist/rules/no-throw-in-implementation.js +0 -34
  217. package/dist/rules/no-throw-in-implementation.js.map +0 -1
  218. package/dist/rules/prefer-schema-inference.d.ts +0 -7
  219. package/dist/rules/prefer-schema-inference.d.ts.map +0 -1
  220. package/dist/rules/prefer-schema-inference.js +0 -86
  221. package/dist/rules/prefer-schema-inference.js.map +0 -1
  222. package/dist/rules/scan.d.ts +0 -8
  223. package/dist/rules/scan.d.ts.map +0 -1
  224. package/dist/rules/scan.js +0 -32
  225. package/dist/rules/scan.js.map +0 -1
  226. package/dist/rules/specs.d.ts +0 -29
  227. package/dist/rules/specs.d.ts.map +0 -1
  228. package/dist/rules/specs.js +0 -192
  229. package/dist/rules/specs.js.map +0 -1
  230. package/dist/rules/structure.d.ts +0 -13
  231. package/dist/rules/structure.d.ts.map +0 -1
  232. package/dist/rules/structure.js +0 -142
  233. package/dist/rules/structure.js.map +0 -1
  234. package/dist/rules/types.d.ts +0 -52
  235. package/dist/rules/types.d.ts.map +0 -1
  236. package/dist/rules/types.js +0 -2
  237. package/dist/rules/types.js.map +0 -1
  238. package/dist/rules/valid-describe-refs.d.ts +0 -7
  239. package/dist/rules/valid-describe-refs.d.ts.map +0 -1
  240. package/dist/rules/valid-describe-refs.js +0 -51
  241. package/dist/rules/valid-describe-refs.js.map +0 -1
  242. package/dist/rules/valid-detour-refs.d.ts +0 -6
  243. package/dist/rules/valid-detour-refs.d.ts.map +0 -1
  244. package/dist/rules/valid-detour-refs.js +0 -116
  245. package/dist/rules/valid-detour-refs.js.map +0 -1
  246. package/src/__tests__/cli.test.ts +0 -198
  247. package/src/__tests__/drift.test.ts +0 -74
  248. package/src/__tests__/formatters.test.ts +0 -157
  249. package/src/__tests__/implementation-returns-result.test.ts +0 -128
  250. package/src/__tests__/no-direct-implementation-call.test.ts +0 -83
  251. package/src/__tests__/no-sync-result-assumption.test.ts +0 -85
  252. package/src/__tests__/no-throw-in-detour-target.test.ts +0 -78
  253. package/src/__tests__/prefer-schema-inference.test.ts +0 -84
  254. package/src/__tests__/rules.test.ts +0 -215
  255. package/src/__tests__/valid-describe-refs.test.ts +0 -60
  256. package/src/rules/no-direct-impl-in-route.ts +0 -77
  257. package/src/rules/no-throw-in-detour-target.ts +0 -150
  258. package/src/rules/valid-detour-refs.ts +0 -187
  259. package/tsconfig.json +0 -9
  260. package/tsconfig.tsbuildinfo +0 -1
@@ -0,0 +1,362 @@
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 './ast.js';
22
+ import type { AstNode } from './ast.js';
23
+ import type { WardenDiagnostic, WardenRule } from './types.js';
24
+
25
+ const RULE_NAME = 'surface-facet-coherence';
26
+
27
+ interface FacetSelector {
28
+ readonly facetId: 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 isFacetDefinition = (node: AstNode): boolean =>
72
+ node.type === 'ObjectExpression' &&
73
+ findConfigProperty(node, 'trails') !== null;
74
+
75
+ const isFacetMapCandidate = (node: AstNode): boolean =>
76
+ objectProperties(node).some((property) => {
77
+ const value = unwrapExpression(propertyValue(property));
78
+ return value !== undefined && isFacetDefinition(value);
79
+ });
80
+
81
+ const isFacetMapBindingName = (name: string | null): boolean =>
82
+ name !== null &&
83
+ (name === 'facets' || name.endsWith('Facets') || name.endsWith('FacetMap'));
84
+
85
+ const hasFacetMapTypeAnnotation = (
86
+ sourceCode: string,
87
+ node: AstNode
88
+ ): boolean => {
89
+ const typeAnnotation = getNodeTypeAnnotation(node);
90
+ return (
91
+ typeAnnotation !== undefined &&
92
+ /\b(?:McpSurfaceFacetMap|TopoGraphFacetDeclaration|FacetMap)\b/.test(
93
+ sourceCode.slice(typeAnnotation.start, typeAnnotation.end)
94
+ )
95
+ );
96
+ };
97
+
98
+ const selectorNodes = (trailsNode: AstNode): readonly AstNode[] | null => {
99
+ const value = unwrapExpression(trailsNode);
100
+ if (!value) {
101
+ return null;
102
+ }
103
+ if (value.type === 'ArrayExpression') {
104
+ return getNodeElements(value).filter((element) => element !== null);
105
+ }
106
+ return [value];
107
+ };
108
+
109
+ const collectLiteralSelectors = (
110
+ sourceCode: string,
111
+ filePath: string,
112
+ facetId: string,
113
+ trailsProp: AstNode,
114
+ diagnostics: WardenDiagnostic[]
115
+ ): readonly FacetSelector[] => {
116
+ const trailsValue = propertyValue(trailsProp);
117
+ const nodes = trailsValue ? selectorNodes(trailsValue) : null;
118
+ if (nodes === null || nodes.length === 0) {
119
+ diagnostics.push(
120
+ diagnostic(
121
+ sourceCode,
122
+ filePath,
123
+ trailsProp,
124
+ `Surface facet "${facetId}" uses a dynamic trails selector. Keep facet selectors as string literals so Warden can check overlap and drift.`
125
+ )
126
+ );
127
+ return [];
128
+ }
129
+
130
+ const selectors: FacetSelector[] = [];
131
+ for (const node of nodes) {
132
+ const selectorValue = extractStringOrTemplateLiteral(node);
133
+ if (selectorValue === null) {
134
+ diagnostics.push(
135
+ diagnostic(
136
+ sourceCode,
137
+ filePath,
138
+ node,
139
+ `Surface facet "${facetId}" uses a dynamic trails selector. Keep facet selectors as string literals so Warden can check overlap and drift.`
140
+ )
141
+ );
142
+ continue;
143
+ }
144
+ selectors.push({ facetId, node, value: selectorValue });
145
+ }
146
+ return selectors;
147
+ };
148
+
149
+ const hasNonEmptyDescription = (definition: AstNode): boolean => {
150
+ const descriptionProp = findConfigProperty(definition, 'description');
151
+ const value = unwrapExpression(propertyValue(descriptionProp ?? definition));
152
+ const description = extractStringOrTemplateLiteral(value);
153
+ return typeof description === 'string' && description.trim().length > 0;
154
+ };
155
+
156
+ const literalStringProperty = (
157
+ definition: AstNode,
158
+ propertyName: string
159
+ ): string | null => {
160
+ const prop = findConfigProperty(definition, propertyName);
161
+ if (!prop) {
162
+ return null;
163
+ }
164
+ return extractStringOrTemplateLiteral(unwrapExpression(propertyValue(prop)));
165
+ };
166
+
167
+ const literalBooleanProperty = (
168
+ definition: AstNode,
169
+ propertyName: string
170
+ ): boolean | null => {
171
+ const prop = findConfigProperty(definition, propertyName);
172
+ if (!prop) {
173
+ return null;
174
+ }
175
+ return literalBooleanValue(unwrapExpression(propertyValue(prop)));
176
+ };
177
+
178
+ const selectorsMayOverlap = (
179
+ first: FacetSelector,
180
+ second: FacetSelector
181
+ ): boolean =>
182
+ first.value === second.value ||
183
+ matchesTrailPattern(first.value, second.value) ||
184
+ matchesTrailPattern(second.value, first.value);
185
+
186
+ const diagnoseFacetDefinition = (
187
+ sourceCode: string,
188
+ filePath: string,
189
+ facetId: string,
190
+ definition: AstNode
191
+ ): {
192
+ readonly diagnostics: readonly WardenDiagnostic[];
193
+ readonly selectors: readonly FacetSelector[];
194
+ } => {
195
+ const diagnostics: WardenDiagnostic[] = [];
196
+ const trailsProp = findConfigProperty(definition, 'trails');
197
+ const selectors =
198
+ trailsProp === null
199
+ ? []
200
+ : collectLiteralSelectors(
201
+ sourceCode,
202
+ filePath,
203
+ facetId,
204
+ trailsProp,
205
+ diagnostics
206
+ );
207
+
208
+ if (!hasNonEmptyDescription(definition)) {
209
+ diagnostics.push(
210
+ diagnostic(
211
+ sourceCode,
212
+ filePath,
213
+ definition,
214
+ `Surface facet "${facetId}" needs a non-empty description so MCP clients and agents can choose it without guessing.`
215
+ )
216
+ );
217
+ }
218
+
219
+ const visibility = literalStringProperty(definition, 'visibility');
220
+ const wideningAccepted = literalBooleanProperty(
221
+ definition,
222
+ 'visibilityWideningAccepted'
223
+ );
224
+ if (visibility === 'public' && wideningAccepted !== true) {
225
+ diagnostics.push(
226
+ diagnostic(
227
+ sourceCode,
228
+ filePath,
229
+ definition,
230
+ `Surface facet "${facetId}" explicitly sets public visibility without visibilityWideningAccepted: true. Facets must not accidentally widen hidden trails.`
231
+ )
232
+ );
233
+ }
234
+
235
+ if (
236
+ wideningAccepted === true &&
237
+ !literalStringProperty(definition, 'descriptionStableThrough')
238
+ ) {
239
+ diagnostics.push(
240
+ diagnostic(
241
+ sourceCode,
242
+ filePath,
243
+ definition,
244
+ `Surface facet "${facetId}" accepts visibility widening but does not record descriptionStableThrough review metadata.`
245
+ )
246
+ );
247
+ }
248
+
249
+ return { diagnostics, selectors };
250
+ };
251
+
252
+ const diagnoseFacetMap = (
253
+ sourceCode: string,
254
+ filePath: string,
255
+ facetMap: AstNode
256
+ ): readonly WardenDiagnostic[] => {
257
+ const diagnostics: WardenDiagnostic[] = [];
258
+ const selectors: FacetSelector[] = [];
259
+
260
+ for (const property of objectProperties(facetMap)) {
261
+ const facetId = getPropertyName(getNodeKey(property));
262
+ const value = unwrapExpression(propertyValue(property));
263
+ if (!facetId || value === undefined || !isFacetDefinition(value)) {
264
+ continue;
265
+ }
266
+ const result = diagnoseFacetDefinition(
267
+ sourceCode,
268
+ filePath,
269
+ facetId,
270
+ value
271
+ );
272
+ diagnostics.push(...result.diagnostics);
273
+ selectors.push(...result.selectors);
274
+ }
275
+
276
+ for (let i = 0; i < selectors.length; i += 1) {
277
+ const first = selectors[i];
278
+ if (!first) {
279
+ continue;
280
+ }
281
+ for (let j = i + 1; j < selectors.length; j += 1) {
282
+ const second = selectors[j];
283
+ if (
284
+ !second ||
285
+ first.facetId === second.facetId ||
286
+ !selectorsMayOverlap(first, second)
287
+ ) {
288
+ continue;
289
+ }
290
+ diagnostics.push(
291
+ diagnostic(
292
+ sourceCode,
293
+ filePath,
294
+ second.node,
295
+ `Surface facet selector "${second.value}" in "${second.facetId}" overlaps selector "${first.value}" in "${first.facetId}". Narrow one facet so each public trail has one MCP owner.`
296
+ )
297
+ );
298
+ }
299
+ }
300
+
301
+ return diagnostics;
302
+ };
303
+
304
+ export const surfaceFacetCoherence: WardenRule = {
305
+ check(sourceCode, filePath) {
306
+ const ast = parse(filePath, sourceCode);
307
+ if (!ast) {
308
+ return [];
309
+ }
310
+
311
+ const seen = new Set<number>();
312
+ const diagnostics: WardenDiagnostic[] = [];
313
+ const diagnoseCandidate = (node: AstNode | undefined): void => {
314
+ const unwrapped = unwrapExpression(node);
315
+ if (
316
+ unwrapped === undefined ||
317
+ unwrapped.type !== 'ObjectExpression' ||
318
+ seen.has(unwrapped.start) ||
319
+ !isFacetMapCandidate(unwrapped)
320
+ ) {
321
+ return;
322
+ }
323
+ seen.add(unwrapped.start);
324
+ diagnostics.push(...diagnoseFacetMap(sourceCode, filePath, unwrapped));
325
+ };
326
+
327
+ walk(ast, (node) => {
328
+ if (node.type === 'Property') {
329
+ const propertyName = getPropertyName(getNodeKey(node));
330
+ if (propertyName === 'facets') {
331
+ diagnoseCandidate(propertyValue(node));
332
+ }
333
+ return;
334
+ }
335
+
336
+ if (node.type === 'VariableDeclarator') {
337
+ const bindingName = getNodeName(getNodeId(node));
338
+ if (
339
+ typeof bindingName === 'string' &&
340
+ isFacetMapBindingName(bindingName)
341
+ ) {
342
+ diagnoseCandidate(getNodeInit(node) ?? undefined);
343
+ }
344
+ return;
345
+ }
346
+
347
+ if (
348
+ (node.type === 'TSAsExpression' ||
349
+ node.type === 'TSSatisfiesExpression') &&
350
+ hasFacetMapTypeAnnotation(sourceCode, node)
351
+ ) {
352
+ diagnoseCandidate(node);
353
+ }
354
+ });
355
+
356
+ return diagnostics;
357
+ },
358
+ description:
359
+ 'Coach surface facet maps away from selector overlap, hidden visibility widening, and drift-prone dynamic selectors.',
360
+ name: RULE_NAME,
361
+ severity: 'warn',
362
+ };