I have a SystemVerilog module that calculates a constant.
How can I make this constant available to an enclosing module?
You are able to use a hierarchical name reference to a parameter, localparam (constants), or variables in a child module.
Ref IEEE 1800 2017 SystemVerilog section 23.6 Hierarchical names
The ability to access objects using a hierarchical naming is not limited to just the testbench. Modules anywhere in the hierarchy can access other modules by using a hierarchical name.
The ability to access objects using a hierarchical naming is not limited to compile time/elaboration time. Objects that change at run time can be referenced also.
Here is a relevant quote from the spec section I referenced
"Any named SystemVerilog object or hierarchical name reference can be referenced uniquely in its full form
by concatenating the names of the modules, module instance names, generate blocks, tasks, functions,
assertion labels, named assertion action blocks, or named blocks that contain it. The period character shall
be used to separate each of the names in the hierarchy, except for escaped identifiers embedded in the
hierarchical name reference, which are followed by separators composed of white space and a periodcharacter."
Example
module tb ();
enclosing dut();
endmodule
Enclosing
module enclosing();
sub u1();
// accessing a variable in the child module using a hierarchical name
initial
$display(u1.FOO);
endmodule
Child module
module sub();
// calculate a constant
// the parent can't change this, its not passed in
localparam FOO = 7 * 7;
endmodule
Produces
xcelium> run
49
The ability to access a child module is not limited to simulation.
Xilinx states that hierarchical names are supported for synthesis
Vivado UG901 Synthesis Guide 2023 p288

It seems reasonable that not all synthesis tools support hierarchical names (I don't know). Maybe check your vendor if its not Xilinx.
If you don't trust the idea of hierarchical names, then create a small design which uses hierarchical names to do what you want then run it thru synthesis and examine the synthesis results as a verification step in your process. Or, deploy it to the lab and verify the behavior on hardware.
Parameters are good for passing constants into a submodule, but they don't seem appropriate for passing constants out of a submodule, because the enclosing module is allowed to set the parameter to whatever it wants (which is what I'm trying to avoid).
If you don't want to use a parameter, then use localparam in the child module.
See Difference between localparam and parameter
Or just use a parameter which is not part of the module header, so that the parent can't change it.
Like this
module sub();
parameter FOO = 7 * 7;
endmodule
...the enclosing module is allowed to set the parameter to whatever it wants
There is no requirement for any parameter to be part of a module header. If you don't want the parent module to change them, then don't put the parameter in question on the child module header. This seems to be a point of confusion in the question.
Hierarchical referencing works fine from a coding and tools perspective. However, some engineers prefer not to use it in RTL code. One reason is that when a module is blindly probed from somewhere else, the module itself is no longer completely specifies its own interface signals in its module header. It can be difficult to understand & maintaining a design, if its not clear how modules are connected. Consider the difficulty debugging a large design (hundreds of thousands of unique modules) that you don't know and many of the modules obtain their input not from the module ports but from sneak paths using hierarchal referencing.
Hierarchical referencing is often used for monitoring in testbenches.