@ontrails/warden 1.0.0-beta.2 → 1.0.0-beta.21

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