test Guard the presenter boundary

Add a guard asserting the presenter (pages.ml, pages.mli) references no inner-circle module — Evidence, Prescription, Exercise, Recovery, Progression, Repository, Trainee, Catalog, or Service. Only view models cross into it. The check matches module access (Name.) so UI label strings like "Exercise" do not trip it. A future change that passes an entity into a page now fails the build.

Commit
1bf2182f88119b6964539232ff09e1e7c537e723
Author
Marius Peter <dev@marius-peter.com>
Author date
Committer
Marius Peter <dev@marius-peter.com>
Committer date
test/test_architecture.ml
index 7c2add85..890607a2 100644..100644
@@ -210,4 +210,61 @@
210 210 (List.mem "hito.persistence" libs) );
211 211 ]
212 212
213 Removed: let suite = [ ("architecture.dependency_rule", dependency_rule) ]
213 Added: (* The presenter (Pages) is an interface adapter: only view models cross into
214 Added: it. It must name no core entity and no repository type — those reach it as
215 Added: plain DTOs from the controller (Handlers). This guard scans the two presenter
216 Added: files directly, since Handlers in the same layer legitimately names
217 Added: entities to build the DTOs. *)
218 Added: let presenter_boundary =
219 Added: let forbidden =
220 Added: [
221 Added: "Evidence";
222 Added: "Prescription";
223 Added: "Exercise";
224 Added: "Recovery";
225 Added: "Progression";
226 Added: "Repository";
227 Added: "Trainee";
228 Added: "Catalog";
229 Added: "Service";
230 Added: ]
231 Added: in
232 Added: let is_word_char c =
233 Added: (c >= 'A' && c <= 'Z')
234 Added: || (c >= 'a' && c <= 'z')
235 Added: || (c >= '0' && c <= '9')
236 Added: || c = '_'
237 Added: in
238 Added: let mentions name text =
239 Added: (* Match a module reference: the name followed by '.', with no identifier
240 Added: char before it. This flags [Evidence.Workout] but not the label string
241 Added: "Exercise". *)
242 Added: let n = String.length name and h = String.length text in
243 Added: let rec scan i =
244 Added: if i + n >= h then false
245 Added: else if String.sub text i n = name && text.[i + n] = '.' then
246 Added: let before = i = 0 || not (is_word_char text.[i - 1]) in
247 Added: if before then true else scan (i + 1)
248 Added: else scan (i + 1)
249 Added: in
250 Added: scan 0
251 Added: in
252 Added: let check file =
253 Added: ( Printf.sprintf "the presenter (%s) names no inner-circle entity"
254 Added: (Filename.basename file),
255 Added: `Quick,
256 Added: fun () ->
257 Added: let text = read_file (Filename.concat (lib "web") file) in
258 Added: List.iter
259 Added: (fun name ->
260 Added: if mentions name text then
261 Added: Alcotest.failf "%s names inner-circle %S" file name)
262 Added: forbidden )
263 Added: in
264 Added: [ check "pages.ml"; check "pages.mli" ]
265 Added:
266 Added: let suite =
267 Added: [
268 Added: ("architecture.dependency_rule", dependency_rule);
269 Added: ("architecture.presenter_boundary", presenter_boundary);
270 Added: ]