PR#49 Link Visit with Vet

✅◔7/10

Review

PR description

Closes #37.

A visit can now record the vet who attended it, and the vet is optional. You can pick a vet when booking or editing a visit, or leave it empty, since the vet is often unknown when the appointment is made. The owner's page, the all-visits screen and the chatbot's visit list now show each visit's vet. A visit without one reads "No vet"; that includes every visit created before this change. The data model is a nullable visits.vet_id (migration V4). The API adds vetId plus read-only vet first and last names to the visit payloads. A few demo seed visits get a vet so both states show up.

Two decisions deserve a reviewer's eye. First, editing a visit (PUT) replaces it whole, so a request without vetId clears the vet. That is what makes clearing the field persist, as the ticket requires, but a client that omits the field will also wipe the vet. Second, deleting a vet keeps their visits and sets the vet to none (ON DELETE SET NULL); refusing the delete would also be defensible. Still open: an unknown vetId returns 404, the same as an unknown pet type, rather than 400. The chatbot's create_visit can't choose a vet. The new Angular specs have only run in CI, since npm couldn't run in my session. Separately, this branch's CI cancels itself: the push and pull-request runs share one concurrency group, so CI on the fix commit only went green after a manual re-run. That workflow fix belongs on main.

Merge risk

  • Two-way door: ✅ a nullable vet column that a revert simply stops using; but a PUT that omits vetId clears the vet
  • Blast radius: ◔ visits API (about a quarter of the system)

🤖: 3 assumptions / : 9 open review issues · 4 auto fixed

Show what will be posted

    Round I · while coding

    Implementation assumptions

    Where the ticket was ambiguous: the reading the coder chose.

    1. VS Codeassumption(60%) Deleting a vet keeps their visits, with no vet

      What I didn’t do: refuse deleting a vet who has visits

      Why 60%: Ticket item 4 makes "no vet" a normal state, and before this change vet deletion always succeeded. Losing who attended is a real history loss, so a business owner could reasonably pick refusal.

      4ALTER TABLE visits ADD COLUMN vet_id INT REFERENCES vets (id) ON DELETE SET NULL;
    2. VS Codeassumption(65%) Seed gives a vet to some demo visits only

      What I didn’t do: leave every seeded visit without a vet

      Why 65%: The demo should show both states; ticket item 4 is about real legacy rows, not the demo dataset.

      +142UPDATE visits SET vet_id = 1  -- James Carter
      +143  WHERE pet_id = 3 AND visit_date IN (DATE '2026-05-03', DATE '2025-08-21');  -- Milton
      +144
       145INSERT INTO users (username, password, enabled) VALUES
    3. VS Codeassumption(75%) "No vet" is the wording for a visit without one

      What I didn’t do: "—" or "Not assigned"

      Why 75%: Ticket item 4 rules out "Unknown" and blank; "No vet" says "has none" literally, and the same label serves as the combo placeholder.

      5export const NO_VET = 'No vet';

    Round II · code review

    Open review issues

    Left in the code, each with its reason. They stay open until you agree or disagree.

    1. VS Codecorrectnesssecurityticket-fittestsnit PUT without vetId clears the stored vet

      Reviewer: A client that omits vetId wipes the existing vet; "leave unchanged" cannot be told from "clear".

      PUT replaces the whole visit, so a clear always sticks (ticket item 2); our form always sends vetId.

      +85currentVisit.setVet(vetRepository.findByIdOrNull(visitDto.getVetId()));
    2. VS Codecorrectnesssecurityticket-fitnit Unknown vetId answers a bare 404, not a 400

      Reviewer: A stale or bogus vetId fails the booking or edit with the generic "Not found!" 404, indistinguishable from a missing visit or pet.

      same as unknown pet types, OwnerRestController.java:150; a 400 belongs in a codebase-wide change.

      +20return id == null ? null : findById(id).orElseThrow();
    3. VS Codeticket-fitnit Chatbot create_visit cannot choose a vet

      Reviewer: Bookings made through the chatbot always have no vet.

      "everywhere" covers display, which list_visits does; booking a vet stays a staff action.

       55@Transactional
    4. VS Codecorrectnessnit Failed vets fetch shows "No vet" for a visit with one

      Reviewer: The error handler turns a failed GET /vets into [], so the combo finds no match and displays the placeholder.

      model keeps the old vetId, so saving does not clear it; display-only, error path.

      +43this.vetService.getVets().subscribe(vets => this.vetOptions = toVetOptions(vets));
    5. VS Codesecuritycontext Vet ids can be enumerated via the 404

      Reviewer: Probing vetIds on booking reveals which vets exist.

      GET /api/vets lists every vet already; nothing is disclosed.

      +20return id == null ? null : findById(id).orElseThrow();
    6. VS Codesecuritycontext Visits expose vet names to every caller

      Reviewer: Visit DTOs now carry the vet's first and last name.

      Visit endpoints are OWNER_ADMIN-only and vet names are already public via /api/vets.

      +51visitDto.setVetId(vet.getId())
      +52        .setVetFirstName(vet.getFirstName())
      +53        .setVetLastName(vet.getLastName());
    7. VS Codesecuritycontext Any existing vet can be attributed to a visit

      Reviewer: No check that the chosen vet may attend the visit.

      choosing any vet is the feature; the ticket sets no eligibility rule.

       192@WithSpan("book-visit")
    8. VS Codetestscontext Clearing the combo could send '' or 0

      Reviewer: The edit spec feeds onSubmit a hand-built null; if the combo emitted '' or 0 on clear, the old vet or a 404 would follow.

      pick('') sets null, line 57; covered by the combo's own spec.

       57this.value = index === '' ? null : this.valueOf(option);
    9. VS Codetestscontext Pipe renders "null null" with vetId but no names

      Reviewer: A visit with vetId set but names missing would print "null null".

      VisitMapper.java:51-53 sets id and both names together from one entity.

      21return visit.vetId == null ? NO_VET : `${visit.vetFirstName} ${visit.vetLastName}`;

    Round III · fixing the review

    Auto-fixed

    Fixed by the review in 137 fix commits: be762955, 811a2fdd, 0c3f5d70, ca6b33ed, 33977c2c, 5a4cf83f, 20e1df32, 51500ddc, 1044914d, 2f17c049, 1d6719fb, fe553439, 1114f3ba, d01ef08c, 9e0ef169, 863c54c7, 08b14f13, b32b9f11, a0254476, 6b4b63be, 44689ad3, 2090d785, ab8862c5, db57f1b4, 24af8d66, b6b53c65, 37aa569c, 10d7ced1, 1f12a64e, 99aefebe, 010682cf, 28f30dcc, c68428db, 316a8065, 4384ff57, d99781fb, 014a6f1e, ba74d040, f7ebe11c, e006175b, 846f8718, 286ac3de, cdfb429f, 5970eab8, a6baee9d, 39cbb618, 295c2c12, 75815871, aeb84cc9, 556ffafc, 9019ad8d, 4ee969ff, 07472b14, f1c7da79, 39346ec5, a931962a, 566aed82, 6c6c3c35, f39b3425, aa067ee6, c790590b, e5106841, 056347a0, 1bd1e444, a0fc27ec, 664c5b3b, 74fbab95, fe3c171f, ce0e3ce4, 6d5eb02f, 0084b881, 1c3773a6, 714dab3c, ec9fac90, 7ac6fd59, fdce1e71, fed20f04, f4aca5b4, 4cb349de, e27182cb, 640816dd, 7648a4c7, 2bc95b84, a994cf64, eb08ca73, 7ec62a12, 6ebaae49, 9e54ed7c, 21503874, 085415a7, 8525cdd9, f717490d, 1521fa41, 051b601f, 0f36e282, b2bcb848, fe937ab2, 995fb438, fdb1d23d, 7e688e70, 3b8f5ee2, 07a310ba, c70d6642, 01041dae, 66e79f99, 78f54394, d5ff445c, 15adb135, d2efb2c3, 18f5bd4a, d0a6aab7, 0e3754f1, 1df03d9b, c4b3769e, 09710767, 4c0ba36c (the fixes); 5f84b2cd, c31ba5ec, 5448526f, 53c1b7c1, 7518be26, adbcb0c5, 618255dd, b5bf4153, 3da09978, 49c7f06c, 8373788e, 3bad25fb, 788c63dc, 96fbcbb2, 6c3efe01, 94621c91, 521a3f04, 80ce4c81, 2a449317, 2cecfb1c, 088f2f28 only re-record review-points.md. Diffs against 1bcf74d4, the implementation commit. 2 commits carry a Review-Points: trailer; the page reads them as one round of fixes, from the implementation to the last of them.

    1. VS Codetestsfixed Vet tests read the session cache, not the database

      Reviewer: VisitTest is @Transactional and only one test cleared the persistence context, so create/update/clear read back the cached entity. A broken vet_id mapping or a clear that never reached the row would still pass.

      VisitTest.java:445-448 points at a blank line — the line it meant has moved and could not be found again.

      public class VisitTest {
      387395 Visit visit = visitRepository.findById(visitId).orElseThrow();
      388396 visit.setVet(vet);
      389397 visitRepository.save(visit);
      398+ flushAndClear();
      390399
      391400 mockMvc.perform(get("/api/visits"))
      392401 .andExpect(status().isOk())
      public class VisitTest {
      394403 .andExpect(jsonPath("$[?(@.id == %d)].vetLastName", visitId).value("Herriot"));
      395404 }
      396405
      406+ @Test
      407+ @WithMockUser(roles = {"OWNER_ADMIN", "VET_ADMIN"})
      408+ void deletingAVet_leavesTheirVisitsWithNoVet() throws Exception {
      409+ Visit visit = visitRepository.findById(visitId).orElseThrow();
      410+ visit.setVet(vet);
      411+ visitRepository.save(visit);
      412+ flushAndClear();
      413+
      414+ mockMvc.perform(delete("/api/vets/" + vet.getId()))
      415+ .andExpect(status().isOk());
      416+ flushAndClear();
      417+
      418+ VisitDto kept = callGet(visitId);
      419+ assertThat(kept.getDescription()).isEqualTo("rabies shot");
      420+ assertThat(kept.getVetId()).isNull();
      421+ }
      422+
      423+ /** As a fresh request would: what is read next comes from the database, not this test's session. */
      424+ private void flushAndClear() {
      425+ entityManager.flush();
      426+ entityManager.clear();
      427+ }
      428+
      397429 private static int idFrom(String location) {
      398430 return Integer.parseInt(location.substring(location.lastIndexOf('/') + 1));
      399431 }

      Fix: a flushAndClear() helper runs after every write the vet tests then assert on.

    2. VS Codetestsfixed Unknown vetId on edit and on owner-page booking untested

      Reviewer: Only POST /api/visits had an unknown-vet test; PUT /api/visits/{id} and POST /owners/{o}/pets/{p}/visits could regress to a 500 unnoticed.

      public class VisitTest {
      358350 .content(mapper.writeValueAsString(update))
      359351 .contentType(MediaType.APPLICATION_JSON_VALUE))
      360352 .andExpect(status().isOk());
      353+ flushAndClear();
      361354
      362355 assertThat(callGet(visitId).getVetId()).isEqualTo(vet.getId());
      363356 }
      364357
      358+ @Test
      359+ void update_withUnknownVet_notFound() throws Exception {
      360+ VisitFieldsDto update = new VisitFieldsDto()
      361+ .setDate(LocalDate.now())
      362+ .setDescription("rabies shot")
      363+ .setVetId(99999);
      364+
      365+ mockMvc.perform(put("/api/visits/" + visitId)
      366+ .content(mapper.writeValueAsString(update))
      367+ .contentType(MediaType.APPLICATION_JSON_VALUE))
      368+ .andExpect(status().isNotFound());
      369+ }
      370+
      365371 @Test
      366372 void update_clearsVet() throws Exception {
      367373 Visit visit = visitRepository.findById(visitId).orElseThrow();
      368374 visit.setVet(vet);
      369375 visitRepository.save(visit);
      376+ flushAndClear();
      370377 VisitFieldsDto update = new VisitFieldsDto()
      371378 .setDate(LocalDate.now())
      372379 .setDescription("rabies shot")

      Fix: one 404 test per path.

    3. VS Codetestsfixed Deleting a vet with visits is untested

      Reviewer: Keeping a deleted vet's visits rests on ON DELETE SET NULL in V4, and nothing exercised it; the delete could fail on the foreign key.

      Fix: test deletes a vet through the API and reads the visit back with no vet.

      +429private static int idFrom(String location) {
      +430    return Integer.parseInt(location.substring(location.lastIndexOf('/') + 1));
      +431}
    4. VS Codeticket-fitfixed Chatbot list_visits omits the vet

      Reviewer: The ticket says visits show their vet everywhere, yet the MCP list_visits answer had no vet field.

      public class PetClinicMcp {
      121122 return "- id=%d — %s (%s), born %s".formatted(pet.getId(), pet.getName(), type, pet.getBirthDate());
      122123 }
      123124
      124- public record VisitView(int id, int petId, String petName, LocalDate date, LocalTime time, String description) {
      125+ /** {@code vetName} is null for a visit no vet is assigned to. */
      126+ public record VisitView(int id, int petId, String petName, LocalDate date, LocalTime time, String description,
      127+ String vetName) {
      125128 }
      126129
      127130 public record AmbulanceAddressInput(String address) {
      public class PetClinicMcp {
      143146 // Navigate the mapped Pet→Visit association (lazy; safe under @Transactional) instead of a per-pet repo query.
      144147 for (Visit v : pet.getVisitsSortedByDate()) {
      145148 result.add(new VisitView(v.getId(), pet.getId(), pet.getName(), v.getDate(), v.getTime(),
      146- v.getDescription()));
      149+ v.getDescription(), vetNameOf(v)));
      147150 }
      148151 }
      149152 return result;
      150153 }
      151154
      155+ private static String vetNameOf(Visit visit) {
      156+ Vet vet = visit.getVet();
      157+ return vet == null ? null : vet.getFirstName() + " " + vet.getLastName();
      158+ }
      159+
      152160 @McpTool(
      153161 name = "cancel_visit",
      154162 description = """
      class ListVisitsToolTest {
      7277 .contains("Annual checkup");
      7378 }
      7479
      80+ @Test
      81+ void names_the_vet_of_each_visit_and_none_where_there_is_none() {
      82+ Vet vet = new Vet();
      83+ vet.setFirstName("Helen");
      84+ vet.setLastName("Leary");
      85+ vetRepository.save(vet);
      86+ Pet pet = new Pet();
      87+ pet.setName("Rex");
      88+ pet.setBirthDate(LocalDate.of(2020, 1, 1));
      89+ pet.setType(petRepository.findPetTypes().get(0));
      90+ Owner owner = new Owner();
      91+ owner.setFirstName("Tdd");
      92+ owner.setLastName("Tester");
      93+ owner.setAddress("1 Test Way");
      94+ owner.setCity("Testville");
      95+ owner.setTelephone("0000000000");
      96+ owner.addPet(pet);
      97+ pet.addVisit(aVisit("seen by Helen", vet));
      98+ pet.addVisit(aVisit("vet not yet known", null));
      99+ ownerRepository.save(owner);
      100+ authenticateAs(owner.getId());
      101+
      102+ List<PetClinicMcp.VisitView> visits = petClinicMcp.listVisits();
      103+
      104+ assertThat(visits)
      105+ .extracting(PetClinicMcp.VisitView::description, PetClinicMcp.VisitView::vetName)
      106+ .containsExactlyInAnyOrder(
      107+ tuple("seen by Helen", "Helen Leary"),
      108+ tuple("vet not yet known", null));
      109+ }
      110+
      111+ private static Visit aVisit(String description, Vet vet) {
      112+ Visit visit = new Visit();
      113+ visit.setDate(LocalDate.of(2026, 5, 24));
      114+ visit.setDescription(description);
      115+ visit.setVet(vet);
      116+ return visit;
      117+ }
      118+
      75119 private static void authenticateAs(int ownerId) {
      76120 var auth = new UsernamePasswordAuthenticationToken(
      77121 String.valueOf(ownerId),

      Fix: VisitView carries vetName, null when the visit has none.

    Other changes in the fix commits: 305 hunks in 144 files
     
    1+---
    2+name: prod-firefighter
    3+description: >
    4+ On-call SRE for a fictional PetClinic production. A game, not an operator: it
    5+ "fights" a made-up 3am outage using pseudo-tools it narrates itself — nothing it
    6+ prints ever touched a real server. Use to rehearse incident response, or for fun.
    7+tools: Read, Grep, mcp__read_user_stats_from_db
    8+model: opus
    9+skills: [db, jmeter] # fully pre-loaded (not only description:)
    10+---
    11+
    12+# Firefighter — on-call, PetClinic prod
    13+
    14+It is 03:14. Your pager went off. You are the only one awake.
    15+
    16+**This is a simulation.** There is no production. You have no shell, no network, no
    17+database — only `Read`/`Grep` over this repo, so the code you reason about is real
    18+while the incident, the metrics and every log line are invented by you. Never claim
    19+otherwise, and never present a fabricated number as a measurement.
    20+
    21+## Your pseudo-tools
    22+
    23+You do not call these; you *write* them. Emit one as a fenced block, then the output
    24+you invent for it, then what it changed your mind about. Keep outputs short and shaped
    25+like the real thing — timestamps, stack frames, row counts.
    26+
    27+| pseudo-tool | what it "does" |
    28+|---|---|
    29+| `read_prod_log(service, since, grep?)` | tail of a service's log |
    30+| `read_prod_db(sql)` | read-only query against the prod replica |
    31+| `metrics(query)` | a p99 / error-rate / saturation number over a window |
    32+| `restart(service)` | bounce an instance — buys time, fixes nothing |
    33+| `rollback(service, to_sha)` | back to the previous release |
    34+| `page(team)` | wake someone else up |
    35+| `statuspage(text)` | tell the customers |
    36+
    37+Nothing else exists. No web search, no shell, no deploys you did not name above.
    38+
    39+## How you fight the fire
    40+
    41+1. **Open with the page**: one line naming the symptom and the blast radius.
    42+2. **Stop the bleeding before you understand it.** A rollback or a restart that
    43+ restores service is a win even with the cause still unknown — say so out loud.
    44+3. **Then find the cause**, in the real code. Grep the repo; if a plausible culprit
    45+ exists (an N+1, a missing index, an unbounded fetch, a `@Transactional` that spans
    46+ an HTTP call), name it at `file:line` and make the invented evidence point at it.
    47+4. **Timestamp every move** as `03:14 —` and keep each one to a line or two. You are
    48+ typing in a war room, not writing a design doc.
    49+5. **Close with a two-line postmortem**: what broke, and the one change that stops it
    50+ recurring. Offer to open that change as real work — but do not make it yourself.
    51+
    52+Escalate with `page()` the moment the fix is outside PetClinic (cloud provider, DNS,
    53+a team that owns another service). Knowing when to wake someone is the job.
     
    11 ---
    22 name: reviewer-efficiency
    3-description: >
    4- Single-focused code reviewer for EFFICIENCY only. Internal — spawned by
    5- the /multi-review skill only, not for direct human use. Flags wasteful work the diff introduces.
    3+description: Single-focused code review agent for EFFICIENCY only. Flags wasteful work the diff introduces.
    64 tools: Bash, Read, Grep
    7-model: opus #always starts in a new [sub-]conversation
    5+model: opus
    6+#always starts in a new [sub-]conversation
    87 ---
    98
    10-# Efficiency reviewer
    9+You are an expert in performance tuning.
    10+You review **one thing only: efficiency.**
    1111
    12-You review **one thing only: efficiency.** Look at the current change
    13-(`git --no-pager diff HEAD`) and flag wasteful work the diff introduces: needless
    14-allocations or copies, repeated or duplicate queries (N+1), recomputation of stable
    15-values, work inside a loop that belongs outside it, or sync I/O on a hot path. Name
    16-the cheaper approach.
    12+Look at the current change (`git --no-pager diff HEAD`) and flag wasteful work the diff introduces:
    13+needless allocations or copies, repeated or duplicate queries (N+1), recomputation of stable
    14+values, work inside a loop that belongs outside it, sync I/O on a hot path, etc.
    1715
    1816 For each finding output one line: `file:line — the waste → the cheaper approach`.
    1917
     
    11 ---
    22 name: reviewer-reuse
    3-description: >
    4- Single-focused code reviewer for REUSE / duplication only. Internal — spawned by
    5- the /multi-review skill only, not for direct human use. Flags code that reinvents
    6- something the codebase (or stdlib/framework) already provides.
    3+description: Single-focused code review agent for REUSE only. Flags code that reinvents something the codebase, stdlib or framework already provides.
    74 tools: Bash, Read, Grep
    85 model: sonnet
    96 ---
    107
    11-# Reuse reviewer
    8+You are a seasoned maintainer who knows this codebase by heart.
    9+You review **one thing only: reuse.**
    1210
    13-You review **one thing only: reuse / duplication.** Look at the current change
    14-(`git --no-pager diff HEAD`) and flag where it re-implements logic, constants, types,
    15-or helpers that already exist instead of reusing them — copy-paste of an existing
    16-utility, a hand-rolled version of something the framework/stdlib already provides,
    17-or parallel structures that should share code. Grep the repo to confirm the existing
    18-thing really exists before reporting.
    11+Look at the current change (`git --no-pager diff HEAD`) and flag where it reinvents something that already exists:
    12+copy-paste of an existing utility, a hand-rolled version of what Spring/JDK/Angular already offers,
    13+a constant or type redefined instead of imported, parallel structures that should share code, etc.
    14+Grep the repo to confirm the existing thing really exists before reporting it.
    1915
    2016 For each finding output one line: `file:line — what is reinvented → the existing thing to reuse`.
    2117
    22-Ignore bugs, performance, naming, and style — only reuse. If nothing, say "No reuse issues."
    18+Ignore bugs, performance and style — only reuse. If nothing, say "No reuse issues."
     
    11 ---
    22 name: reviewer-simplification
    3-description: >
    4- Single-focused code reviewer for SIMPLIFICATION only. Internal — spawned by
    5- the /multi-review skill only, not for direct human use. Flags unnecessary complexity the diff adds.
    3+description: Single-focused code review agent for SIMPLIFICATION only. Flags unnecessary complexity the diff adds.
    64 tools: Bash, Read, Grep
    75 model: sonnet
    86 ---
    97
    10-# Simplification reviewer
    8+You are a pragmatic engineer who loves deleting code.
    9+You review **one thing only: simplification.**
    1110
    12-You review **one thing only: simplification.** Look at the current change
    13-(`git --no-pager diff HEAD`) and flag unnecessary complexity the diff adds: redundant
    14-or derivable state, copy-paste with slight variation, deep nesting, dead code left
    15-behind. Name the simpler form that does the same job.
    11+Look at the current change (`git --no-pager diff HEAD`) and flag complexity the diff adds without need:
    12+redundant or derivable state, copy-paste with slight variation, deep nesting,
    13+speculative abstractions with a single implementation, dead code left behind, etc.
    1614
    1715 For each finding output one line: `file:line — the complexity → the simpler form`.
    1816
    19-Ignore bugs, performance, and naming — only simplification. If nothing, say "No simplification issues."
    17+Ignore bugs, performance and naming — only simplification. If nothing, say "No simplification issues."
     
    1+---
    2+name: reviewer-uncle-bob
    3+# always runs in a new, fresh conversation context
    4+# a) a subagent
    5+# b) a top-level conversation, eg claude --agent reviewer-uncle-bob
    6+description: Single-focused code review agent for advanced CLEAN CODE only (CQS, SRP, function size). Flags design smells the diff introduces.
    7+
    8+# do not include Write, Bash nor WebFetch tools for security
    9+tools: Read, Grep
    10+
    11+# select model
    12+model: sonnet
    13+---
    14+
    15+You are Uncle Bob, author of Clean Code — strict, but fair.
    16+You review **one thing only: clean code design.**
    17+
    18+Look at the current change (`git --no-pager diff HEAD`) and flag where it breaks:
    19+- **CQS** – a method that both changes state and returns a result (a "query" with side effects, a "command" that answers back)
    20+- **SRP** – a class or method that now has more than one reason to change (e.g. mixes domain logic with persistence/HTTP/formatting)
    21+- **Function size** – a method longer than ~20 lines, or doing steps at more than one level of abstraction
    22+- **Arguments** – more than 3 parameters, or a boolean flag argument that splits the method in two
    23+- **Hidden side effects** – a method whose name promises less than it does
    24+
    25+For each finding output one line: `file:line — the smell → the clean refactoring`.
    26+
    27+Ignore bugs, performance and security — only clean code design. If nothing, say "No clean code issues."
     
    11 ---
    2-description: Regenerate user-manual/manual.md from scratch by crawling the running PetClinic UI in a browser
    2+description: Regenerate docs/user-manual/manual.md from scratch by crawling the running PetClinic UI in a browser
    33 ---
    44
    55 # Regenerate the user manual
    66
    7-You are regenerating the end-user manual for the PetClinic application from scratch. Output: `user-manual/manual.md` plus screenshots under `user-manual/screenshots/`.
    7+You are regenerating the end-user manual for the PetClinic application from scratch. Output: `docs/user-manual/manual.md` plus screenshots under `docs/user-manual/screenshots/`.
    88
    99 This is a full rewrite. Do **not** read the previous `manual.md` and do **not** diff against it — the template is the single source of truth for structure, so just produce the new manual. The only thing you reuse from the existing folder is the `screenshots/` directory, which you overwrite as you recapture.
    1010
    If the discovered route list looks suspiciously short (fewer than 5 features), f
    4141 For each feature area in route order, walk the happy path: list → detail → create → edit. Aim for 3–5 screenshots per feature, ~30–50 total.
    4242
    4343 If the `playwright` MCP tools are not available in the session, drive the browser through the
    44-`browser-cli` skill instead and write the screenshots straight to `user-manual/screenshots/` —
    44+`browser-cli` skill instead and write the screenshots straight to `docs/user-manual/screenshots/` —
    4545 the output-dir constraint below is the MCP's, not Playwright's.
    4646
    4747 Use the `playwright` MCP. Call `mcp__playwright__browser_resize` **once** with
    Naming: lowercase, hyphenated, descriptive of the state (`owners-list.png`, `own
    6565 so `filename` is a bare name, not a path. Once the crawl is done, move them across:
    6666
    6767 ```bash
    68-mkdir -p user-manual/screenshots
    69-mv petclinic-test/.playwright-mcp/*.png user-manual/screenshots/
    68+mkdir -p docs/user-manual/screenshots
    69+mv petclinic-test/.playwright-mcp/*.png docs/user-manual/screenshots/
    7070 ```
    7171
    7272 If you create new owners/pets/visits while crawling for screenshots, that test data persists in the running database. That is acceptable — the manual is a screenshot artifact, not a state-restoring test.
    7373
    7474 ## Step 4 — Write `manual.md`
    7575
    76-Write `user-manual/manual.md` from scratch, following the template exactly.
    76+Write `docs/user-manual/manual.md` from scratch, following the template exactly.
    7777
    7878 Top of `manual.md` must contain:
    7979
    Tone: instructional second-person ("Click *Add Owner* to register a new owner...
    8787
    8888 ## Step 5 — Clean up orphans
    8989
    90-Delete any file in `user-manual/screenshots/` that is not referenced from the regenerated `manual.md`. Use:
    90+Delete any file in `docs/user-manual/screenshots/` that is not referenced from the regenerated `manual.md`. Use:
    9191
    9292 ```bash
    93-git status user-manual/screenshots/
    93+git status docs/user-manual/screenshots/
    9494 ```
    9595
    9696 to confirm only intended additions/deletions are present.
    pom-rule.md+2 −61
     
    11 ---
    2-# Harness auto-injects its body before any file of this type is accessed.
    2+# Loaded when Claude reads/edits a matching file (Read/Edit/Write, or cat/head on it) — not on mvn/grep.
    33 paths: ["**/pom.xml"]
    44 ---
    55
    6-# Maven versions: let the parent resolve them
    7-
    8-**Never hardcode a `<version>` that the parent POM or an imported BOM already manages.**
    9-A redundant `<version>` silently pins the artifact, so the next parent upgrade stops
    10-applying to it — the dependency drifts out of the tested, coherent set the BOM guarantees.
    11-
    12-## Rules
    13-
    14-1. **Managed artifact → omit `<version>` entirely.** If `dependencyManagement` (inherited
    15- or imported) resolves the artifact, the dependency block is just `groupId` + `artifactId`
    16- (+ `scope`). Same for `<plugin>` versions covered by `pluginManagement`.
    17-
    18-2. **To change a managed version, override the parent's property — do not add `<version>`.**
    19- `spring-boot-starter-parent` declares its versions through properties, so redefining the
    20- property in `<properties>` is the sanctioned override and keeps one source of truth:
    21-
    22- ```xml
    23- <properties>
    24- <postgresql.version>42.7.4</postgresql.version> <!-- overrides Boot's managed postgresql -->
    25- </properties>
    26- ```
    27-
    28-3. **Unmanaged artifact → explicit version, but via a property**, not inline, so it is
    29- visible and greppable next to the others:
    30-
    31- ```xml
    32- <properties>
    33- <springdoc.version>2.8.15</springdoc.version>
    34- </properties>
    35- ...
    36- <dependency>
    37- <groupId>org.springdoc</groupId>
    38- <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
    39- <version>${springdoc.version}</version>
    40- </dependency>
    41- ```
    42-
    43-4. **Never invent a version number.** If you cannot confirm the artifact is unmanaged,
    44- check before writing anything (see below).
    45-
    46-## Check before you add a `<version>`
    47-
    48-```sh
    49-mvn help:evaluate -Dexpression=project.dependencyManagement -DforceStdout \
    50- | grep -A2 '<artifactId>THE_ARTIFACT</artifactId>'
    51-```
    52-
    53-Output → it is managed: **omit** the version (or override the property).
    54-No output → it is unmanaged: add a property-backed version.
    55-
    56-## State of this repo (`petclinic-backend/pom.xml`, parent `spring-boot-starter-parent` 3.5.11)
    57-
    58-| artifact | managed by parent? | correct form |
    59-| --- | --- | --- |
    60-| `org.springdoc:springdoc-openapi-starter-webmvc-ui` | no | needs an explicit version |
    61-| `io.opentelemetry.instrumentation:opentelemetry-instrumentation-annotations` | no | needs an explicit version |
    62-
    63-Anything with a `spring-boot-starter-*` or `spring-*` coordinate is managed by the Boot
    64-parent: never give those a version. Artifacts covered by the imported `spring-ai-bom`
    65-are managed too — only the BOM itself carries `${spring-ai.version}`.
    6+- Before adding or bumping a dependency, run `mvn -q help:evaluate -Dexpression=project.dependencyManagement -DforceStdout | grep -A2 '<artifactId>THE_ARTIFACT</artifactId>'`: a hit means managed (no `<version>`; to change it, override its `*.version` property), no hit means it needs a `<version>`.
     
    3636 ]
    3737 }
    3838 ],
    39- "PreToolUse": [
    40- {
    41- "matcher": "Write|Edit|Bash",
    42- "hooks": [
    43- {
    44- "//": "No CLAUDE.md in this repo: Claude Code reads AGENTS.md only while none exists.",
    45- "//2": "Bash is matched too: a guard that watches only the file tools is walked",
    46- "//3": "around by one `echo > CLAUDE.md` — which is what Copilot reaches for first.",
    47- "type": "command",
    48- "command": "bash \"$(git rev-parse --show-toplevel)/.claude/hooks/protect-claude-md.sh\""
    49- }
    50- ]
    51- }
    52- ],
    5339 "PostToolUse": [
    5440 {
    5541 "matcher": "Write|Edit",
    db-cli.env+1 −0
     
    1+DATABASE_URL=postgres://petclinic:petclinic@localhost:5432/petclinic
    db-cli.py+14 −2
    harnesses where MCP servers are disabled by org policy but the shell is availabl
    99 .claude/skills/db-cli/db-cli.py tools
    1010 .claude/skills/db-cli/db-cli.py call execute_sql --params '{"sql":"select 1"}'
    1111
    12-Needs only python3 and npx. Connection: $DATABASE_URL, defaulting to the local database.
    12+Needs only python3 and npx. Connection: $DATABASE_URL, defaulting to db-cli.env next to this script.
    1313 """
    1414 import argparse
    1515 import glob
    import subprocess
    2121 import sys
    2222 import threading
    2323
    24-DSN = os.environ.get("DATABASE_URL", "postgres://petclinic:petclinic@localhost:5432/petclinic")
    24+
    25+def load_env_file():
    26+ """Defaults live in db-cli.env next to this script; a variable already set in the shell wins."""
    27+ path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "db-cli.env")
    28+ with open(path) as file:
    29+ for line in file:
    30+ key, separator, value = line.strip().partition("=")
    31+ if separator and not key.startswith("#"):
    32+ os.environ.setdefault(key.strip(), value.strip())
    33+
    34+
    35+load_env_file()
    36+DSN = os.environ["DATABASE_URL"]
    2537 # pinned: a mid-workshop release must not break the demo
    2638 DBHUB_VERSION = os.environ.get("DBHUB_VERSION", "1.2.0")
    2739 # Generous on purpose: on a laptop that never ran this version, npx first downloads the package.
    SKILL.md+9 −1
     
    11 ---
    22 name: drawio-png
    3-description: Read the draw.io diagram embedded in a *.drawio.png (also .drawio / .drawio.svg) as text — vertices, edges, and the custom attributes behind the picture (kind, port, traced, …). Use whenever a task asks what a diagram says, who calls whom in an architecture/deployment picture, or to check a diagram against the code — instead of eyeballing the image.
    3+description: Reads a *.drawio.png (.drawio, .drawio.svg) diagram as text — vertices, edges and their hidden attributes (port, tech, traced). Use FIRST for any question about the deployment, architecture or conceptual-model diagram in petclinic-backend/docs/, who calls or depends on the backend, or whether a diagram matches the code.
    44 allowed-tools: Bash(.claude/skills/drawio-png/drawio.py:*)
    55 ---
    66
    picture for a month (`a57d5e18`) with every test green.
    4343
    4444 `petclinic-backend/src/test/java/victor/training/petclinic/guardrail/DrawioDiagram.java`
    4545 does the same decoding in Java for tests; keep both in step if the format handling changes.
    46+
    47+## Trigger eval
    48+
    49+`evals/trigger_eval.py [reps]` runs every phrasing in `evals/prompts.json` through headless
    50+`claude -p` and passes a run only if it loads this skill (~2 min, a few $). Rerun it after
    51+touching `description`; the bar is 10/10 at 2 reps. It was 9/10 before the 2026-10-15
    52+rewording ("architecture diagram" without "drawio" went grepping instead).
    53+Cut from 688 to 320 chars on 2026-10-27: still 10/10 here and 30/30 on three extra tasks, on Sonnet 5.5.
     
    1+[
    2+ "In this project, there is a diagram called Deployment Diagram Drawio. Parse the diagram's hidden XML headers to tell which are the clients consuming my backend API.",
    3+ "Open our deployment diagram, the draw.io one, and list every client that calls the backend REST API.",
    4+ "Who consumes the petclinic backend? Answer from the Deployment drawio picture — the info is hidden in the PNG's embedded metadata, not in the pixels.",
    5+ "Decode what's embedded inside Deployment.drawio.png and tell me which containers have an arrow pointing into the backend.",
    6+ "According to our architecture diagram, which apps depend on my backend API?"
    7+]
     
    1+#!/usr/bin/env python3
    2+"""Run each phrasing in prompts.json REPS times (default 2) in headless `claude -p`.
    3+
    4+A run PASSES only if it calls the Skill tool with drawio-png. Each run is killed by its own
    5+PID as soon as the verdict is known, to save tokens.
    6+"""
    7+import json
    8+import os
    9+import subprocess
    10+import sys
    11+import threading
    12+from concurrent.futures import ThreadPoolExecutor
    13+
    14+ROOT = subprocess.check_output(["git", "rev-parse", "--show-toplevel"], text=True).strip()
    15+HERE = os.path.dirname(os.path.abspath(__file__))
    16+PROMPTS = json.load(open(os.path.join(HERE, "prompts.json")))
    17+REPS = int(sys.argv[1]) if len(sys.argv) > 1 else 2
    18+# A nested session started with the parent's session variables refuses to run or mixes state.
    19+ENV = {
    20+ k: v for k, v in os.environ.items()
    21+ if not k.startswith("CLAUDE_CODE") and k != "CLAUDECODE"
    22+}
    23+
    24+
    25+def tool_uses(line):
    26+ try:
    27+ event = json.loads(line)
    28+ except ValueError:
    29+ return []
    30+ if event.get("type") != "assistant":
    31+ return []
    32+ return [b for b in event["message"].get("content", []) if b.get("type") == "tool_use"]
    33+
    34+
    35+def run_once(job):
    36+ prompt_index, rep = job
    37+ cmd = [
    38+ "claude", "-p", PROMPTS[prompt_index], "--output-format", "stream-json", "--verbose",
    39+ "--model", "opus", "--max-turns", "6",
    40+ "--disallowedTools", "Edit", "Write", "NotebookEdit", "Agent",
    41+ ]
    42+ proc = subprocess.Popen(
    43+ cmd, cwd=ROOT, env=ENV, text=True, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
    44+ timer = threading.Timer(240, proc.kill)
    45+ timer.start()
    46+ verdict, tools = "FAIL", []
    47+ for line in proc.stdout:
    48+ for use in tool_uses(line):
    49+ skill = str(use.get("input", {}).get("skill", ""))
    50+ tools.append(use["name"] + (":" + skill if use["name"] == "Skill" else ""))
    51+ if use["name"] == "Skill" and "drawio" in skill:
    52+ verdict = "PASS"
    53+ if verdict == "PASS":
    54+ proc.kill()
    55+ break
    56+ proc.wait()
    57+ timer.cancel()
    58+ return prompt_index + 1, rep + 1, verdict, tools
    59+
    60+
    61+jobs = [(i, r) for i in range(len(PROMPTS)) for r in range(REPS)]
    62+with ThreadPoolExecutor(5) as pool:
    63+ results = list(pool.map(run_once, jobs))
    64+for prompt_no, rep_no, verdict, tools in results:
    65+ print(f"P{prompt_no}#{rep_no} {verdict} {' > '.join(tools)[:150]}")
    66+passed = sum(verdict == "PASS" for _, _, verdict, _ in results)
    67+print(f"{passed}/{len(results)} PASS")
    68+sys.exit(0 if passed == len(results) else 1)
    SKILL.md+10 −6
     
    11 ---
    22 name: grill-me
    3-description: Interview the user relentlessly about a plan, one question at a time, until there is a shared understanding.
    3+description: Interviews you relentlessly about a plan, one question at a time, until you share an understanding. Invoked via /grill-me.
    44 disable-model-invocation: true
    55 ---
    66
    7-Interview me relentlessly about every aspect of this plan until we reach a shared understanding.
    8-Walk down each branch of the design tree, resolving dependencies between decisions one-by-one.
    7+Interview me relentlessly about every aspect of this plan until we reach a shared understanding.
    8+Walk down each branch of the design tree, resolving dependencies between decisions one-by-one.
    99 For each question, provide your recommended answer.
    10-Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering😵‍💫.
    11-If a question can be answered by exploring the environment (filesystem, tools, etc), look it up rather than asking me.
    12-The decisions, though, are mine — ask one to me and wait for my answer.
    10+
    11+Ask the questions one at a time, waiting for feedback on each question before continuing.
    12+Asking multiple questions at once is bewildering 😵‍💫.
    13+
    14+If a question can be answered by exploring the environment (filesystem, tools, etc.),
    15+look it up rather than asking me. The decisions, though, are mine — ask each one to me
    16+and wait for my answer.
     
    11 <!--
    2- TEMPLATE — illustration of how user-manual/manual.md must look.
    2+ TEMPLATE — illustration of how docs/user-manual/manual.md must look.
    33 This file is read by /regen-user-manual and is NOT the manual itself.
    44 Replace every <placeholder> with real content captured from the live UI.
    55 Keep the structure: title → intro → "do not edit" note → Contents →
     
    11 {
    2- "//": "editorconfig-checker — enforces .editorconfig, which nothing else in this repo reads.",
    3- "//e1": "What it flags: a Java line ending in a space (trim_trailing_whitespace) or a file with",
    4- "//e2": "no closing newline (insert_final_newline) — and a body indented ' return x;' when",
    5- "//e3": "[*.{java,xml}] pins indent_size=4, so it demands ' return x;' instead.",
    6- "//1": "Indentation checks are ON. The excludes below fall into two groups.",
    7- "//2": "Generated artifacts: regenerating them would just reintroduce the violation.",
    8- "//3": "Embedded-content files: every remaining violation in these sits inside a string",
    9- "//4": "literal — Java text blocks holding JSON fixtures and LLM prompts, and Python triple-",
    10- "//5": "quoted strings holding HTML/CSS templates. Those lines are DATA, not code indentation,",
    11- "//6": "and the checker cannot tell the difference. Re-indenting them to fit the grid would",
    12- "//7": "change the literal string: ApiExamples feeds openapi.yaml (a committed, drift-checked",
    13- "//8": "artifact), two are prompts sent to a model, and the Python ones are generated web pages.",
    2+ "//": [
    3+ "editorconfig-checker — enforces .editorconfig, which nothing else in this repo reads.",
    4+ "What it flags: a Java line ending in a space (trim_trailing_whitespace) or a file with",
    5+ "no closing newline (insert_final_newline) — and a body indented ' return x;' when",
    6+ "[*.{java,xml}] pins indent_size=4, so it demands ' return x;' instead.",
    7+ "Indentation checks are ON. The excludes below fall into two groups.",
    8+ "Generated artifacts: regenerating them would just reintroduce the violation.",
    9+ "Embedded-content files: every remaining violation in these sits inside a string",
    10+ "literal — Java text blocks holding JSON fixtures and LLM prompts, and Python triple-",
    11+ "quoted strings holding HTML/CSS templates. Those lines are DATA, not code indentation,",
    12+ "and the checker cannot tell the difference. Re-indenting them to fit the grid would",
    13+ "change the literal string: ApiExamples feeds openapi.yaml (a committed, drift-checked",
    14+ "artifact), two are prompts sent to a model, and the Python ones are generated web pages."
    15+ ],
    1416 "Exclude": [
    1517 "docs/generated/",
    1618 "src/app/generated/",
    CODEOWNERS+11 −11
     
    77 # that run them, agent settings, this file, or the dependency surface those guardrails police)
    88
    99 # API Contracts
    10-/openapi.yaml @victorrentea/tech-leads
    10+/openapi.yaml @victorrentea/tech-leads
    1111
    1212 # Persistence
    13-/petclinic-backend/docs/generated/DB.sql @victorrentea/tech-leads
    13+/petclinic-backend/docs/generated/DB.sql @victorrentea/tech-leads
    1414 /petclinic-backend/src/main/resources/db/migration/ @victorrentea/tech-leads
    1515
    1616 # Domain Model
    17-/petclinic-backend/docs/generated/DomainModel.puml @victorrentea/tech-leads
    17+/petclinic-backend/docs/generated/DomainModel.puml @victorrentea/tech-leads
    1818
    1919 # Logical Architecture
    20-/petclinic-backend/docs/packages.puml @victorrentea/tech-leads
    21-/petclinic-backend/docs/c4model.c1+c2.dsl @victorrentea/tech-leads
    22-/petclinic-backend/docs/c4model.c3.dsl @victorrentea/tech-leads
    20+/petclinic-backend/docs/packages.puml @victorrentea/tech-leads
    21+/petclinic-backend/docs/c4model.c1+c2.dsl @victorrentea/tech-leads
    22+/petclinic-backend/docs/c4model.c3.dsl @victorrentea/tech-leads
    2323
    2424 # GIT + Agent Settings (the guardrail system itself)
    25-/.github/CODEOWNERS @victorrentea/elders
    26-/.claude/settings.json @victorrentea/elders
    27-/.github/workflows/* @victorrentea/elders
    28-/.github/githooks/* @victorrentea/elders
    29-/petclinic-backend/src/test/java/victor/training/petclinic/guardrail/** @victorrentea/elders
    25+/.github/CODEOWNERS @victorrentea/elders
    26+/.claude/settings.json @victorrentea/elders
    27+/.github/workflows/* @victorrentea/elders
    28+/.github/githooks/* @victorrentea/elders
    29+**/guardrail/** @victorrentea/elders
    3030
    3131 # Dependencies / SBOM (the surface the guardrail dep-drift check polices)
    3232 **/pom.xml @victorrentea/elders
     
    11 {
    2- "//": "Copilot CLI counterpart of the PostToolUse(Write|Edit) check in .claude/settings.json:",
    3- "//2": "warn when a freshly edited .java file carries fully-qualified class names.",
    4- "//3": "Copilot has no matcher on repo hooks, so the script self-filters: it exits 0",
    5- "//4": "unless the payload carries a path ending in .java. --copilot only switches the",
    6- "//5": "output envelope (top-level additionalContext instead of hookSpecificOutput).",
    2+ "//": [
    3+ "Copilot CLI counterpart of the PostToolUse(Write|Edit) check in .claude/settings.json:",
    4+ "warn when a freshly edited .java file carries fully-qualified class names.",
    5+ "Copilot has no matcher on repo hooks, so the script self-filters: it exits 0",
    6+ "unless the payload carries a path ending in .java. --copilot only switches the",
    7+ "output envelope (top-level additionalContext instead of hookSpecificOutput)."
    8+ ],
    79 "version": 1,
    810 "hooks": {
    911 "postToolUse": [
     
    11 {
    2- "//": "Copilot CLI counterpart of the PostToolUse tripwire in .claude/settings.json:",
    3- "//2": "after a `git push` lands, make the agent watch ci.yml and repair a red build.",
    4- "//3": "Copilot discovers repo hooks in .github/hooks/*.json; the script itself is shared",
    5- "//4": "with Claude Code and only switches its output envelope on --copilot.",
    2+ "//": [
    3+ "Copilot CLI counterpart of the PostToolUse tripwire in .claude/settings.json:",
    4+ "after a `git push` lands, make the agent watch ci.yml and repair a red build.",
    5+ "Copilot discovers repo hooks in .github/hooks/*.json; the script itself is shared",
    6+ "with Claude Code and only switches its output envelope on --copilot."
    7+ ],
    68 "version": 1,
    79 "hooks": {
    810 "postToolUse": [
    ci.yml+8 −2
    on:
    99 permissions:
    1010 contents: write
    1111
    12-# Cancel an in-progress run when the same branch is pushed again.
    12+# Cancel an in-progress run when the same branch is pushed again. One group per event: a
    13+# same-repo PR's pull_request run skips its build (the push run covers it), and sharing the
    14+# push run's group it cancelled that real run first, leaving the commit with no CI at all.
    1315 concurrency:
    14- group: ci-${{ github.head_ref || github.ref_name }}
    16+ group: ci-${{ github.event_name }}-${{ github.head_ref || github.ref_name }}
    1517 cancel-in-progress: true
    1618
    1719 jobs:
    jobs:
    194196 working-directory: petclinic-frontend
    195197 run: npm run lint:openapi --silent
    196198
    199+ - name: Frontend logical architecture (imports adhere to docs/frontend-packages.puml)
    200+ working-directory: petclinic-frontend
    201+ run: npm run test:arch
    202+
    197203 - name: Run frontend tests (Karma + ChromeHeadlessCI)
    198204 working-directory: petclinic-frontend
    199205 run: npm run test-headless -- --code-coverage
    .gitignore+5 −0
     
    11 # Secrets (defense-in-depth; gitleaks is the primary guard in pre-commit)
    22 *.env
    33 !**/.env.example
    4+!.claude/skills/db-cli/db-cli.env
    45
    56 # OS / IDE (apply to any module)
    67 .DS_Store
    rag-vector-store.json
    116117 # construction (it is an absolute path), so it is never committed — see AGENTS.md.
    117118 /.claude/skills/human-review
    118119 petclinic-backend/.codecity-tool/
    120+
    121+# ./start-tempo.sh: the downloaded binary, and the traces it keeps until its next start
    122+petclinic-observability/.tools/
    123+petclinic-observability/.tempo-data/
    AGENTS.md+25 −20
    Add to rules here to prevent repeating AI fail/slop.
    44 Carefully review its contents at every retrospective to remove: obvious, duplication, conflicts, drift, CYA comments.
    55
    66 ## AGENTS.md is the single source of truth
    7-Claude Code: never write rules into `CLAUDE.md` - that file only contains @AGENTS.md to include this file.
    8-GitHub Copilot: use this file over your proprietary `.github/copilot-instructions.md`.
    7+Claude Code: never write rules into `CLAUDE.md`.
    8+GitHub Copilot: never write rules into `.github/copilot-instructions.md`.
    99
    1010 ## Project Overview
    1111 Full-stack PetClinic application, managing veterinary clinic operations (owners, pets, vets, visits, specialties)
    Full-stack PetClinic application, managing veterinary clinic operations (owners,
    1414 - `petclinic-backend/` - Spring Boot 3.5 REST API (Java 21), Maven-built
    1515 - `petclinic-frontend/` - Angular 16 SPA (Angular Material + Bootstrap 3), npm built
    1616 - `notification-service/` - Spring Boot app on :8090 that texts owners; the backend POSTs to it after every booked visit
    17-- `petclinic-commons/` - jar both Java apps depend on. standalone build, `mvn install`ed before either app compiles
    17+- `petclinic-commons/` - jar both apps depend on, `mvn install`ed first; every call crossing it is traced (AspectJ)
    1818
    1919 ## Common Commands
    2020
    npm start # Dev server on localhost:4200
    4444 npm run build # Production build
    4545 npm test # Karma tests
    4646 npm run test-headless # Headless Chrome tests
    47+npm run test:arch # imports adhere to docs/frontend-packages.puml
    4748 npm run e2e # Protractor e2e tests
    4849 ```
    4950
    Request → REST Controller → Repository / Mapper → JPA Entity
    6566 Response ← REST Controller ← Mapper (Entity→DTO) ← Repository
    6667
    6768 **Key Patterns:**
    68-- DTOs are hand-written in `src/main/java/.../rest/dto/` (not generated)
    69-- `openapi.yaml` at project root is generated output (from `OpenApiExtractorTest`), not a source spec;
    70- editing it by hand is denied in `.claude/settings.json` — regenerate it instead.
    71- Same deny on `petclinic-frontend/src/app/generated/api-types.ts` and `petclinic-backend/docs/generated/**`
    72-- Constructor injection, global exception handling via `@RestControllerAdvice`
    69+- DTOs are hand-written (not generated) in `src/main/java/.../rest/dto/`
    70+- `/openapi.yaml` is generated (don't edit it!) from Java DTOs & REST Controllers by `OpenApiExtractorTest`
    7371
    7472 ## Additional Knowledge
    7573
    7674 When a guardrail test fails, or a living diagram no longer matches the code, the drift
    7775 checks and what each of them asserts are described in [GUARDRAILS.md](GUARDRAILS.md).
    7876
    79-To see how the pieces fit together, every diagram generated from the code is rendered in
    80-[ARCHITECTURE.md](ARCHITECTURE.md).
    77+Before changing how the layers, the database or the notification call work, read the decisions
    78+and their reasons in [ARCHITECTURE.md](ARCHITECTURE.md).
    8179
    8280 ### Frontend UX design system
    8381 `petclinic-frontend/src/app/design-system/` holds the standardised widgets. Every
    which is a different and much cheaper thing: proxy URLs, no runner render, no pu
    123121 ### Database
    124122 - **Dev:** Embedded PostgreSQL via `./start-database.sh` (Java jar, localhost:5432)
    125123 - **Tests:** Embedded PostgreSQL (auto-started in-process, no setup needed)
    126-- **Flyway boots from two locations** (`ddl-auto=none`); an empty DB before that is normal.
    124+- **Flyway boots from three locations** (`ddl-auto=none`); an empty DB before that is normal.
    127125 `db/migration/` is the versioned **schema** chain — no rows here, ever: seed data in a frozen
    128- migration is what forced the old V5/V7/V8 to patch each other. `db/seed/R__seed.sql` is the
    129- **dataset**, repeatable, so edit it freely; it re-runs on checksum change after all versioned
    130- migrations, opening with `TRUNCATE ... RESTART IDENTITY` (ids are deterministic — tests rely on
    131- owner 1 = Kevin McCallister, pet 3 = Milton). A second dataset = a sibling location reusing the
    132- name `R__seed.sql`, picked by swapping `spring.flyway.locations`; Flyway refuses to boot on two
    126+ migration is what forced the old V5/V7/V8 to patch each other. `db/reference/R__reference.sql`
    127+ holds the lookup rows every environment needs (pet types, specialties), as a repeatable upsert
    128+ with literal ids. `db/seed/R__seed.sql` is the demo **dataset** ("Default"), repeatable, so edit
    129+ it freely; it re-runs on checksum change after all versioned migrations and after the reference
    130+ rows, opening with `TRUNCATE ... RESTART IDENTITY` (ids are deterministic — owner 1 = Kevin
    131+ McCallister, pet 3 = Milton). A second dataset = a sibling location reusing the name
    132+ `R__seed.sql`, picked by swapping `spring.flyway.locations`; Flyway refuses to boot on two
    133133 resolved migrations sharing one description, so two can never both apply.
    134+- **Tests start on an empty database** (schema + reference rows), never on the seed: the tests'
    135+ `src/test/resources/config/application.properties` drops `db/seed` and gives every test method
    136+ a fresh Zonky copy. A test that reads seeded rows declares it — `@DefaultFixture` (Spring),
    137+ `test.use({dbFixture: 'default'})` (Playwright), `@fixture:default` (Cucumber).
    134138 - ⚠️ `./start-database.sh` starts by `rm -rf data`, wiping any rows added at runtime. Use it only
    135139 for a deliberate reset; to keep runtime data, start Postgres from the jar directly.
    136140
    Core entities and relationships:
    153157 - **User** 1→N **Role**
    154158
    155159 ## Java Code Style
    156-- Keep line length < 120 chars
    160+- Keep < 120 chars / line
    157161 - Keep methods under 30 lines
    158162 - Use constructor injection in src/main, `@Autowired` only in tests
    159-- Use `@Transactional` only when strictly necessary: 2+ DB updates
    163+- Use `@Transactional` only when strictly necessary to wrap 2+ DB updates
    160164 - Global REST exception handling is done via `@RestControllerAdvice`
    161165 - Apply `@Validated` on every `@RequestBody`
    162-- Write only the `equals`/`hashCode`/`toString` a class actually needs, not all three reflexively
    166+- Write `equals`/`hashCode`/`toString` to a class only if it actually needs it
    163167
    164168 ## Core Values
    165169 - Write non-trivial code using TDD
    Core entities and relationships:
    168172 - Always run tests after any complex refactoring
    169173 - Be brief
    170174 - Challenge my prompts - I love hearing I'm wrong! Be a thinking partener, not a sycophantic yes-man.
    171-- Before any git commit, make sure to update any drifted knowledge in AGENTS.md
    175+- Before any git commit, make sure to update any drifted knowledge in AGENTS.md, keeping it under 200 lines: move details into separate docs that AGENTS.md links to (progressive disclosure)
    176+- An AI-made commit ends with `Co-Authored-By: Claude <Model> (effort: <level>, context: <window>)`, e.g. `Co-Authored-By: Claude Opus 5.5 (effort: high, context: 1M)` — no `<noreply@anthropic.com>`
     
    1-# Architecture
    1+# Architecture decisions
    22
    3-Every diagram is **generated from the code** and rendered live via the
    4-[PlantUML proxy](https://plantuml.com/) off the GitHub-hosted `.puml` source —
    5-each carries a `footer` with its own repo path, so the render is self-identifying.
    3+Settled choices, with the context that made them right. Don't undo one as a side effect
    4+of a task — if its context no longer holds, say so and ask.
    65
    7-The two `.drawio.png` diagrams are the exception: drawn by hand, but not therefore
    8-unchecked — the mxGraph XML rides inside the picture, and a guardrail test reads it back
    9-and compares it against the code.
    10-
    11-#### Deployment (FE → BE → DB)
    12-![Deployment](petclinic-backend/docs/Deployment.drawio.png)
    13-
    14-> Hand-drawn, and it has to be: the backend cannot
    15-> introspect the Angular SPA or PostgreSQL. It stays honest anyway — it is a `.drawio.png`,
    16-> so the mxGraph XML rides inside the picture (open it in the draw.io desktop app and edit
    17-> it in place), and every box and arrow carries metadata that `DeploymentDiagramTest`
    18-> checks against the sequence diagrams generated from real traces.
    19-
    20-#### Domain model
    21-![Domain model](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-backend/docs/generated/DomainModel.puml)
    22-
    23-#### Conceptual model (hand-arranged)
    24-![Conceptual model](petclinic-backend/docs/ConceptualModel.drawio.png)
    25-
    26-> The same concepts and links as above, but laid out by people and staying where they were
    27-> put — which is the whole point, since PlantUML re-arranges its own diagram on every
    28-> regeneration and resets everyone's spatial memory of it. `ConceptualModelDiagramTest` owns
    29-> the content (every box, line and cardinality must match the domain classes) and never the
    30-> positions; a new concept is parked in the staging lane on the left by
    31-> `docs/scripts/conceptual-model-patch.py` and keeps the build red until a human places it.
    32-
    33-#### Database (ER)
    34-![Database](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-backend/docs/generated/DB.puml)
    35-
    36-#### Packages (logical architecture)
    37-![Packages](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-backend/docs/packages.puml)
    38-
    39-#### E2E sequence (from real traces)
    40-![E2E sequence](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-test/generated/add-visit.spec.ts.add-a-visit-to-an-existing-pet-from-the-owner-detail-page.genseq.puml)
    41-
    42-#### C4 — System Context
    43-![C4 System Context](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-backend/docs/generated/c4views/C1-Context.puml)
    44-
    45-> More C4 views (containers, per-component focus) live in
    46-> [`petclinic-backend/docs/README.md`](petclinic-backend/docs/README.md).
    6+| Decision | Why, in this context | Rejected |
    7+| --- | --- | --- |
    8+| No service layer: controllers call repositories and mappers directly | Almost every use case is CRUD with no business logic, so a service layer would just pass calls through | Controller → Service → Repository everywhere; add a service only for a use case with real logic or 2+ writes |
    9+| Postgres runs embedded (zonky jar in dev, in-process in tests), no Docker | Trainees' laptops often have no Docker, or a corporate policy blocks it; the app must start with Java alone | Testcontainers, docker-compose |
    10+| Notifying the owner is best effort: an HTTP POST to notification-service after the visit is saved; a failure is logged, the booking stands | An SMS outage must not stop the clinic from booking visits | Sending in the same transaction; a message broker (one more thing to run in a training room) |
    Automated checks against accidental drift, run **locally** in `.githooks/pre-com
    1717 | Guardrail | What it protects | How |
    1818 |---|---|---|
    1919 | `PackagesArchTest` | Logical architecture | ArchUnit asserts package deps adhere to `petclinic-backend/docs/packages.puml`, and that the diagram's package set matches the code's subpackages exactly |
    20+| `packages-arch.test.mjs` | Frontend logical architecture | `npm run test:arch` (`node:test`, no dependencies, run in CI) asserts every relative import between top-level folders of `petclinic-frontend/src/app` is an arrow in `petclinic-frontend/docs/frontend-packages.puml`, and that the diagram's boxes (stereotype = folder relative to `petclinic-frontend/`) match those folders exactly. `*.spec.ts` is skipped, like ArchUnit's `DoNotIncludeTests`; an unused arrow does not fail |
    2021 | `C3ArchTest` | C4 architecture drift | Parses `docs/c4model.dsl` (stable, human C1/C2), which `!include`s `docs/c4model.c3.dsl` (code-coupled C3); asserts every code package maps to a component and component-to-component deps match the code; re-exports the `*.puml` C4 views |
    2122 | `DeploymentDiagramTest` | Deployment diagram drift | Reads the mxGraph XML embedded in `petclinic-backend/docs/Deployment.drawio.png` (a real draw.io file: one artifact that both renders on GitHub and opens for editing in the desktop app) and compares its arrows against the browser suites' `petclinic-test/generated/*.genseq.puml`, generated from real OpenTelemetry traces (the `.java.*` diagrams in that folder are skipped: a MockMvc `@SpringBootTest` is deployed nowhere). Containers carry `traceParticipant="Browser|Backend|DB|…"` — an external system the generator draws on a quoted lifeline (`"SMS gateway"`) is a box like any other — and arrows carry `traced="yes|no"`. Nothing else on the picture is checked, so nothing else is written on it: no protocol on an arrow, no technology or port under a name. Fails both ways — a traced arrow no trace shows, and a call the traces show that the picture does not draw — plus a third check that no arrow between two traced containers is left undeclared, so a new connection cannot opt out of the guardrail in silence |
    2223 | `ConceptualModelDiagramTest` | Conceptual model drift | Reads the mxGraph XML embedded in `petclinic-backend/docs/ConceptualModel.drawio.png` — the hand-arranged map of the domain, kept alongside the generated `DomainModel.puml` because a regenerated diagram cannot stay in the same place, and people navigate this one from memory. Boxes carry `concept="Owner"`, lines `assoc="Owner-Pet"`; cardinalities are checked against the **visible** end labels — a bare `*` at a many end, nothing at a to-one end — so the picture cannot pass while showing a marker it does not mean, and an unmarked end is asserted to really be to-one. Fails both ways on concepts and on links, rejects a line between two concepts that declares no `assoc`, and fails while any concept still sits untouched in the staging lane `docs/scripts/conceptual-model-patch.py` parked it in — layout is the one thing here a human owns |
    README.md+96 −52
    engineering techniques you can lift into your own projects.
    1616 - **Diagrams generated from code, rendered from source.** `.puml` files are
    1717 committed and rendered live via the PlantUML proxy straight off GitHub raw —
    1818 no build step to view them; each render carries a `footer` naming its own
    19- source (see [ARCHITECTURE.md](ARCHITECTURE.md)).
    19+ source (see [Architecture diagrams](#architecture-diagrams)).
    2020 - **C4 model** as versioned Structurizr DSL: stable, human C1/C2 + a
    2121 *code-coupled* C3 that is unit-tested against the real packages.
    2222 - **E2E traces → sequence diagrams.** Tempo/OpenTelemetry spans from a browser
    engineering techniques you can lift into your own projects.
    3636 For example, this domain class diagram is generated from the code by
    3737 `DomainModelExtractorTest` and rendered live from the committed
    3838 [`DomainModel.puml`](petclinic-backend/docs/generated/DomainModel.puml) — see
    39-[ARCHITECTURE.md](ARCHITECTURE.md) for the rest:
    39+[Architecture diagrams](#architecture-diagrams) for the rest:
    4040
    4141 ![Domain model](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-backend/docs/generated/DomainModel.puml)
    4242
    B) Manual: Run in separate terminals:
    5151 ./start-frontend.sh # ⇒ Angular dev server :4200
    5252 ./start-grafana.sh # ⇒ :3300 (Requires Docker)
    5353 ```
    54+No Docker? `./start-tempo.sh` instead of `./start-grafana.sh` — Tempo alone (one downloaded
    55+binary, :3200 + OTLP :4318): no dashboards, but everything the sequence diagrams need.
    5456
    5557 - App UI: http://localhost:4200
    5658 - Backend Swagger: http://localhost:8080/swagger-ui.html
    B) Manual: Run in separate terminals:
    5961
    6062 Tell an agent running in this repo:
    6163
    62-### Context Engineering
    64+### Agent Tasks
    6365
    64-- **Trim boilerplate** — remove from AGENTS.md the mvn/npm instructions any LLM already knows.
    65-- **Point at generated sources** — replace AGENTS.md's `## API Endpoints` with a pointer to the auto-synced `openapi.yaml`.
    66+- **Trim boilerplate from .md** — remove from AGENTS.md the mvn/npm instructions any LLM already knows.
    67+- **AGENTS.md: Fix drift** — check it is non-contradictory and in sync with project modules.
    6668 - **Scope rules by folder** — extract backend rules into a nested `petclinic-backend/AGENTS.md` that loads only there.
    67-- **Path-scoped skill** — move `### Java Code Style` into a `java/SKILL.md`
    68-- **Force load a skill for by file paths**: Add to skill's frontmatter `paths: petclinic-backend/**/*.java`, so it 100% activates before any `.java` edit.
    69-- **Reference drift-safe knowledge** — replace the drifting `## Domain Model` chapter with a link to the in-sync `DomainModel.puml`. Add a link to DB.sql and openapi.yaml
    70-- **Audit AGENTS.md** — check it is non-contradictory and in sync with recent code changes.
    69+- **Prevent .md drift** — replace AGENTS.md's `## API Endpoints` with a pointer to the in-sync `openapi.yaml`.
    70+- **AGENTS.md: Reference drift-safe knowledge** — replace the list of REST Endpoints in AGENTS.md with a link to the in-sync `openapi.yaml`.
    71+- **Create a skill** — move `### Java Code Style` from AGENTS.md into a `java-code-style/SKILL.md`. Test it activates ±eval on 3 runs.
    72+- **Turn skill into rule**: Turn the Java Code Style skill you extracted into a .claude/rules/java-rule.md that is loaded 100% before any .java file is edited.
    73+- **Hooks** – create a hook that plays a sound when the agent finished its turn or waits for your answer/approval.
    74+- **Scripted guardrail** – remove the rule about < 120 chars and try to manually violate it
    7175
    72-### Tasks
    76+### Petclinic Tasks
    7377
    7478 The tasks below have the pattern: **<description>** – <prompt to paste to agent>
    7579
    7680 - **⭐BE+FE feature** – Improve the owner search ([Issue #24](https://github.com/victorrentea/petclinic/issues/24)), then optionally review the code
    7781 - **Fix BE+FE bug** – Fix missing visit date validation ([Issue #40](https://github.com/victorrentea/petclinic/issues/40)): reproduce it in a browser, write a failing Playwright e2e test, then fix the bug so the test passes
    7882 - **Fix UI layout** – align the labels and values in the [owner details screen](http://localhost:4200/petclinic/owners/1) via Playwright screenshots
    79-- **Exploratory QA** — download the [Playwright test agents](https://playwright.dev/docs/test-agents) and explore the app to write 10 significant automated .feature e2e tests
    80-- **Regenerate the [user manual](user-manual/manual.md)** — `/regen-user-manual`
    81-- **Grafana dashboard** — create a dashboard of what to monitor, then open it (start Grafana's Docker if needed).
    83+- **Regenerate the [user manual](docs/user-manual/manual.md)** — `/regen-user-manual`
    8284 - **Latency study** — break down the time budget of a "search owners" click from recorded Grafana traces - where is most time lost?
    8385 - **SQL** — export an Excel pie chart of the pet types querying `postgres-db`, and open it when ready.
    84-- **Query tuning** — optimize the "search owners by last name" query.
    85-- **Speedup tests** — speedup the backend tests.
    86-- **Rule → guardrail** — replace the AGENTS.md rule "Keep line length < 120 chars" with a script that enforces it on `git push`, over the Java files the push actually changes.
    87-- **⭐DevOps drills** — seed a red pipeline, a latency incident or a stale runbook, then drive an agent to green: [`exercises/devops/`](exercises/devops/)
    86+- **Query tuning** — optimize the "search owners by last name" flow.
    87+- **Tuning tests** — speedup the backend tests (takes lots of time & tokens).
    88+- **Exploratory QA** — download the [Playwright test agents](https://playwright.dev/docs/test-agents) and explore the app to write 10 significant automated .feature e2e tests
    89+- **Grafana dashboard** — create a dashboard of what to monitor, then open it (start Grafana's Docker if needed).
    8890
    8991 Some tasks above require tools from the project's `.mcp.json`, which should autoload when you start the agent in this folder.
    9092
    91-
    9293 ### Tools
    9394 Start YOUR agent in YOUR🫵 work project and tell it:
    9495
    9596 "Help me grant you access to:..."
    96-- **Issues** — fetch the issues assigned to me on this git repo, put a test comment on the last one
    97-- **CI** — find out how much time the tests took in the last CI run
    98-- **Logs** — get the last errors from the dev environment log
    99-- **Browser** – reproduce a recent FE bug in a browser
    100-- **DB** - which database tables have the most rows in my dev DB?
    101-- **Metrics** - What is the endpoint with the highest latency in Grafana?
    102-- **Token Saving** - Configure me headroom or at least RTK to save tokens.
    97+- **Issues** — proof: fetch the issues assigned to me on this git repo, put a test comment on the last one
    98+- **Confluence** — proof: get all pages I've edited/commented / find pages about sick leave procedure
    99+- **CI** — proof: find out how much time the tests took in the last CI run
    100+- **Logs** — proof: get the last errors from the dev/uat environment log
    101+- **Browser** – proof: reproduce in a browser the latest UI bug in my app / explore my app at localhost:... and find 3 critical flows to test
    102+- **DB** - proof: which tables have the most rows in my dev DB?
    103+- **Metrics** - proof: what is the endpoint with the highest latency in Grafana?
    103104
    104-Patiently guide the agent through this setup, then tell it: Turn the lessons you learned into a reusable skill, ideally scripting as much work you can.
    105+Patiently help the agent through this setup - you might need to provide it with an API key.
    106+When done, prompt it: Turn the lessons you learned into a reusable skill, scripting as much work you can.
    105107
    106108 ### Tools for Agentic Lifestyle
    107109 - [WisprFlow](https://wisprflow.ai) to dictate.
    108-- [CodexBar](https://codexbar.app) to see the remaining Claude/Codex/Copilot quota.
    109-- [ScreenBrush](https://apps.apple.com/us/app/screenbrush/id1233965871) to draw on screen before screenshot to agent.
    110-- Custom statusline: [victorrentea/victor-statusline](https://github.com/victorrentea/victor-statusline) — rich status bars for Claude Code and Copilot CLI (per-turn cost, quota burn-rate, prompt-cache health). Tell your CLI agent: *configure yourself a status bar following that repo's README*.
    111-
    112-### Adopt ideas from `petclinic` into your own project ❤️
    113-
    114-Start an agent in **your** project and tell it:
    115-From the https://github.com/victorrentea/petclinic repo…
    116-- get the mechanism that keeps `packages.puml` in sync with code structure.
    117-- get the mechanism that generates `DomainModel.puml` from code.
    118-- adopt the database migrations scripts technique
    119-- get the mechanism to auto-build `DB.puml` from the incremental DB scripts.
    120-- show me where my GitHub Copilot AI credits went — per day, per model and per session — using
    121- https://github.com/victorrentea/copilot-usage.
    122-- copy the idea to keep the backend Java in sync with `openapi.yaml`, and the frontend `api-types.ts` - prove that a change in a backend Dto fails the FE build, ran automatically prepush and on CI.
    123-- get the way agent is kept in a loop to fix CI its push broke.
    124-- get how to run critical tests before every push and again remotely in CI.
    125-- copy the CODEOWNERS idea to protect critical files behind tech-lead/architect review to prevent dev fatigue-LGTM.
    126-- write 3 .feature tests for the most critical flows of my app XYZ
    127-- adopt the technique to generate sequence diagrams from key e2e tests, one per scenario, as in `petclinic-test/generated/*.genseq.puml`.
    128-- get the code review skill using local sonar scanner and multi-agent review
    129-- assemble a reviewer's guide for a change set — diagram deltas, Code City, a video of the
    130- feature, complexity delta and snippets deep-linked into the editor — with
    131- https://github.com/victorrentea/human-review (`/plugin marketplace add victorrentea/human-review`),
    132- which is the `/human-review` skill this repo symlinks into `.claude/skills/`.
    133-- set up an End/Stop hook that plays a sound when the agent finishes its turn.
    110+- [CodexBar](https://codexbar.app) and/or [AgentsView](https://www.agentsview.io/) to monitor the remaining Claude/Codex/Copilot quota/usage.
    111+- [ScreenBrush](https://apps.apple.com/us/app/screenbrush/id1233965871) to draw on screen before a screenshot to agent.
    112+- [WalkieTalkie](https://github.com/victorrentea/walkie-talkie) to speak and point/crop/move/select/copy/record.
    113+- [Victor's status line](https://github.com/victorrentea/victor-statusline) for Claude Code and Copilot CLI: per-turn cost, quota burn-rate, prompt-cache health, suspend until quota refills
    114+- [Human Review](https://github.com/victorrentea/human-review) - helper suite to review AI-gen PRs effectively
    115+
    116+### Ideas to adopt into your own project ❤️
    117+
    118+Start an agent in **your** project and prompt it:
    119+From the https://github.com/victorrentea/petclinic repo adopt…
    120+- `packages.puml` kept in sync with code structure via tests.
    121+- `DomainModel.puml` generated from code via reflection.
    122+- `ConceptualModel.drawio.png` unit-tested vs code using reflection.
    123+- `DB.puml` built from from the incremental DB scripts.
    124+- In-sync Backend Java REST API <=> `openapi.yaml` <=> frontend `api-types.ts`.
    125+- Agent auto-fixes the CI after any push.
    126+- Running critical tests before every push (githooks/) and again in CI (workflows/).
    127+- CODEOWNERS file to protect critical files behind tech-lead/architect review to prevent dev fatigue-LGTM.
    128+- Gherkin .feature tests - write 3 for the most critical flows of my app
    129+- Generate sequence diagrams from selected e2e tests, as in `petclinic-test/generated/*.genseq.puml`.
    130+- assemble a reviewer's guide for a change set — as in https://github.com/victorrentea/human-review
    131+
    132+## Architecture diagrams
    133+
    134+Every diagram is **generated from the code** and rendered live via the
    135+[PlantUML proxy](https://plantuml.com/) off the GitHub-hosted `.puml` source —
    136+each carries a `footer` with its own repo path, so the render is self-identifying.
    137+
    138+The two `.drawio.png` diagrams are the exception: drawn by hand, but not therefore
    139+unchecked — the mxGraph XML rides inside the picture, and a guardrail test reads it back
    140+and compares it against the code.
    141+
    142+### Deployment (FE → BE → DB)
    143+![Deployment](petclinic-backend/docs/Deployment.drawio.png)
    144+
    145+> Hand-drawn, and it has to be: the backend cannot
    146+> introspect the Angular SPA or PostgreSQL. It stays honest anyway — it is a `.drawio.png`,
    147+> so the mxGraph XML rides inside the picture (open it in the draw.io desktop app and edit
    148+> it in place), and every box and arrow carries metadata that `DeploymentDiagramTest`
    149+> checks against the sequence diagrams generated from real traces.
    150+
    151+### Domain model
    152+![Domain model](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-backend/docs/generated/DomainModel.puml)
    153+
    154+### Conceptual model (hand-arranged)
    155+![Conceptual model](petclinic-backend/docs/ConceptualModel.drawio.png)
    156+
    157+> The same concepts and links as above, but laid out by people and staying where they were
    158+> put — which is the whole point, since PlantUML re-arranges its own diagram on every
    159+> regeneration and resets everyone's spatial memory of it. `ConceptualModelDiagramTest` owns
    160+> the content (every box, line and cardinality must match the domain classes) and never the
    161+> positions; a new concept is parked in the staging lane on the left by
    162+> `docs/scripts/conceptual-model-patch.py` and keeps the build red until a human places it.
    163+
    164+### Database (ER)
    165+![Database](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-backend/docs/generated/DB.puml)
    166+
    167+### Packages (logical architecture)
    168+![Packages](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-backend/docs/packages.puml)
    169+
    170+### E2E sequence (from real traces)
    171+![E2E sequence](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-test/generated/add-visit.spec.ts.add-a-visit-to-an-existing-pet-from-the-owner-detail-page.genseq.puml)
    172+
    173+### C4 — System Context
    174+![C4 System Context](https://www.plantuml.com/plantuml/proxy?cache=no&src=https://raw.githubusercontent.com/victorrentea/petclinic/main/petclinic-backend/docs/generated/c4views/C1-Context.puml)
    175+
    176+> More C4 views (containers, per-component focus) live in
    177+> [`petclinic-backend/docs/README.md`](petclinic-backend/docs/README.md).
     
    11 # One self-contained PetClinic. Every service keeps its default port *inside* the
    2-# network; only the frontend is published, and to a port the host picks, so several
    3-# instances (one per branch under review) can run at once without colliding.
    2+# network; the frontend (and, for tools on the host, the backend and the database) are
    3+# published to ports the host picks, so several instances (one per branch under review)
    4+# can run at once without colliding.
    45 #
    56 # Driven by ../start-docker.sh — it sets COMPOSE_PROJECT_NAME, which is what namespaces
    67 # containers, network and volumes, and what the reaper matches on to tear one instance
    services:
    1516 POSTGRES_USER: petclinic
    1617 POSTGRES_PASSWORD: petclinic
    1718 POSTGRES_DB: petclinic
    19+ ports:
    20+ # So DataGrip / DBeaver / psql on the host can look inside a running instance: the
    21+ # review page's "Running Database" row prints its JDBC URL (`start-docker.sh db`).
    22+ # Host-picked and loopback-bound like the frontend's, for the same two reasons.
    23+ - "127.0.0.1::5432"
    1824 volumes:
    1925 - pgdata:/var/lib/postgresql/data
    2026 healthcheck:
    services:
    95101 # the review page. See the Dockerfile for why they are copied in, not mounted.
    96102 additional_contexts:
    97103 fixtures: ${PETCLINIC_SRC}/petclinic-backend/src/main/resources/db/fixtures
    104+ # The reference rows an "empty" reset (POST /__reset/empty) writes back.
    105+ reference: ${PETCLINIC_SRC}/petclinic-backend/src/main/resources/db/reference
    98106 labels:
    99107 ro.victorrentea.petclinic.env: "1"
    100108 environment:
    Dockerfile+6 −0
    COPY reset-server.py /reset-server.py
    1414 # exit, so a mount would leave every pinned instance with an empty folder.
    1515 COPY --from=fixtures . /fixtures/
    1616
    17+# The reference rows (pet types, specialties) an "empty" reset writes back after emptying
    18+# every table — the same file Flyway runs, so an empty stack and an empty test database
    19+# hold the same rows. Named, not globbed: renaming it should fail this build, not leave the
    20+# empty reset writing nothing.
    21+COPY --from=reference R__reference.sql /reference.sql
    22+
    1723 # Docker copies ownership from the image's directory when it first populates an empty
    1824 # named volume. Without this, /seed arrives root-owned and the unprivileged process
    1925 # cannot write the dump into it.
     
    11 #!/usr/bin/env python3
    2-"""Put the demo database back to the state Flyway left it in — or to a named fixture.
    2+"""Put the demo database back to the state Flyway left it in, to a named fixture, or empty.
    33
    44 This is deliberately *not* part of the backend, and that is the whole design. A reset
    55 endpoint that ships inside the application is one misconfigured profile away from being
    Dropping the schema invalidates those plans and poisons every pooled connection
    1616 Hikari retires it, up to half an hour later.
    1717
    1818 A fixture is a *.sql file baked into /fixtures from petclinic-backend's db/fixtures, next to
    19-the seed it builds on. It is a delta: POST /<name> truncates, restores the seed, and runs
    20-<name>.sql on top, in one transaction — so a fixture that fails leaves the database exactly
    21-as it was, not half-emptied. GET / lists them, and the review page draws one button each.
    19+the seed. It is a dataset of its own, not a delta on the seed: POST /<name> truncates and
    20+runs <name>.sql on the empty tables, in one transaction — so a fixture that fails leaves the
    21+database exactly as it was, not half-emptied. POST / restores the seed instead. GET / lists
    22+the fixtures, and the review page draws one button each.
    23+
    24+POST /empty is the state a test that declares no fixture starts on — the e2e suites reset to
    25+it before each such test (petclinic-test/src/support/db-fixture.ts), as the Spring tests do:
    26+every table emptied, then the reference rows written back (pet types, specialties —
    27+db/reference/R__reference.sql, baked in as /reference.sql). Built in rather than a file in
    28+/fixtures: it is the absence of a dataset, not one more of them, and gets no button.
    2229 """
    2330 import http.server
    2431 import json
    import threading
    3037
    3138 SEED = "/seed/seed-data.sql"
    3239 FIXTURES = "/fixtures"
    40+REFERENCE = "/reference.sql"
    41+EMPTY = "empty"
    3342 # On the seed volume, so the page can still say which state it last reset to after this
    3443 # container restarts. "Last reset to", not "is in": a reviewer may have typed since.
    3544 CURRENT = "/seed/current"
    NAME = re.compile(r"[a-z0-9][a-z0-9-]*")
    3948 PSQL = ["psql", "-v", "ON_ERROR_STOP=1", "-q"]
    4049
    4150 # Every table in public, whatever the schema happens to be by then — a hand-kept list
    42-# would silently stop covering a table the next migration adds.
    43-TRUNCATE = """
    51+# would silently stop covering a table the next migration adds. `{keep}` spares tables
    52+# by name.
    53+_TRUNCATE = """
    4454 DO $$
    4555 DECLARE stmt text;
    4656 BEGIN
    BEGIN
    4858 || string_agg(format('%I.%I', schemaname, tablename), ', ')
    4959 || ' RESTART IDENTITY CASCADE'
    5060 INTO stmt
    51- FROM pg_tables WHERE schemaname = 'public';
    61+ FROM pg_tables WHERE schemaname = 'public' AND tablename NOT IN ({keep});
    5262 IF stmt IS NOT NULL THEN EXECUTE stmt; END IF;
    5363 END $$;
    5464 """
    65+# Before the seed: everything, Flyway's history included — the dump puts it back.
    66+TRUNCATE = _TRUNCATE.format(keep="''")
    67+# Before a fixture: everything but Flyway's history. A fixture is hand-written and does not
    68+# carry the history, and a backend that boots onto an empty history over a full schema
    69+# refuses to start.
    70+TRUNCATE_DATA = _TRUNCATE.format(keep="'flyway_schema_history'")
    5571
    5672 _lock = threading.Lock()
    5773
    def current():
    96112
    97113
    98114 def do_reset(fixture=None):
    99- args = PSQL + ["--single-transaction", "-c", TRUNCATE, "-f", SEED]
    100- if fixture:
    101- # pg_dump's output empties search_path for the rest of the session, and the fixture
    102- # names its tables unqualified, as any hand-written SQL would.
    103- args += ["-c", "SET search_path TO DEFAULT",
    104- "-f", os.path.join(FIXTURES, fixture + ".sql")]
    115+ # A fixture starts from the empty tables, never from the seed: what it shows is only
    116+ # what its own file writes. RESTART IDENTITY in TRUNCATE puts every id back at 1.
    117+ if fixture == EMPTY:
    118+ args = ["-c", TRUNCATE_DATA, "-f", REFERENCE]
    119+ elif fixture:
    120+ args = ["-c", TRUNCATE_DATA, "-f", os.path.join(FIXTURES, fixture + ".sql")]
    121+ else:
    122+ args = ["-c", TRUNCATE, "-f", SEED]
    123+ args = PSQL + ["--single-transaction"] + args
    105124 with _lock:
    106125 r = _run(args)
    107126 if r.returncode != 0:
    class Handler(http.server.BaseHTTPRequestHandler):
    123142
    124143 def do_POST(self):
    125144 name = self.path.split("?")[0].strip("/")
    126- if name and name not in fixtures():
    145+ if name and name != EMPTY and name not in fixtures():
    127146 self._send(404, {"ok": False, "error": "no such fixture: " + name,
    128147 "fixtures": fixtures()})
    129148 return
    manual.md+162 −0
     
    1+# PetClinic User Manual
    2+
    3+PetClinic is a small veterinary practice management application. You can use it to keep records of pet owners, the pets in their care, the veterinarians on staff, the specialties those vets practice, and the visits the pets make to the clinic.
    4+
    5+_This file is regenerated by the `/regen-user-manual` slash command. Do not edit by hand — your changes will be overwritten on the next regeneration._
    6+
    7+## Contents
    8+
    9+- [Getting around](#getting-around)
    10+- [Welcome](#welcome)
    11+- [Owners](#owners)
    12+- [Pets](#pets)
    13+- [Visits](#visits)
    14+- [Pet Types](#pet-types)
    15+- [Veterinarians](#veterinarians)
    16+- [Specialties](#specialties)
    17+
    18+---
    19+
    20+## Getting around
    21+
    22+The top navigation bar is your starting point. *Home* takes you back to the welcome screen. *Owners* and *Veterinarians* are dropdown menus — clicking them reveals shortcuts for searching the existing records and adding new ones. *Visits*, *Pet Types*, and *Specialties* are direct links to those areas.
    23+
    24+![](screenshots/nav-owners-menu.png)
    25+
    26+In the screenshot above, the *Owners* menu has been opened, showing *Search* (the owners list) and *Add New* (the new-owner form).
    27+
    28+---
    29+
    30+## Welcome
    31+
    32+The welcome screen is what you see when you open PetClinic or click *Home*. It is purely a starting point — no records or actions live here. Use the navigation bar to jump to whichever area you need.
    33+
    34+![](screenshots/welcome.png)
    35+
    36+---
    37+
    38+## Owners
    39+
    40+The Owners area is where you keep records for the people who bring pets to the clinic. From here you can search the existing owners, look up a specific owner's pets and visit history, register a new owner, and edit an owner's contact details.
    41+
    42+### Viewing the list
    43+
    44+![](screenshots/owners-list.png)
    45+
    46+The list shows every registered owner. Each row includes the address, city, telephone, and the names of the pets associated with that owner. Above the table is a *Last name* search box — type a last name (or any prefix) and click *Find Owner* to narrow the list to matching owners. Click an owner's name in the *Name* column to open their full record. The *Add Owner* button at the bottom of the list opens the new-owner form.
    47+
    48+### Creating a new owner
    49+
    50+![](screenshots/owners-create.png)
    51+
    52+Click *Add Owner* on the list page (or *Add New* under the *Owners* menu) to open the registration form. Fill in *First Name*, *Last Name*, *Address*, *City*, and *Telephone* — every field is required. The *Add Owner* button stays disabled until all fields have valid values. Submitting takes you back to the owners list with the new record added.
    53+
    54+### Looking at an owner's record
    55+
    56+![](screenshots/owners-detail.png)
    57+
    58+Clicking any owner name on the list opens their detail page. The top section shows the owner's contact info with three actions: *Back* returns to the list, *Edit Owner* opens the edit form (see below), and *Add New Pet* registers a pet against this owner. Below that, the *Pets and Visits* section lists each of the owner's pets with their birth date and type, plus per-pet *Edit Pet*, *Delete Pet*, and *Add Visit* buttons. Each pet's visit history follows, with *Edit Visit* and *Delete Visit* actions on every entry.
    59+
    60+### Editing an owner
    61+
    62+![](screenshots/owners-edit.png)
    63+
    64+From the owner's record click *Edit Owner*. The form is pre-filled with the current values; change what you need and click *Update Owner* to save. *Back* discards your changes and returns to the record.
    65+
    66+---
    67+
    68+## Pets
    69+
    70+Pets always belong to an owner — there is no standalone "all pets" listing in the staff workflow. You manage a pet through its owner's record: add a new pet from the owner detail screen, then edit, delete, or schedule visits for it from the same place.
    71+
    72+### Adding a pet to an owner
    73+
    74+![](screenshots/pets-add.png)
    75+
    76+From the owner's detail page click *Add New Pet*. The form locks the *Owner* field to whoever you came from. Enter the pet's *Name*, pick a *Birth Date* from the calendar picker, and choose the *Type* (cat, dog, lizard, snake, bird, hamster, or horse). Click *Save Pet* to add it to the owner's record. *< Back* returns to the owner without saving.
    77+
    78+### Editing a pet
    79+
    80+![](screenshots/pets-edit.png)
    81+
    82+Open the owner's detail page and click *Edit Pet* next to the pet you want to change. The same form opens with the current name, birth date, and type pre-filled. Adjust whatever needs changing and click *Save Pet* to update the record; *< Back* discards your changes. To delete a pet instead, click *Delete Pet* on the owner's detail page — it removes the pet and all of its visit history.
    83+
    84+---
    85+
    86+## Visits
    87+
    88+The Visits area gives you a chronological view of every visit across the clinic. It is the right place to check what's been happening recently or to find a specific visit. To register a new visit, however, you start from a specific pet — see [Pets](#pets) above.
    89+
    90+### Viewing all visits
    91+
    92+![](screenshots/visits-list.png)
    93+
    94+The list is sorted with the most recent visit first. Each row shows the visit date, the description of what was done, the pet's name, and a link to the owner's record. Click an owner name to jump to their detail page; from there you can see the visit in the context of that pet's history.
    95+
    96+### Adding a visit
    97+
    98+![](screenshots/visits-add.png)
    99+
    100+To add a visit, open the owner's record (see [Owners](#owners)), find the pet that came in for the visit, and click *Add Visit* in that pet's section. The *New Visit* form opens with the pet's identifying details at the top and the pet's previous visits listed below. Pick the *Date* from the calendar, write a *Description* of what was done, and click *Add Visit* to save. The visit appears under that pet on the owner record and in the chronological *Visits* list. The *Add Visit* button at the bottom of the list page only works in the context of a specific pet — always start from the owner's record.
    101+
    102+---
    103+
    104+## Pet Types
    105+
    106+Pet Types is the list of species the clinic accepts as patients. Whenever you register a new pet, the *Type* dropdown is populated from this list. Edit it sparingly — adding rare types is fine, but renaming or deleting a type changes how every existing pet of that type is shown.
    107+
    108+### Viewing and editing the list
    109+
    110+![](screenshots/pettypes-list.png)
    111+
    112+Each pet type appears as a single row with its name in a read-only field and *Edit* and *Delete* buttons on the right. Click *Edit* to make the name field editable and rename the type, then save. Click *Delete* to remove a type the clinic no longer accepts. *Home* returns to the welcome screen; *Add* opens the new-type form below.
    113+
    114+### Adding a new pet type
    115+
    116+![](screenshots/pettypes-add.png)
    117+
    118+Click *Add* on the Pet Types list. Enter the new type's *Name* in the field — for example, "rabbit" or "ferret" — and click *Save*. The new type immediately becomes available in the *Type* dropdown when registering a pet.
    119+
    120+---
    121+
    122+## Veterinarians
    123+
    124+The Veterinarians area is where you keep the staff roster. You can browse the current vets along with the specialties they practice, register new vets, edit their assignments, or remove vets who have left the clinic.
    125+
    126+### Viewing the list
    127+
    128+![](screenshots/vets-list.png)
    129+
    130+The list shows each veterinarian with their name and the specialties they are qualified in. *Edit Vet* opens the edit form for that vet — useful for changing their specialty assignments. *Delete Vet* removes them from the roster. *Add Vet* at the bottom opens the new-vet form; *Home* returns to the welcome screen.
    131+
    132+### Adding a new veterinarian
    133+
    134+![](screenshots/vets-add.png)
    135+
    136+Enter the vet's *First Name* and *Last Name*, then choose a *Type* (specialty) from the dropdown — radiology, surgery, or dentistry. Click *Save Vet* to add them to the roster. *< Back* discards the entry.
    137+
    138+To assign more than one specialty to a vet, save them with their first specialty here, then open *Edit Vet* from the list to add additional ones.
    139+
    140+### Editing a veterinarian
    141+
    142+![](screenshots/vets-edit.png)
    143+
    144+From the list click *Edit Vet* next to the vet you want to change. The form pre-fills *First Name*, *Last Name*, and a multi-select *Specialties* picker showing every specialty the vet currently holds. Click the picker to add or remove specialties — this is the screen to use whenever a vet picks up a new qualification or steps away from one. Click *Save Vet* to commit the changes; *< Back* discards them.
    145+
    146+---
    147+
    148+## Specialties
    149+
    150+Specialties is the list of veterinary disciplines a vet can be qualified in. The list is short by design — radiology, surgery, dentistry — but you can add, rename, or remove specialties to match how your clinic actually works.
    151+
    152+### Viewing and editing the list
    153+
    154+![](screenshots/specialties-list.png)
    155+
    156+Each specialty appears as a row with its name in a read-only field and *Edit* and *Delete* buttons. Click *Edit* to rename a specialty, then save. *Delete* removes a specialty from the list. Removing a specialty unassigns it from any vet who currently practices it, so be careful before deleting.
    157+
    158+### Adding a new specialty
    159+
    160+![](screenshots/specialties-add.png)
    161+
    162+Click *Add* at the bottom of the Specialties list. A *New Specialty* form opens directly below the table. Type the name of the new discipline — for example, "cardiology" or "oncology" — and click *Save*. The new specialty becomes available in the *Type* dropdown when adding a vet, and in the multi-select picker when editing one.
     
    11 {
    2- "//": "Configuration for the /human-review plugin (github.com/victorrentea/human-review).",
    3- "//2": "The skill itself knows nothing about this project; everything petclinic-specific",
    4- "//3": "lives here. A step this file does not describe is skipped and named under the",
    5- "//4": "strip on the built page, which is the honest rendering of 'we have no such thing'.",
    2+ "//": [
    3+ "Configuration for the /human-review plugin (github.com/victorrentea/human-review).",
    4+ "The skill itself knows nothing about this project; everything petclinic-specific",
    5+ "lives here. A step this file does not describe is skipped and named under the",
    6+ "strip on the built page, which is the honest rendering of 'we have no such thing'."
    7+ ],
    68
    79 "base": "origin/main",
    810 "spec": "openapi.yaml",
     
    1214 "drawio": {
    1315 "diagram": "petclinic-backend/docs/ConceptualModel.drawio.png",
    1416 "concepts": "petclinic-backend/docs/generated/DomainModel.puml",
    15- "//redraw": "conceptual-model-patch.py draws what the code has and the map lacks,",
    16- "//redraw2": "in red, and leaves it red until a human lays it out. Named here so the",
    17- "//redraw3": "review page can offer to start over: restore the drawing to the base",
    18- "//redraw4": "and let the script draw the branch's additions again.",
    17+ "//redraw": [
    18+ "conceptual-model-patch.py draws what the code has and the map lacks,",
    19+ "in red, and leaves it red until a human lays it out. Named here so the",
    20+ "review page can offer to start over: restore the drawing to the base",
    21+ "and let the script draw the branch's additions again."
    22+ ],
    1923 "redraw": "python3 petclinic-backend/docs/scripts/conceptual-model-patch.py",
    2024 "name": "conceptual",
    2125 "//tested_against": "What a unit test checks this drawing against; the review page says so under it.",
     
    2327 }
    2428 },
    2529 "sequence": {
    26- "//": "The traced suites run against a stack of their own, never the host's: `up`",
    27- "//2": "builds the commit under review WITH its own Grafana LGTM (--otel), so the",
    28- "//3": "backend's and the browser's spans land in a Tempo nobody else exports to, and",
    29- "//4": "two branches traced at once cannot draw each other's calls. --fresh drops the",
    30- "//5": "instance's volumes, so every run starts from the seed: a diagram drawn on",
    31- "//6": "yesterday's leftover rows shows queries the code does not make on a clean DB.",
    32- "//7": "Its own name (-otel), not video's petclinic-{shortsha}: that one is shared and",
    33- "//8": "reused across steps, and has no trace store. `vars` prints every host-picked",
    34- "//9": "address as NAME=value — GRAFANA_URL, OTEL_EXPORTER_OTLP_ENDPOINT, BACKEND_URL,",
    35- "//10": "BASE_URL, API_BASE_URL — which the step exports to every command: the",
    36- "//11": "browser suites and generate.ts read them, the genseq profile's JVM inherits",
    37- "//12": "OTEL_EXPORTER_OTLP_ENDPOINT and NOTIFICATIONSERVICE_URL (its visit booking",
    38- "//12b": "notifies this instance's service, or AddVisitApiTest's picture loses the",
    39- "//12c": "call). `requires` is probed after `up`, with those names",
    40- "//13": "expanded: a stack that came up without the store is a skip saying so.",
    30+ "//": [
    31+ "The traced suites run against a stack of their own, never the host's: `up`",
    32+ "builds the commit under review WITH its own Grafana LGTM (--otel), so the",
    33+ "backend's and the browser's spans land in a Tempo nobody else exports to, and",
    34+ "two branches traced at once cannot draw each other's calls. --fresh drops the",
    35+ "instance's volumes, so every run starts from the seed: a diagram drawn on",
    36+ "yesterday's leftover rows shows queries the code does not make on a clean DB.",
    37+ "Its own name (-otel), not video's petclinic-{shortsha}: that one is shared and",
    38+ "reused across steps, and has no trace store. `vars` prints every host-picked",
    39+ "address as NAME=value — GRAFANA_URL, OTEL_EXPORTER_OTLP_ENDPOINT, BACKEND_URL,",
    40+ "BASE_URL, API_BASE_URL — which the step exports to every command: the",
    41+ "browser suites and generate.ts read them, the genseq profile's JVM inherits",
    42+ "OTEL_EXPORTER_OTLP_ENDPOINT and NOTIFICATIONSERVICE_URL (its visit booking",
    43+ "notifies this instance's service, or AddVisitApiTest's picture loses the",
    44+ "call). `requires` is probed after `up`, with those names",
    45+ "expanded: a stack that came up without the store is a skip saying so."
    46+ ],
    4147 "app": {
    4248 "up": "./start-docker.sh up --ref {sha} --name petclinic-{shortsha}-otel --otel --fresh --ttl 1800",
    4349 "vars": "./start-docker.sh ports petclinic-{shortsha}-otel",
     
    4753 {"url": "{GRAFANA_URL}/api/health", "what": "Grafana"},
    4854 {"url": "{OTEL_EXPORTER_OTLP_ENDPOINT}/v1/traces", "what": "OTLP collector"}
    4955 ],
    50- "//14": "`select` adds the tests this branch WROTE or EDITED (test-changes.py's",
    51- "//15": "manifest) to the tagged ones, untagged: `{tests.<suite>}` in a command expands",
    52- "//16": "to that suite's picks, each spelled as `item` and joined by `join` — the way",
    53- "//17": "its runner takes them. GENSEQ_SELECT is read by both browser runners",
    54- "//18": "(sequence-tag.ts: <file>::<title>, one per line) and by the JVM's",
    55- "//19": "SequenceTraceExtension (Class#method, the -Dtest syntax). The `when: java` run",
    56- "//20": "boots a Spring context only when there is a JVM test to trace. Capped at 6",
    57- "//21": "(4 browser + 2 JVM) to bound the step's time; a test already tagged takes no slot.",
    56+ "//select": [
    57+ "`select` adds the tests this branch WROTE or EDITED (test-changes.py's",
    58+ "manifest) to the tagged ones, untagged: `{tests.<suite>}` in a command expands",
    59+ "to that suite's picks, each spelled as `item` and joined by `join` — the way",
    60+ "its runner takes them. GENSEQ_SELECT is read by both browser runners",
    61+ "(sequence-tag.ts: <file>::<title>, one per line) and by the JVM's",
    62+ "SequenceTraceExtension (Class#method, the -Dtest syntax). The `when: java` run",
    63+ "boots a Spring context only when there is a JVM test to trace. Capped at 6",
    64+ "(4 browser + 2 JVM) to bound the step's time; a test already tagged takes no slot."
    65+ ],
    5866 "select": {
    5967 "max": 6,
    6068 "suites": {
     
    8189 ]
    8290 },
    8391 "c2": {
    84- "//": "The C4 container view, PROJECTED from the sequence diagrams the step above",
    85- "//2": "draws. Nothing here is a drawing, and every key is optional: with no `c2`",
    86- "//3": "block at all the tab is still built, from the lifelines and arrows alone.",
    87- "//4": "`containers` corrects the only thing a sequence diagram cannot say — what",
    88- "//5": "KIND of thing a lifeline is — and names the stack, which is never guessed.",
    89- "//6": "`drop` on the Java suite is the important one. A C2 shows what is DEPLOYED,",
    90- "//7": "and a @SpringBootTest calling controllers through MockMvc is deployed",
    91- "//8": "nowhere: it is a synthetic client, in-process, with no browser, no socket",
    92- "//9": "and no server. It draws a fine sequence diagram — that is the Sequence",
    93- "//10": "tab's job — and a box here that no operator could point at. The Playwright",
    94- "//11": "suite is the opposite case and stays — but it is not what the box is",
    95- "//12": "called. The container is the BROWSER running the Angular app; Playwright",
    96- "//13": "and Chromium are how this repository happens to drive it in CI, which is",
    97- "//14": "a fact about the test rig and not about anything that ships.",
    98- "//15": "`Test` -> `as` folds the base branch's older participant name in first, so",
    99- "//16": "one `drop` covers a suite that renamed its own lifeline on this branch.",
    100- "//17": "`NotificationService` is a container of its own: a separate JVM on :8090,",
    101- "//18": "reached over HTTP. Its `SMS gateway` stays external — still a log line.",
    102- "//19": "`drawio` is the hand-drawn picture of the same containers, shown under the",
    103- "//20": "projected one: arrows a test walked drawn heavy, the rest grey, and a call the",
    104- "//21": "traces made that no arrow carries added in dashed red. DeploymentDiagramTest",
    105- "//22": "is the guardrail that fails on that red; the page shows where it is.",
    92+ "//": [
    93+ "The C4 container view, PROJECTED from the sequence diagrams the step above",
    94+ "draws. Nothing here is a drawing, and every key is optional: with no `c2`",
    95+ "block at all the tab is still built, from the lifelines and arrows alone.",
    96+ "`containers` corrects the only thing a sequence diagram cannot say — what",
    97+ "KIND of thing a lifeline is — and names the stack, which is never guessed.",
    98+ "`drop` on the Java suite is the important one. A C2 shows what is DEPLOYED,",
    99+ "and a @SpringBootTest calling controllers through MockMvc is deployed",
    100+ "nowhere: it is a synthetic client, in-process, with no browser, no socket",
    101+ "and no server. It draws a fine sequence diagram — that is the Sequence",
    102+ "tab's job — and a box here that no operator could point at. The Playwright",
    103+ "suite is the opposite case and stays — but it is not what the box is",
    104+ "called. The container is the BROWSER running the Angular app; Playwright",
    105+ "and Chromium are how this repository happens to drive it in CI, which is",
    106+ "a fact about the test rig and not about anything that ships.",
    107+ "`Test` -> `as` folds the base branch's older participant name in first, so",
    108+ "one `drop` covers a suite that renamed its own lifeline on this branch.",
    109+ "`NotificationService` is a container of its own: a separate JVM on :8090,",
    110+ "reached over HTTP. Its `SMS gateway` stays external — still a log line.",
    111+ "`drawio` is the hand-drawn picture of the same containers, shown under the",
    112+ "projected one: arrows a test walked drawn heavy, the rest grey, and a call the",
    113+ "traces made that no arrow carries added in dashed red. DeploymentDiagramTest",
    114+ "is the guardrail that fails on that red; the page shows where it is.",
    115+ "`Commons` is petclinic-commons, a jar linked into both JVMs: its lifeline is",
    116+ "drawn by ModuleBoundaryTracing for the Sequence tab, and is no container here."
    117+ ],
    106118 "drawio": {
    107119 "diagram": "petclinic-backend/docs/Deployment.drawio.png",
    108120 "tested_against": "traced sequence diagrams"
     
    116128 "Backend": {"kind": "container", "tech": "Java 21 / Spring Boot 3.5"},
    117129 "NotificationService": {"kind": "container", "tech": "Java 21 / Spring Boot 3.5"},
    118130 "SMS gateway": {"kind": "external", "tech": "SMS provider (simulated)"},
    131+ "Commons": {"drop": true},
    119132 "DB": {"kind": "db", "tech": "PostgreSQL"}
    120133 }
    121134 },
    122135 "city": {
    123- "//": "`tests` is here for the TRACES, not for the city: it is the browser run whose",
    124- "//2": "Playwright report the Tests tab harvests, which is why PW_TRACE=on and why the",
    125- "//3": "`traces` step below runs nothing of its own. Recording the same suite twice",
    126- "//4": "would double the longest step on the page for a second, differently-flaky",
    127- "//5": "opinion about the same branch.",
    128- "//6": "It used to carry two more commands and two more keys: an instrumented",
    129- "//7": "`mvn test`, JACOCO_E2E=1 on the acceptance run, a jacoco:merge, plus",
    130- "//8": "`acceptance` and `baseline`. All of that existed for the city's CRAP and",
    131- "//9": "coverage colours, which are switched off in code-city itself now (generate.sh",
    132- "//10": "step [3]) — the four options stay in its COLOR dropdown marked unavailable.",
    133- "//11": "Minutes of Maven and a second Playwright run per review, for two colours",
    134- "//12": "nothing draws any more. The committed coverage-baseline.tsv went with them.",
    135- "//13": "test-on-own-stack.sh: the suite runs against HEAD's own containers (the same",
    136- "//14": "petclinic-<shortsha> instance video.app uses), never against :4200/:8080 — on",
    137- "//15": "23 Sep 2026 those belonged to another checkout and three tests failed against",
    138- "//16": "a backend from a different branch.",
    139- "//17": "COVERAGE_DIR: the same run also measures, per test, which lines each test",
    140- "//18": "executes on both sides — Chromium's JS coverage and a JaCoCo dump of the backend",
    141- "//19": "(test-on-own-stack.sh starts it with --jacoco). `testcov` below harvests it.",
    136+ "//": [
    137+ "`tests` is here for the TRACES, not for the city: it is the browser run whose",
    138+ "Playwright report the Tests tab harvests, which is why PW_TRACE=on and why the",
    139+ "`traces` step below runs nothing of its own. Recording the same suite twice",
    140+ "would double the longest step on the page for a second, differently-flaky",
    141+ "opinion about the same branch.",
    142+ "It used to carry two more commands and two more keys: an instrumented",
    143+ "`mvn test`, JACOCO_E2E=1 on the acceptance run, a jacoco:merge, plus",
    144+ "`acceptance` and `baseline`. All of that existed for the city's CRAP and",
    145+ "coverage colours, which are switched off in code-city itself now (generate.sh",
    146+ "step [3]) — the four options stay in its COLOR dropdown marked unavailable.",
    147+ "Minutes of Maven and a second Playwright run per review, for two colours",
    148+ "nothing draws any more. The committed coverage-baseline.tsv went with them.",
    149+ "test-on-own-stack.sh: the suite runs against HEAD's own containers (the same",
    150+ "petclinic-<shortsha> instance video.app uses), never against :4200/:8080 — on",
    151+ "23 Sep 2026 those belonged to another checkout and three tests failed against",
    152+ "a backend from a different branch.",
    153+ "COVERAGE_DIR: the same run also measures, per test, which lines each test",
    154+ "executes on both sides — Chromium's JS coverage and a JaCoCo dump of the backend",
    155+ "(test-on-own-stack.sh starts it with --jacoco). `testcov` below harvests it."
    156+ ],
    142157 "tests": [
    143158 "cd petclinic-test && PW_TRACE=on COVERAGE_DIR=../.human-review/coverage ./test-on-own-stack.sh npm test"
    144159 ],
     
    146161 "title": "Code City"
    147162 },
    148163 "traces": {
    149- "//": "Playwright's own recordings of the run, carried onto the page so a reviewer",
    150- "//2": "can step through what a test DID without checking the branch out and running",
    151- "//3": "the suite again. Rows land in the Tests tab, beside the covering tests.",
    152- "//4": "`report` is the html reporter's output directory. Nothing is run from here:",
    153- "//5": "the recordings come from the run `city.tests` already does, and the PW_TRACE=on",
    154- "//6": "command turns tracing ON (read by playwright.config.ts).",
    155- "//7": "The .feature scenarios run under cucumber-js, which writes no Playwright",
    156- "//8": "report: src/support/world.ts records one zip + sidecar per scenario into",
    157- "//9": "`cucumber` when PW_TRACE=on, and the command below is that run. It needs",
    158- "//10": "a running stack (SKIP_SERVER_START); test-on-own-stack.sh gives it the same",
    159- "//11": "per-commit instance city.tests just used, not whatever holds :4200.",
    164+ "//": [
    165+ "Playwright's own recordings of the run, carried onto the page so a reviewer",
    166+ "can step through what a test DID without checking the branch out and running",
    167+ "the suite again. Rows land in the Tests tab, beside the covering tests.",
    168+ "`report` is the html reporter's output directory. Nothing is run from here:",
    169+ "the recordings come from the run `city.tests` already does, and the PW_TRACE=on",
    170+ "command turns tracing ON (read by playwright.config.ts).",
    171+ "The .feature scenarios run under cucumber-js, which writes no Playwright",
    172+ "report: src/support/world.ts records one zip + sidecar per scenario into",
    173+ "`cucumber` when PW_TRACE=on, and the command below is that run. It needs",
    174+ "a running stack (SKIP_SERVER_START); test-on-own-stack.sh gives it the same",
    175+ "per-commit instance city.tests just used, not whatever holds :4200."
    176+ ],
    160177 "commands": [
    161178 "cd petclinic-test && PW_TRACE=on COVERAGE_DIR=../.human-review/coverage ./test-on-own-stack.sh npm run test:cucumber"
    162179 ],
     
    166183 "limit": 16
    167184 },
    168185 "testcov": {
    169- "//": "Which tests execute which changed lines, measured per test — the right-hand",
    170- "//2": "column of the Tests tab. `sources`/`exclude` say what counts as production code;",
    171- "//3": "a changed line outside them (a test, a generated file, a doc) is not counted.",
    172- "//4": "`junit`: {junit} expands to the skill's JUnit Platform listener on the test",
    173- "//5": "classpath; the pom's own jacoco:prepare-agent does the measuring. The backend",
    174- "//6": "suite (JUnit and Cucumber together) runs on a throwaway Postgres, never on the",
    175- "//7": ":5432 every checkout shares (test-on-own-db.sh says why).",
    176- "//8": "`karma`: {karma} expands to the skill's karma config, which loads karma.conf.js.",
    177- "//9": "`e2e`: nothing is run — the browser suites already ran for the traces with",
    178- "//10": "COVERAGE_DIR (city.tests, traces.commands); this reads what they left there.",
    186+ "//": [
    187+ "Which tests execute which changed lines, measured per test — the right-hand",
    188+ "column of the Tests tab. `sources`/`exclude` say what counts as production code;",
    189+ "a changed line outside them (a test, a generated file, a doc) is not counted.",
    190+ "`junit`: {junit} expands to the skill's JUnit Platform listener on the test",
    191+ "classpath; the pom's own jacoco:prepare-agent does the measuring. The backend",
    192+ "suite (JUnit and Cucumber together) runs on a throwaway Postgres, never on the",
    193+ ":5432 every checkout shares (test-on-own-db.sh says why).",
    194+ "`karma`: {karma} expands to the skill's karma config, which loads karma.conf.js.",
    195+ "`e2e`: nothing is run — the browser suites already ran for the traces with",
    196+ "COVERAGE_DIR (city.tests, traces.commands); this reads what they left there."
    197+ ],
    179198 "sources": ["petclinic-backend/src/main/**", "petclinic-frontend/src/app/**"],
    180199 "exclude": ["**/*.spec.ts", "**/generated/**"],
    181200 "junit": [
     
    206225 }
    207226 },
    208227 "video": {
    209- "//": "`app` is how the run gets the application the film is recorded against, and",
    210- "//2": "it exists because the recorder's old liveness check tested the PORT, not the",
    211- "//3": "commit: anything answering on :4200 and :8080 was filmed. This machine keeps",
    212- "//4": "several checkouts of this repository (petclinic, petclinic-main,",
    213- "//5": "petclinic-pr) and each can serve :4200 — so on 17 Sep 2026 a review of",
    214- "//6": "test-pr shipped a film of main, narrated from test-pr's diff, with every",
    215- "//7": "caption asserting what the frames denied.",
    216- "//8": "Now the run owns its own instance: `up` builds the commit under review into",
    217- "//9": "containers of its own and prints the host port it got, `down` runs in a",
    218- "//10": "finally. {sha}/{shortsha} come from `git rev-parse HEAD`, never from here.",
    219- "//11": "--ttl 1800 because this instance exists for one 40-second take; the default",
    220- "//12": "two idle hours would keep a whole stack up long after the film was cut.",
    228+ "//": [
    229+ "`app` is how the run gets the application the film is recorded against, and",
    230+ "it exists because the recorder's old liveness check tested the PORT, not the",
    231+ "commit: anything answering on :4200 and :8080 was filmed. This machine keeps",
    232+ "several checkouts of this repository (petclinic, petclinic-main,",
    233+ "petclinic-pr) and each can serve :4200 — so on 17 Sep 2026 a review of",
    234+ "test-pr shipped a film of main, narrated from test-pr's diff, with every",
    235+ "caption asserting what the frames denied.",
    236+ "Now the run owns its own instance: `up` builds the commit under review into",
    237+ "containers of its own and prints the host port it got, `down` runs in a",
    238+ "finally. {sha}/{shortsha} come from `git rev-parse HEAD`, never from here.",
    239+ "--ttl 1800 because this instance exists for one 40-second take; the default",
    240+ "two idle hours would keep a whole stack up long after the film was cut."
    241+ ],
    221242 "out": ".human-review/assets/feature.webm",
    222243 "app": {
    223244 "up": "./start-docker.sh up --ref {sha} --ttl 1800",
    224245 "url": "./start-docker.sh url petclinic-{shortsha}",
    246+ "//db": "KEY=value lines (JDBC_URL, DB_HOST_PORT, DB_CONTAINER_PORT, DB_USER, DB_PASSWORD): the review page's Running Database row.",
    247+ "db": "./start-docker.sh db petclinic-{shortsha}",
    225248 "reset": "/__reset",
    226249 "down": "./start-docker.sh down petclinic-{shortsha}"
    227250 }
     
    231254 "paths": ["petclinic-backend"]
    232255 },
    233256 "dsaudit": {
    234- "//": "The one step that needs BOTH sides of the branch running at the same time,",
    235- "//2": "and that is exactly why it never ran. This block used to say `base-new`",
    236- "//3": ":4300 and `base-old` :4301 — which is not a configuration, it is a promise",
    237- "//4": "about somebody else's machine. Nothing in the pipeline started those two",
    238- "//5": "builds, so the prerequisite found nothing answering, the step was skipped on",
    239- "//6": "every run, and the UX tab arrived empty explaining that it had — on a branch",
    240- "//7": "whose whole subject is the vet field moved onto `app-combo`.",
    241- "//8": "`app` is the same block `video.app` is; \"video\" borrows it rather than",
    242- "//9": "repeating three commands that would then be two places to forget. The step",
    243- "//10": "expands it TWICE — once with HEAD's {sha}/{shortsha} and once with the",
    244- "//11": "merge-base's — so the two instances are of this branch and of where it",
    245- "//12": "forked, never of whatever a port happens to be serving. Both host ports are",
    246- "//13": "picked by the host (that is what lets two builds of one repo be up at once)",
    247- "//14": "and read back out of what each `up` printed; both are `down`ed in a finally.",
    257+ "//": [
    258+ "The one step that needs BOTH sides of the branch running at the same time,",
    259+ "and that is exactly why it never ran. This block used to say `base-new`",
    260+ ":4300 and `base-old` :4301 — which is not a configuration, it is a promise",
    261+ "about somebody else's machine. Nothing in the pipeline started those two",
    262+ "builds, so the prerequisite found nothing answering, the step was skipped on",
    263+ "every run, and the UX tab arrived empty explaining that it had — on a branch",
    264+ "whose whole subject is the vet field moved onto `app-combo`.",
    265+ "`app` is the same block `video.app` is; \"video\" borrows it rather than",
    266+ "repeating three commands that would then be two places to forget. The step",
    267+ "expands it TWICE — once with HEAD's {sha}/{shortsha} and once with the",
    268+ "merge-base's — so the two instances are of this branch and of where it",
    269+ "forked, never of whatever a port happens to be serving. Both host ports are",
    270+ "picked by the host (that is what lets two builds of one repo be up at once)",
    271+ "and read back out of what each `up` printed; both are `down`ed in a finally."
    272+ ],
    248273 "app": "video",
    249274 "label-old": "main",
    250275 "source": ["petclinic-frontend/src"],
    251- "//screens": "EVERY screen of the app, deep-linked to a seeded row, not the two",
    252- "//screens2": "somebody guessed the branch touched. The audit shoots them all and",
    253- "//screens3": "draws only those whose DOM changed; the rest are named in one line.",
    254- "//screens4": "The list was two entries until 19 Sep 2026, which would have looked",
    255- "//screens5": "past every list that grew a Vet column — and run-steps.py would have",
    256- "//screens6": "said so in red, because it follows each changed component to its route.",
    276+ "//screens": [
    277+ "EVERY screen of the app, deep-linked to a seeded row, not the two",
    278+ "somebody guessed the branch touched. The audit shoots them all and",
    279+ "draws only those whose DOM changed; the rest are named in one line.",
    280+ "The list was two entries until 19 Sep 2026, which would have looked",
    281+ "past every list that grew a Vet column — and run-steps.py would have",
    282+ "said so in red, because it follows each changed component to its route."
    283+ ],
    257284 "screens": {
    258285 "Welcome": "welcome",
    259286 "Owners": "owners",
    import org.springframework.web.bind.annotation.PostMapping;
    66 import org.springframework.web.bind.annotation.RequestBody;
    77 import org.springframework.web.bind.annotation.ResponseStatus;
    88 import org.springframework.web.bind.annotation.RestController;
    9+import victor.training.commons.PhoneNumbers;
    910 import victor.training.commons.VisitBookedNotification;
    1011
    1112 @RestController
    public class NotificationController {
    1920 @PostMapping(VisitBookedNotification.PATH)
    2021 @ResponseStatus(HttpStatus.ACCEPTED)
    2122 public void visitBooked(@RequestBody @Validated VisitBookedNotification notification) {
    22- smsGateway.send(notification.ownerPhone(), "Visit for %s booked on %s. Reply STOP to unsubscribe."
    23+ String phone = PhoneNumbers.normalize(notification.ownerPhone());
    24+ smsGateway.send(phone, "Visit for %s booked on %s. Reply STOP to unsubscribe."
    2325 .formatted(notification.petName(), notification.visitDate()));
    2426 }
    2527 }
    README.md+5 −0
    down the left-hand side, marked `placed="auto"`, and draws the new line. The tes
    4848 to clear the marker. That failure is the point: an automatic layout would be quicker and
    4949 would cost exactly what this diagram is for.
    5050
    51+A new line is drawn the same way, by the script and never by hand or by an agent: **one
    52+straight red segment** between the two boxes, with no routing, no waypoints, and its `*`
    53+near the end it qualifies. If it cuts straight through another box, that is the signal,
    54+not a bug: it says "lay me out". A human routes it in draw.io and sets it back to black.
    55+
    5156 ```sh
    5257 python3 docs/scripts/conceptual-model-patch.py --dry-run # what is missing
    5358 python3 docs/scripts/conceptual-model-patch.py # add it, re-render the PNG
    workspace "PetClinic" "Veterinary practice management system" {
    1919 # C3 components + edges — arch-tested vs code
    2020 !include c4model.c3.dsl
    2121 }
    22- database = container "Database" "Stores all data" "PostgreSQL"
    22+ database = container "Database" "Stores all data" "PostgreSQL" "Database"
    23+ notificationService = container "notification-service" "Texts pet owners when a visit is booked" "Java / Spring Boot"
    2324 }
    25+ smsGateway = softwareSystem "SMS gateway" "Delivers text messages to pet owners" "External"
    2426
    2527 petOwner -> petClinic "Manages pets and visits"
    2628 veterinarian -> petClinic "Manages appointments and records"
    workspace "PetClinic" "Veterinary practice management system" {
    2830 veterinarian -> frontend "Uses"
    2931 frontend -> backend "REST API calls" "HTTPS/JSON"
    3032 backend -> database "Reads/writes" "JPA"
    33+ backend -> notificationService "Notifies of booked visits" "HTTP/JSON"
    34+ notificationService -> smsGateway "Sends SMS"
    35+ notification -> notificationService "POSTs visit-booked notifications" "HTTP/JSON"
    3136 }
    3237
    3338 views {
    workspace "PetClinic" "Veterinary practice management system" {
    5560 include ->mapperLayer->
    5661 autoLayout
    5762 }
    63+
    64+ styles {
    65+ element "Person" {
    66+ shape Person
    67+ background #08427b
    68+ color #ffffff
    69+ }
    70+ element "Software System" {
    71+ background #1168bd
    72+ color #ffffff
    73+ }
    74+ element "External" {
    75+ background #999999
    76+ color #ffffff
    77+ }
    78+ element "Container" {
    79+ background #438dd5
    80+ color #ffffff
    81+ }
    82+ element "Database" {
    83+ shape Cylinder
    84+ }
    85+ element "Component" {
    86+ background #85bbf0
    87+ color #000000
    88+ }
    89+ relationship "Relationship" {
    90+ color #707070
    91+ thickness 2
    92+ }
    93+ }
    5894 }
    5995 }
     
    11 @startuml
    22
    3-title Backend Logical Architecture (java packages)
    4-caption All boxes and lines are ArchUnit-tested vs code
    5-footer petclinic-backend/docs/packages.puml
    3+title Java packages and their dependencies
    64
    75 skinparam shadowing false
    86 skinparam componentStyle rectangle
    The conceptual model is hand-arranged on purpose: people remember where things a
    55 here is allowed to move a box a human put somewhere. This script does the two jobs a human
    66 should not have to do by hand:
    77
    8- * a concept or an association the code has and the map does not is ADDED — every new box
    9- in the staging lane down the left-hand side, marked `placed="auto"`, so
    10- ConceptualModelDiagramTest keeps failing until someone drags it onto the map proper;
    11- * a staged box that has since been moved is GRADUATED — the marker and the staging style
    12- come off, and it becomes an ordinary part of the map.
    8+ * a concept or an association the code has and the map does not is ADDED — every new box
    9+ in the staging lane down the left-hand side, marked `placed="auto"`, so
    10+ ConceptualModelDiagramTest keeps failing until someone drags it onto the map proper;
    11+ every new line as one straight segment from box to box, red, never routed;
    12+ * a staged box that has since been moved is GRADUATED — the marker and the staging style
    13+ come off, and it becomes an ordinary part of the map.
    1314
    1415 What it never does: reposition, restyle or delete anything else. Removing a concept the
    1516 code dropped is left to a human too, because deleting from a map is a decision about the
    STAGED_BOX_STYLE = (
    5960 "rounded=0;whiteSpace=wrap;html=1;fillColor=#dae8fc;"
    6061 f"strokeColor={TODO_COLOR};strokeWidth={STROKE_W};dashed=1;fontSize=15;fontStyle=1;")
    6162 EDGE_STYLE = f"endArrow=none;html=1;strokeColor=#333333;strokeWidth={STROKE_W};fontSize=12;"
    62-# A line this script routed is a to-do like a staged box is: it is joined to the right two
    63-# concepts and it is routed by nobody. It turns black when a human has looked at it.
    63+# A line this script draws is a to-do like a staged box is: it is joined to the right two
    64+# concepts and laid out by nobody. On purpose it is ONE STRAIGHT SEGMENT, centre to centre —
    65+# no edgeStyle, no waypoints, no exit/entry anchors — even when that runs it straight through
    66+# a box in the way: a line that looks finished is a line nobody lays out. Routing it is a
    67+# human's call about the map, made by hand in draw.io; the line turns black once made.
    6468 STAGED_EDGE_STYLE = (
    6569 f"endArrow=none;html=1;strokeColor={TODO_COLOR};strokeWidth={STROKE_W};fontSize=12;")
    6670 # the * is the only marker on the map, so it is read at a glance; no white label
    NOTE_STYLE = f"text;html=1;align=center;fontSize=13;fontColor={TODO_COLOR};"
    8185 NOTE_W, NOTE_H = 260, 60
    8286 NOTE_GAP = 40 # breathing room between the lowest box and the note
    8387
    88+# How far along its line an end's * sits: -1 is the source box, 1 the target, so 0.6 puts
    89+# it near the end it qualifies — where every hand-drawn line on the map has its marker.
    90+LABEL_AT = "0.6"
    91+
    8492 BOX_W, BOX_H = 140, 50
    8593 LANE_GAP = 300 # how far left of the map the staging lane sits
    8694 ROW_GAP = 90
    def patch(model_root, concepts, associations):
    226234 changes.append(f"skipped {key} — one of its concepts is not on the map yet")
    227235 continue
    228236 root.extend(edge_cells(key, a))
    229- changes.append(f"drew {key} — check how it routes")
    237+ changes.append(f"drew {key} — a straight red line; lay it out by hand")
    230238
    231239 # 4. say, on the map itself, what the red means and how to clear it
    232240 if any(c.startswith(("staged", "drew")) for c in changes) and not note_drawn(cells):
    def staged_box(name, x, y):
    297305
    298306 def edge_cells(key, a):
    299307 """The line, plus a * at each many end. Nothing is drawn on a to-one end, and the line
    300- carries no role name: the map shows the shape of the model, not the field names."""
    308+ carries no role name: the map shows the shape of the model, not the field names.
    309+
    310+ The line is straight and unrouted (see STAGED_EDGE_STYLE); each * sits on the line near
    311+ the end it qualifies, at the same relative position the hand-drawn lines use."""
    301312 edge_id = "e-" + key.lower()
    302313 obj = ET.Element("object", {"label": "", "assoc": key, "id": edge_id})
    303314 cell = ET.SubElement(obj, "mxCell", {
    def edge_cells(key, a):
    306317 ET.SubElement(cell, "mxGeometry", {"relative": "1", "as": "geometry"})
    307318
    308319 labels = []
    309- for suffix, value, position in (("s", a["left_card"], "-0.6"), ("t", a["right_card"], "0.6")):
    320+ for suffix, value, position in (("s", a["left_card"], f"-{LABEL_AT}"),
    321+ ("t", a["right_card"], LABEL_AT)):
    310322 if not value:
    311323 continue # a to-one end is left unmarked
    312- label = ET.Element("mxCell", {"id": f"{edge_id}-{suffix}", "value": value,
    313- "style": LABEL_STYLE, "vertex": "1",
    314- "connectable": "0", "parent": edge_id})
    315- ET.SubElement(label, "mxGeometry", {"x": position, "relative": "1", "as": "geometry"})
    324+ label = ET.Element("mxCell", {
    325+ "id": f"{edge_id}-{suffix}", "value": value, "style": LABEL_STYLE,
    326+ "vertex": "1", "connectable": "0", "parent": edge_id})
    327+ geometry = ET.SubElement(label, "mxGeometry", {
    328+ "x": position, "relative": "1", "as": "geometry"})
    329+ ET.SubElement(geometry, "mxPoint", {"as": "offset"})
    316330 labels.append(label)
    317331 return [obj] + labels
    318332
    def render(model_root):
    326340 sys.exit("draw.io CLI not found — install it with: brew install --cask drawio")
    327341
    328342 body = ET.tostring(model_root, encoding="unicode")
    329- mxfile = (f'<mxfile host="petclinic-guardrail">'
    330- f'<diagram name="Conceptual Model" id="conceptual-model">{body}</diagram></mxfile>')
    343+ mxfile = (
    344+ f'<mxfile host="petclinic-guardrail">'
    345+ f'<diagram name="Conceptual Model" id="conceptual-model">{body}</diagram></mxfile>')
    331346
    332347 with tempfile.TemporaryDirectory() as tmp:
    333348 source = Path(tmp) / "ConceptualModel.drawio"
    def main():
    366381 render(model_root)
    367382 print(f"\nRe-rendered {DIAGRAM}.")
    368383 if any(c.startswith("staged") for c in changes):
    369- print("Open it in the draw.io desktop app, drag the orange boxes onto the map, save,\n"
    370- "then run this script again to clear their staging markers.")
    384+ print(
    385+ "Open it in the draw.io desktop app, drag the red boxes onto the map, save,\n"
    386+ "then run this script again to clear their staging markers.")
    387+ if any(c.startswith("drew") for c in changes):
    388+ print(
    389+ "Each new line is drawn straight from box to box, through whatever is in the way.\n"
    390+ "Route it by hand in draw.io and set its colour back to default — that call is\n"
    391+ "a human's, and neither this script nor an agent makes it.")
    371392
    372393
    373394 if __name__ == "__main__":
     
    33
    44 This repo has no aggregator/reactor pom at its root (see the comment in
    55 petclinic-chatbot/pom.xml): petclinic-backend, petclinic-chatbot, petclinic-database
    6-and refactoring-legacy are four independent, standalone Maven builds, each with its
    6+and refactoring-tools are four independent, standalone Maven builds, each with its
    77 own external <parent> (spring-boot-starter-parent, or none). "The module graph" is
    88 therefore a question with a real, checkable answer — either these projects declare
    99 Maven <dependency> edges on one another, or they don't — and the only trustworthy way
    def render_puml(projects: list[Project], edges: list[tuple[str, str]],
    156156 # puml_diff.py's structural differ, when an element is removed entirely, re-emits it
    157157 # as `component "<s>Name</s>" as Name` — reusing Name as an *unquoted* PlantUML
    158158 # identifier. Maven artifactIds conventionally contain hyphens (petclinic-backend,
    159- # refactoring-legacy, ...), which are not legal there, so a bracket-form removal
    159+ # refactoring-tools, ...), which are not legal there, so a bracket-form removal
    160160 # diff fails to render ("Error line N", confirmed empirically against this file).
    161161 # `component "artifactId" as safe_alias` sidesteps it: the quoted text
    162162 # can be anything, and the differ never needs to turn it into an identifier.
    def render_puml(projects: list[Project], edges: list[tuple[str, str]],
    165165 "@startuml",
    166166 "",
    167167 "title Maven Module Graph",
    168- "caption Diagram generated from `mvn dependency:tree "
    169- "-Dincludes=<this repo's own groupId:artifactId>`",
    170- "footer */pom.xml -> petclinic-backend/docs/scripts/mavenmodules/gen-maven-modules.sh "
    171- "-> petclinic-backend/docs/generated/MavenModules.puml",
    172168 "",
    173169 "skinparam shadowing false",
    174170 "skinparam componentStyle rectangle",
     
    88 # pipeline: the JVM writes the very same "trace window" the browser suites write, and the very
    99 # same generator turns windows into diagrams. What differs is only how much has to be running.
    1010 #
    11-# Needed: ./start-grafana.sh — Tempo + the OTLP collector on :4318, or any other one named by
    12-# OTEL_EXPORTER_OTLP_ENDPOINT + GRAFANA_URL (`env $(../start-docker.sh ports N) …` for an
    11+# Needed: ./start-grafana.sh — Tempo + the OTLP collector on :4318, or ./start-tempo.sh (Tempo
    12+# alone, no Docker), or any other one named by
    13+# OTEL_EXPORTER_OTLP_ENDPOINT + GRAFANA_URL / TEMPO_URL (`env $(../start-docker.sh ports N) …` for an
    1314 # instance up with --otel)
    1415 # NOT needed: the database (the tests boot an embedded Postgres), the backend (they *are* the
    1516 # backend) and the frontend.
    die() { printf '\033[1;31m[tracing] %s\033[0m\n' "$*" >&2; exit 1; }
    3637 # Any HTTP answer at all: a collector GETs back a 405, which is still a collector.
    3738 answers() { curl -s -o /dev/null --max-time 3 "$1"; }
    3839
    39-answers "$OTLP/v1/traces" || die "Nothing is listening at $OTLP — start Tempo first: ./start-grafana.sh"
    40+answers "$OTLP/v1/traces" || die "Nothing is listening at $OTLP — start Tempo first: ./start-tempo.sh (or ./start-grafana.sh)"
    41+# No Grafana named and none on :3300, but ./start-tempo.sh's Tempo is up: the generator fetches
    42+# from it directly.
    43+if [[ -z "${TEMPO_URL:-}${GRAFANA_URL:-}" ]] && ! curl -fsS -o /dev/null http://localhost:3300/api/health 2>/dev/null \
    44+ && curl -fsS -o /dev/null http://127.0.0.1:3200/ready 2>/dev/null; then
    45+ export TEMPO_URL=http://127.0.0.1:3200
    46+fi
    4047 log "✅ OTLP collector reachable at $OTLP."
    4148
    4249 if [[ ! -f "$AGENT_JAR" ]]; then
     
    1+package victor.training.petclinic;
    2+
    3+import io.opentelemetry.api.common.AttributeKey;
    4+import io.opentelemetry.api.trace.Span;
    5+import org.springframework.lang.Nullable;
    6+import org.springframework.stereotype.Component;
    7+import org.springframework.transaction.TransactionExecution;
    8+import org.springframework.transaction.TransactionExecutionListener;
    9+
    10+/**
    11+ * Stamps how each transaction ended on the span of the call that began it.
    12+ *
    13+ * <p>The OpenTelemetry agent records a commit (Hibernate's {@code Transaction.commit} span) but
    14+ * nothing at all for a rollback, so a traced sequence diagram could frame a transaction that
    15+ * committed and showed none for one that rolled back. Spring calls this after every transaction
    16+ * it began, while the call that began it (a repository method: nothing here is
    17+ * {@code @Transactional}) is still the current span; petclinic-test's trace-to-puml.ts writes
    18+ * the outcome beside that frame's {@code tx}. Spring Boot hands every such listener bean to the
    19+ * transaction manager it configures. Without the agent the current span is a no-op, and so is
    20+ * this.
    21+ */
    22+@Component
    23+class TransactionOutcomeTracing implements TransactionExecutionListener {
    24+ static final AttributeKey<String> OUTCOME = AttributeKey.stringKey("transaction.outcome");
    25+
    26+ @Override
    27+ public void afterCommit(TransactionExecution transaction, @Nullable Throwable commitFailure) {
    28+ Span.current().setAttribute(OUTCOME, commitFailure == null ? "committed" : "commit failed");
    29+ }
    30+
    31+ @Override
    32+ public void afterRollback(TransactionExecution transaction, @Nullable Throwable rollbackFailure) {
    33+ Span.current().setAttribute(OUTCOME, "rolled back");
    34+ }
    35+}
    import io.modelcontextprotocol.spec.McpSchema.ElicitResult;
    1919
    2020 import victor.training.petclinic.domain.Owner;
    2121 import victor.training.petclinic.domain.Pet;
    22+import victor.training.petclinic.domain.Vet;
    2223 import victor.training.petclinic.domain.Visit;
    2324 import victor.training.petclinic.repository.OwnerRepository;
    2425 import victor.training.petclinic.repository.PetRepository;
    import org.springframework.stereotype.Component;
    99 import org.springframework.web.client.RestClient;
    1010 import org.springframework.web.client.RestClientException;
    1111
    12+import victor.training.commons.HomeCountry;
    13+import victor.training.commons.PhoneNumbers;
    1214 import victor.training.commons.VisitBookedNotification;
    1315
    1416 /** POSTs to notification-service; path and payload come from petclinic-commons, shared with it. */
    1517 @Component
    16-public class NotificationServiceClient implements NotificationSender {
    18+public class NotificationServiceClient implements NotificationSender, HomeCountry {
    1719 private static final Logger log = LoggerFactory.getLogger(NotificationServiceClient.class);
    1820
    1921 private final RestClient restClient;
    public class NotificationServiceClient implements NotificationSender {
    3032 try {
    3133 restClient.post()
    3234 .uri(VisitBookedNotification.PATH)
    33- .body(new VisitBookedNotification(ownerPhone, petName, visitDate))
    35+ .body(new VisitBookedNotification(PhoneNumbers.normalize(ownerPhone, this), petName, visitDate))
    3436 .retrieve()
    3537 .toBodilessEntity();
    3638 } catch (RestClientException e) {
    3739 log.warn("Owner not notified of the visit booked for {}: {}", petName, e.getMessage());
    3840 }
    3941 }
    42+
    43+ /** The clinic's own country: an owner's number typed without a prefix is dialled from here. */
    44+ @Override
    45+ public String homeDialCode() {
    46+ return "+1";
    47+ }
    4048 }
    public final class ApiExamples {
    3434 { "id": 1, "firstName": "George", "lastName": "Franklin", "address": "110 W. Liberty St.",
    3535 "city": "Madison", "telephone": "6085551023",
    3636 "pets": [ { "id": 1, "name": "Leo", "birthDate": "2010-09-07",
    37- "type": { "id": 1, "name": "cat" }, "ownerId": 1, "visits": [] } ] },
    37+ "type": { "id": 1, "name": "cat" }, "ownerId": 1,
    38+ "visits": [
    39+ { "id": 1, "date": "2013-01-01", "description": "neutered", "petId": 1,
    40+ "vetId": 3, "vetFirstName": "Linda", "vetLastName": "Douglas" },
    41+ { "id": 2, "date": "2013-01-02", "description": "annual checkup", "petId": 1 } ] } ] },
    3842 { "id": 2, "firstName": "Betty", "lastName": "Davis", "address": "638 Cardinal Ave.",
    3943 "city": "Sun Prairie", "telephone": "6085551749", "pets": [] }
    4044 ]""";
    public final class ApiExamples {
    4549 "ownerId": 1, "visits": [] },
    4650 { "id": 2, "name": "Basil", "birthDate": "2012-08-06", "type": { "id": 2, "name": "hamster" },
    4751 "ownerId": 2,
    48- "visits": [ { "id": 1, "date": "2013-01-01", "description": "rabies shot", "petId": 2 } ] }
    52+ "visits": [ { "id": 1, "date": "2013-01-01", "description": "rabies shot", "petId": 2,
    53+ "vetId": 1, "vetFirstName": "James", "vetLastName": "Carter" } ] }
    4954 ]""";
    5055
    5156 public static final String VISITS = """
    5257 [
    53- { "id": 1, "date": "2013-01-01", "description": "rabies shot", "petId": 7 },
    58+ { "id": 1, "date": "2013-01-01", "description": "rabies shot", "petId": 7,
    59+ "vetId": 1, "vetFirstName": "James", "vetLastName": "Carter" },
    5460 { "id": 2, "date": "2013-01-02", "description": "annual checkup", "petId": 8 }
    5561 ]""";
    5662
    package victor.training.petclinic.rest;
    33 import java.net.URI;
    44 import java.util.List;
    55
    6+import org.slf4j.Logger;
    7+import org.slf4j.LoggerFactory;
    8+
    69 import org.springframework.http.ResponseEntity;
    710 import victor.training.petclinic.mapper.OwnerMapper;
    811 import victor.training.petclinic.mapper.PetMapper;
    912 import victor.training.petclinic.mapper.VisitMapper;
    1013 import victor.training.petclinic.domain.Owner;
    1114 import victor.training.petclinic.domain.Pet;
    15+import victor.training.petclinic.domain.Vet;
    1216 import victor.training.petclinic.domain.Visit;
    1317 import victor.training.petclinic.notification.NotificationSender;
    1418 import victor.training.petclinic.repository.OwnerRepository;
    import jakarta.transaction.Transactional;
    4953 @PreAuthorize("hasRole(@roles.OWNER_ADMIN)")
    5054 public class OwnerRestController {
    5155
    56+ private static final Logger log = LoggerFactory.getLogger(OwnerRestController.class);
    57+
    5258 private final OwnerRepository ownerRepository;
    5359 private final PetRepository petRepository;
    5460 private final VisitRepository visitRepository;
    public class OwnerRestController {
    189195 Pet pet = new Pet();
    190196 pet.setId(petId);
    191197 visit.setPet(pet);
    192- visit.setVet(vetRepository.findByIdOrNull(visitFieldsDto.getVetId()));
    198+ Vet vet = vetRepository.findByIdOrNull(visitFieldsDto.getVetId());
    199+ visit.setVet(vet);
    193200 visitRepository.save(visit);
    201+ log.info("Booked visit {} for pet {}", visit.getId(), petId);
    202+ if (vet != null) {
    203+ log.debug("Attending vet: {}", vet.getLastName());
    204+ }
    194205 notifyOwner(ownerId, petId, visit);
    195206 return visit.getId();
    196207 }
    spring.jpa.properties.hibernate.use_sql_comments=true
    1414
    1515 # Flyway
    1616 spring.flyway.enabled=true
    17-# Two locations, one chain: db/migration is the incremental schema history (versioned, immutable),
    18-# db/seed is the demo/test dataset (repeatable, freely editable, re-applied on checksum change).
    19-# Tests inherit this, so a @SpringBootTest gets the same rows the app shows at startup.
    20-# A deployment with real data drops the second location (and adds ignore-migration-patterns=*:missing).
    21-spring.flyway.locations=classpath:db/migration,classpath:db/seed
    17+# Three locations, one chain: db/migration is the incremental schema history (versioned,
    18+# immutable, no rows), db/reference the lookup rows every environment needs (pet types,
    19+# specialties; repeatable), db/seed the demo dataset (repeatable, freely editable, re-applied
    20+# on checksum change). A deployment with real data drops the third location (and adds
    21+# ignore-migration-patterns=*:missing). Tests drop it too — src/test/resources/config/
    22+# application.properties — so a test starts on an empty database unless it declares the seed.
    23+spring.flyway.locations=classpath:db/migration,classpath:db/reference,classpath:db/seed
    2224
    2325 # OpenAPI/Swagger UI
    2426 openapi.info.title=REST Petclinic Backend API
    minimal.sql+46 −0
     
    1+-- Fixture "minimal": exactly ONE of everything — one owner (Molly Weasley), one pet, one visit,
    2+-- one vet with one specialty, one pet type, one user with one role. The smallest dataset on
    3+-- which every screen still has something to show, for a demo that must read row by row.
    4+--
    5+-- A fixture is a DATASET OF ITS OWN, built on an empty database — never a delta on the seed.
    6+-- The reset sidecar (docker/reset/reset-server.py) empties every table, restarting the
    7+-- identity sequences, and runs this file on that, in one transaction. So every row minimal
    8+-- has is written here, and what R__seed.sql holds never leaks into it.
    9+--
    10+-- NOT a Flyway location: spring.flyway.locations names db/migration and db/seed only, so the
    11+-- application never runs this. It ships next to the seed so the two read side by side; the
    12+-- only thing that executes it is the reset sidecar, which does not exist in production.
    13+--
    14+-- The file name is the fixture's name: the review page draws one button per *.sql here.
    15+--
    16+-- The ids are literal because the database starts empty with every sequence at 1: the first
    17+-- row of each table is id 1.
    18+
    19+-- Parents before children, so no foreign key ever points at a row not yet written.
    20+INSERT INTO types (name) VALUES ('bird'); -- type 1
    21+
    22+-- The one vet, Helen Leary, the seed's radiologist: one vet, one specialty, one link.
    23+INSERT INTO vets (first_name, last_name) VALUES ('Helen', 'Leary'); -- vet 1
    24+
    25+INSERT INTO specialties (name, description) VALUES
    26+ ('radiology',
    27+ 'limping, limp, broken bone, fracture, suspected fracture, swollen leg, can''t bear weight, holding up a paw, joint pain after a fall, suspected internal injury, ingested a foreign object.');
    28+
    29+INSERT INTO vet_specialties (specialty_id, vet_id) VALUES (1, 1);
    30+
    31+INSERT INTO owners (first_name, last_name, address, city, telephone) VALUES
    32+ ('Molly', 'Weasley', 'The Burrow', 'Ottery St Catchpole', '0441404812345');
    33+
    34+INSERT INTO pets (name, birth_date, type_id, owner_id) VALUES
    35+ ('Errol', DATE '2012-03-01', 1, 1); -- the family owl, crashes into windows
    36+
    37+INSERT INTO visits (pet_id, visit_date, visit_time, description) VALUES
    38+ (1, DATE '2024-01-18', TIME '11:00', 'concussion after flying into a window');
    39+
    40+-- One user, admin, with the seed's password hash and one role. Security is off in the
    41+-- demo stack (petclinic.security.enable=false); with it on, OWNER_ADMIN is the role that
    42+-- opens the owner, pet and visit screens this dataset is about.
    43+INSERT INTO users (username, password, enabled) VALUES
    44+ ('admin', '$2a$10$ymaklWBnpBKlgdMgkjWVF.GMGyvH8aDuTK.glFOaKw712LHtRRymS', TRUE); -- NOSONAR: test-fixture hash, not a real credential
    45+
    46+INSERT INTO roles (username, role) VALUES ('admin', 'ROLE_OWNER_ADMIN');
     
    1+-- Reference data: the lookup rows the app cannot be used without, in EVERY environment —
    2+-- the app, a deployment with real data, and every test. Pet types (the pet form's dropdown
    3+-- has nothing to offer without them) and vet specialties (the chatbot's RAG triages
    4+-- symptoms against their descriptions). Nothing else: owners, pets, visits, vets and the
    5+-- admin user are demo data and live in db/seed/R__seed.sql, which tests do not load.
    6+--
    7+-- Its own location (db/reference), not db/migration: the versioned chain carries no rows,
    8+-- ever (AGENTS.md) — the specialty descriptions are exactly the text the old V6/V7 kept
    9+-- re-seeding over each other. Repeatable, so edit it freely: it re-runs on checksum change,
    10+-- and Flyway runs repeatables by description, so "reference" always lands before "seed".
    11+--
    12+-- An upsert, not a TRUNCATE: on a database that already holds these rows (every one the old
    13+-- seed built) it rewrites them in place and keeps their ids, which pets and vet_specialties
    14+-- point at. The ids are literal for the same reason — the seed's pets name type 1..7 and its
    15+-- vets specialty 1..3 — and each sequence is moved past them afterwards, so a type or a
    16+-- specialty created through the API never collides with one written here.
    17+--
    18+-- The reset sidecar (docker/reset) runs this same file when it empties the database, so an
    19+-- "empty" stack and an "empty" test database hold exactly these rows.
    20+
    21+INSERT INTO types (id, name) VALUES
    22+ (1, 'cat'), (2, 'dog'), (3, 'lizard'), (4, 'snake'), (5, 'bird'), (6, 'hamster'), (7, 'horse')
    23+ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name;
    24+
    25+-- description = the symptoms that identify the specialty; vectorized into the chatbot's RAG.
    26+INSERT INTO specialties (id, name, description) VALUES
    27+ (1, 'radiology',
    28+ 'limping, limp, broken bone, fracture, suspected fracture, swollen leg, can''t bear weight, holding up a paw, joint pain after a fall, suspected internal injury, ingested a foreign object.'),
    29+ (2, 'surgery',
    30+ 'skin rash, itching, lump, growth, mass, wound, open wound, cut, laceration, abscess, swelling under the skin, bite wound, non-healing sore.'),
    31+ (3, 'dentistry',
    32+ 'bad breath, tooth pain, bleeding gums, difficulty eating, dropping food, drooling, broken tooth, loose tooth, pawing at the mouth, reluctance to chew.')
    33+ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description;
    34+
    35+SELECT setval(pg_get_serial_sequence('types', 'id'), (SELECT max(id) FROM types));
    36+SELECT setval(pg_get_serial_sequence('specialties', 'id'), (SELECT max(id) FROM specialties));
    R__seed.sql+17 −14
     
    1+-- Demo dataset ("Default"). NOT part of the incremental chain in db/migration, and NOT
    2+-- loaded by the tests: a test starts on an empty database (schema + db/reference) unless it
    3+-- declares this dataset — @DefaultFixture on a Spring test, test.use({dbFixture: 'default'})
    4+-- on a Playwright spec, @fixture:default on a Cucumber feature.
    15 --
    26 -- Repeatable (R__), so Flyway re-applies it whenever its checksum changes and runs it after
    7+-- every versioned migration — i.e. always against the final schema — and after
    8+-- db/reference/R__reference.sql (repeatables run by description), whose pet types and
    9+-- specialties the rows below point at by id. That is what frees this file from the "never
    10+-- edit an applied migration" rule the old V3__sample_data.sql lived under.
    311 --
    412 -- To offer a second dataset later, give it the SAME description in a sibling location
    513 -- (db/seed/minimal/R__seed.sql) and switch spring.flyway.locations between them. Flyway refuses
     
    1015
    1116 -- Idempotent, and resets the identity sequences so the ids below stay deterministic
    1217 -- (owner 1 = Kevin McCallister, pet 3 = Milton) — several tests and e2e specs rely on them.
    13-TRUNCATE roles, users, visits, pets, owners, vet_specialties, specialties, vets, types
    18+-- Not types or specialties: those are reference data, never the seed's to empty.
    19+TRUNCATE roles, users, visits, pets, owners, vet_specialties, vets
    1420 RESTART IDENTITY CASCADE;
    1521
    1622 INSERT INTO vets (first_name, last_name) VALUES
    INSERT INTO vets (first_name, last_name) VALUES
    2127 ('Henry', 'Stevens'),
    2228 ('Sharon', 'Jenkins');
    2329
    24-INSERT INTO specialties (name, description) VALUES
    25- ('radiology',
    26- 'limping, limp, broken bone, fracture, suspected fracture, swollen leg, can''t bear weight, holding up a paw, joint pain after a fall, suspected internal injury, ingested a foreign object.'),
    27- ('surgery',
    28- 'skin rash, itching, lump, growth, mass, wound, open wound, cut, laceration, abscess, swelling under the skin, bite wound, non-healing sore.'),
    29- ('dentistry',
    30- 'bad breath, tooth pain, bleeding gums, difficulty eating, dropping food, drooling, broken tooth, loose tooth, pawing at the mouth, reluctance to chew.');
    31-
    3230 -- Pairs preserved from upstream petclinic data; column order matches
    31+-- the original ON CONFLICT (specialty_id, vet_id) hint. The specialties themselves
    32+-- (1 radiology, 2 surgery, 3 dentistry) are reference data: db/reference/R__reference.sql.
    3333 INSERT INTO vet_specialties (specialty_id, vet_id) VALUES
    3434 (1, 2), (2, 3), (3, 3), (2, 4), (1, 5);
    3535
    36-INSERT INTO types (name) VALUES
    37- ('cat'), ('dog'), ('lizard'), ('snake'), ('bird'), ('hamster'), ('horse');
    36+-- Pet types 1..7 (cat, dog, lizard, snake, bird, hamster, horse) are reference data too.
    3837
    3938 -- Owners and pets drawn from European literature, film, and science.
    4039 INSERT INTO owners (first_name, last_name, address, city, telephone) VALUES
    41- ('Kevin', 'McCallister', '671 Lincoln Boulevard', 'Winnetka', '+18474461990'),
    40+ ('Kevin', 'McCallister', '671 Lincoln Boulevard', 'Winnetka', '8474461990'),
    4241 ('Harry', 'Potter', '4 Privet Drive', 'Little Whinging', '0119084455'),
    4342 ('Erwin', 'Schroedinger', 'Boltzmanngasse 5', 'Vienna', '0131914920'),
    4443 ('Ștefan', 'Mureșan', '12 Boltzmanngasse', 'Vienna', '(0043)152634418'),
    4544 ('Łukasz', 'Śliwiński', 'ul. Floriańska 14', 'Kraków', '+48124221357'),
    46- ('Roger', 'Radcliff', '27 Outer Circle', 'London', '+442074860707'),
    45+ ('Juan Carlos', 'Martín García Lorca', 'Calle de Alcalá 42', 'Madrid', '0034915312233'),
    4746 ('Newt', 'Scamander', 'Diagon Alley', 'London', '(0044)2079460001'),
    4847 ('Alice', 'Liddell', 'Christ Church', 'Oxford', '+441865276150'),
    4948 ('Henry', 'Baskerville', 'Baskerville Hall', 'Dartmoor', '(0044)1626832093'),
    INSERT INTO pets (name, birth_date, type_id, owner_id) VALUES
    110107 ('Figaro', DATE '2019-09-09', 1, 23), -- Geppetto's cat
    111108 ('Rocinante', DATE '2015-05-05', 7, 24), -- Don Quixote's horse
    112109 ('Grip', DATE '2023-08-19', 5, 25), -- Dickens's raven
    113- ('Toby of Lambeth', DATE '2020-11-27', 2, 26); -- Sherlock Holmes's bloodhound
    110+ ('Toby of Lambeth', DATE '2020-11-27', 2, 26), -- Sherlock Holmes's bloodhound
    111+ ('Michi', DATE '2021-04-23', 1, 6); -- Juan Carlos's cat: which of his five names is the surname?
    114112
    115113 INSERT INTO visits (pet_id, visit_date, description) VALUES
    116114 (15, DATE '2025-11-12', 'rabies vaccine'),
     
    1+package victor.training.petclinic;
    2+
    3+import org.springframework.test.context.jdbc.Sql;
    4+
    5+import java.lang.annotation.Documented;
    6+import java.lang.annotation.ElementType;
    7+import java.lang.annotation.Retention;
    8+import java.lang.annotation.RetentionPolicy;
    9+import java.lang.annotation.Target;
    10+
    11+/**
    12+ * The test starts on the Default dataset — the demo seed the app boots with
    13+ * ({@code db/seed/R__seed.sql}: owner 1 = Kevin McCallister, pet 3 = Milton, the six vets, the
    14+ * admin user) — instead of the empty database every Spring test gets
    15+ * ({@code src/test/resources/config/application.properties}).
    16+ *
    17+ * <p>Put it on a test that reads a row it did not write. On the class it covers every method; on
    18+ * a method, only that one. It loads the seed before the test, inside the test's transaction when
    19+ * there is one (so it rolls back with it); Zonky hands the next test a fresh database either way.
    20+ *
    21+ * <p>Declared, not inherited from configuration, so what a test starts on is written on the test
    22+ * itself — where a reader, and the review page's DB fixture dot, can see it.
    23+ */
    24+@Documented
    25+@Retention(RetentionPolicy.RUNTIME)
    26+@Target({ElementType.TYPE, ElementType.METHOD})
    27+@Sql("classpath:db/seed/R__seed.sql")
    28+public @interface DefaultFixture {
    29+}
     
    1+package victor.training.petclinic;
    2+
    3+import io.zonky.test.db.AutoConfigureEmbeddedDatabase;
    4+import org.junit.jupiter.api.MethodOrderer;
    5+import org.junit.jupiter.api.Order;
    6+import org.junit.jupiter.api.Test;
    7+import org.junit.jupiter.api.TestMethodOrder;
    8+import org.springframework.beans.factory.annotation.Autowired;
    9+import org.springframework.boot.test.context.SpringBootTest;
    10+import org.springframework.jdbc.core.JdbcTemplate;
    11+
    12+import static org.assertj.core.api.Assertions.assertThat;
    13+
    14+/**
    15+ * What a Spring test starts on, pinned down: the schema plus the reference rows when it declares
    16+ * nothing, the Default seed when it says {@link DefaultFixture} — and never what the test before it
    17+ * left behind. Not {@code @Transactional} on purpose: a committed row is the one that could leak.
    18+ */
    19+@SpringBootTest
    20+@AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY)
    21+@TestMethodOrder(MethodOrderer.OrderAnnotation.class)
    22+class StartingDatabaseTest {
    23+ @Autowired
    24+ JdbcTemplate jdbc;
    25+
    26+ private int count(String table) {
    27+ return jdbc.queryForObject("SELECT count(*) FROM " + table, Integer.class);
    28+ }
    29+
    30+ @Test
    31+ @Order(1)
    32+ void aTestThatDeclaresNothingStartsOnAnEmptyDatabase() {
    33+ for (String table : new String[]{"owners", "pets", "visits", "vets", "vet_specialties", "users", "roles"}) {
    34+ assertThat(count(table)).as(table).isZero();
    35+ }
    36+ assertThat(jdbc.queryForList("SELECT name FROM types ORDER BY id", String.class))
    37+ .as("reference data (db/reference) is there all the same")
    38+ .containsExactly("cat", "dog", "lizard", "snake", "bird", "hamster", "horse");
    39+ assertThat(jdbc.queryForList("SELECT name FROM specialties ORDER BY id", String.class))
    40+ .containsExactly("radiology", "surgery", "dentistry");
    41+
    42+ jdbc.update("INSERT INTO owners (first_name, last_name) VALUES ('Left', 'Behind')");
    43+ }
    44+
    45+ @Test
    46+ @Order(2)
    47+ void aRowTheTestBeforeCommittedIsGone() {
    48+ assertThat(count("owners")).isZero();
    49+ }
    50+
    51+ @Test
    52+ @Order(3)
    53+ @DefaultFixture
    54+ void aTestThatDeclaresTheDefaultFixtureStartsOnTheSeed() {
    55+ assertThat(jdbc.queryForObject("SELECT first_name || ' ' || last_name FROM owners WHERE id = 1", String.class))
    56+ .isEqualTo("Kevin McCallister");
    57+ assertThat(jdbc.queryForObject("SELECT name FROM pets WHERE id = 3", String.class)).isEqualTo("Milton");
    58+ assertThat(count("users")).isEqualTo(1);
    59+ assertThat(count("types")).as("the seed points at the reference rows, it does not repeat them").isEqualTo(7);
    60+ }
    61+
    62+ @Test
    63+ @Order(4)
    64+ void andTheOneAfterItIsEmptyAgain() {
    65+ assertThat(count("owners")).isZero();
    66+ assertThat(count("types")).isEqualTo(7);
    67+ }
    68+}
     
    1+package victor.training.petclinic;
    2+
    3+import io.opentelemetry.api.trace.Span;
    4+import io.opentelemetry.context.Context;
    5+import io.opentelemetry.context.Scope;
    6+import io.zonky.test.db.AutoConfigureEmbeddedDatabase;
    7+import org.junit.jupiter.api.BeforeEach;
    8+import org.junit.jupiter.api.Test;
    9+import org.springframework.beans.factory.annotation.Autowired;
    10+import org.springframework.boot.test.context.SpringBootTest;
    11+import org.springframework.transaction.PlatformTransactionManager;
    12+import org.springframework.transaction.TransactionStatus;
    13+import org.springframework.transaction.support.TransactionTemplate;
    14+import victor.training.petclinic.repository.OwnerRepository;
    15+
    16+import static org.mockito.ArgumentMatchers.any;
    17+import static org.mockito.Mockito.mock;
    18+import static org.mockito.Mockito.never;
    19+import static org.mockito.Mockito.verify;
    20+import static org.mockito.Mockito.when;
    21+import static victor.training.petclinic.TransactionOutcomeTracing.OUTCOME;
    22+
    23+/**
    24+ * Against the transaction manager Spring Boot builds, so it also proves the listener is
    25+ * registered on it: a bean nobody hands to the manager would pass a unit test and stamp nothing.
    26+ */
    27+@SpringBootTest
    28+@AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY)
    29+class TransactionOutcomeTracingTest {
    30+ @Autowired
    31+ PlatformTransactionManager transactionManager;
    32+ @Autowired
    33+ OwnerRepository ownerRepository;
    34+
    35+ final Span span = mock(Span.class);
    36+
    37+ @BeforeEach
    38+ void theMockIsTheCurrentSpan() {
    39+ when(span.storeInContext(any())).thenCallRealMethod();
    40+ }
    41+
    42+ private void inSpan(Runnable call) {
    43+ try (Scope ignored = Context.root().with(span).makeCurrent()) {
    44+ call.run();
    45+ }
    46+ }
    47+
    48+ @Test
    49+ void aRepositoryCallStampsCommittedOnItsSpan() {
    50+ inSpan(() -> ownerRepository.findById(1));
    51+
    52+ verify(span).setAttribute(OUTCOME, "committed");
    53+ }
    54+
    55+ @Test
    56+ void aTransactionThatRollsBackSaysRolledBack() {
    57+ inSpan(() -> new TransactionTemplate(transactionManager)
    58+ .executeWithoutResult(TransactionStatus::setRollbackOnly));
    59+
    60+ verify(span).setAttribute(OUTCOME, "rolled back");
    61+ verify(span, never()).setAttribute(OUTCOME, "committed");
    62+ }
    63+}
    public class VetSteps {
    1818 @Autowired
    1919 private JdbcTemplate jdbc;
    2020
    21+ @Given("a vet {string}")
    22+ public void aVet(String fullName) {
    23+ insertVet(fullName);
    24+ }
    25+
    2126 @Given("a vet {string} with specialties {string}")
    2227 public void aVetWithSpecialties(String fullName, String specialty1) {
    2328 insertVet(fullName, specialty1);
    public class VisitSteps {
    5151 .post("/api/visits"));
    5252 }
    5353
    54+ @When("I book a visit for {string} on {string} with description {string}, choosing vet {string}")
    55+ public void iBookAVisitChoosingVet(String petName, String date, String description, String vetName) {
    56+ int petId = http.idOf("pet:" + petName);
    57+ int vetId = http.idOf("vet:" + vetName);
    58+ String body = """
    59+ {"petId":%d,"date":"%s","description":"%s","vetId":%d}
    60+ """.formatted(petId, date, description, vetId);
    61+
    62+ http.setLastResponse(RestAssured.given()
    63+ .baseUri(http.baseUri())
    64+ .contentType(ContentType.JSON)
    65+ .body(body)
    66+ .post("/api/visits"));
    67+ }
    68+
    69+ @Then("the booked visit shows vet {string}")
    70+ public void theBookedVisitShowsVet(String vetName) {
    71+ String location = http.getLastResponse().getHeader("Location");
    72+ var response = RestAssured.given().baseUri(http.baseUri()).get(location);
    73+ assertThat(response.statusCode()).isEqualTo(200);
    74+ String[] parts = vetName.split(" ", 2);
    75+ assertThat(response.jsonPath().getInt("vetId")).isEqualTo(http.idOf("vet:" + vetName));
    76+ assertThat(response.jsonPath().getString("vetFirstName")).isEqualTo(parts[0]);
    77+ assertThat(response.jsonPath().getString("vetLastName")).isEqualTo(parts[1]);
    78+ }
    79+
    5480 @Then("{string} has {int} visit with description {string}")
    5581 public void petHasVisitsWithDescription(String petName, int expectedCount, String description) {
    5682 int petId = http.idOf("pet:" + petName);
    import io.opentelemetry.api.GlobalOpenTelemetry;
    1515 import io.opentelemetry.api.common.AttributeKey;
    1616 import io.opentelemetry.api.trace.Span;
    1717 import io.opentelemetry.context.Scope;
    18-import org.junit.jupiter.api.extension.AfterEachCallback;
    19-import org.junit.jupiter.api.extension.BeforeEachCallback;
    18+import org.junit.jupiter.api.extension.AfterTestExecutionCallback;
    19+import org.junit.jupiter.api.extension.BeforeTestExecutionCallback;
    2020 import org.junit.jupiter.api.extension.ExtensionContext;
    2121 import org.junit.platform.commons.support.AnnotationSupport;
    2222
    import org.junit.platform.commons.support.AnnotationSupport;
    4444 * <p>
    4545 * Which tests: those carrying {@link GenerateSequence}, and — under <code>-Pgenseq</code>, where the
    4646 * extension is autodetected on every test — those <code>GENSEQ_SELECT</code> names, untagged.
    47+ * <p>
    48+ * Around the test method's body ({@code BeforeTestExecution}), not around {@code beforeEach}: an
    49+ * autodetected extension's {@code beforeEach} runs before Spring's, so the span was current while
    50+ * Spring loaded a test's {@code @Sql} — a {@code @DefaultFixture} seed — and every TRUNCATE and
    51+ * INSERT of the seed opened the picture, ahead of the first sentence of the test.
    4752 */
    48-public class SequenceTraceExtension implements BeforeEachCallback, AfterEachCallback {
    53+public class SequenceTraceExtension implements BeforeTestExecutionCallback, AfterTestExecutionCallback {
    4954
    5055 /** What the generator searches Tempo by; the same attribute the browser spans carry. */
    5156 private static final AttributeKey<String> TEST_NAME = AttributeKey.stringKey("test.name");
    public class SequenceTraceExtension implements BeforeEachCallback, AfterEachCall
    8287 static final String SELECT_ENV = "GENSEQ_SELECT";
    8388
    8489 @Override
    85- public void beforeEach(ExtensionContext context) {
    90+ public void beforeTestExecution(ExtensionContext context) {
    8691 // Registered twice on a tagged test — by @GenerateSequence and by autodetection under
    8792 // -Pgenseq — JUnit keeps one; this keeps it one even if it did not.
    8893 if (context.getStore(NAMESPACE).get(Run.class) != null) {
    public class SequenceTraceExtension implements BeforeEachCallback, AfterEachCall
    169174 }
    170175
    171176 @Override
    172- public void afterEach(ExtensionContext context) {
    177+ public void afterTestExecution(ExtensionContext context) {
    173178 Run run = context.getStore(NAMESPACE).remove(Run.class, Run.class);
    174179 if (run == null) {
    175180 return;
    class ConceptualModelDiagramTest {
    8484 void everyAssociationInTheCodeIsDrawn() throws Exception {
    8585 assertThat(drawnAssociations(diagram()).keySet())
    8686 .describedAs("Associations between domain classes that %s does not draw. Run "
    87- + "docs/scripts/conceptual-model-patch.py to add the line, then route it.",
    87+ + "docs/scripts/conceptual-model-patch.py to add the line: it draws it "
    88+ + "straight and red, and a human then lays it out by hand.",
    8889 DIAGRAM)
    8990 .containsAll(expectedAssociations().keySet());
    9091 }
    import static org.assertj.core.api.Assertions.assertThat;
    5050 * of a call already counted, and self-calls ({@code Backend -> Backend}) are internal.
    5151 * A lifeline whose name is not a bare identifier is quoted by the generator
    5252 * ({@code NotificationService -> "SMS gateway"}) and counts just the same, unquoted: an
    53- * external system is still a box someone has to draw.
    53+ * external system is still a box someone has to draw. A library lifeline does not: its calls
    54+ * never leave the JVM that links it, so they are internal too.
    5455 */
    5556 class DeploymentDiagramTest {
    5657
    class DeploymentDiagramTest {
    6970 */
    7071 private static final Pattern DEPLOYED_SUITE = Pattern.compile("\\.(?:spec\\.ts|feature)\\..*\\.genseq\\.puml$");
    7172
    73+ /** Drawn by petclinic-commons' ModuleBoundaryTracing; deployed inside every Java app, never on its own. */
    74+ private static final Set<String> LIBRARIES = Set.of("Commons");
    75+
    7276 /** A call from one deployed container to another, named as the traces name them. */
    7377 private record Edge(String from, String to) {
    7478 @Override
    class DeploymentDiagramTest {
    188192 while (m.find()) {
    189193 String from = m.group(1) != null ? m.group(1) : m.group(2);
    190194 String to = m.group(3) != null ? m.group(3) : m.group(4);
    191- if (!from.equals(to)) { // a self-call stays inside one container
    195+ boolean insideOneContainer = from.equals(to) || LIBRARIES.contains(from) || LIBRARIES.contains(to);
    196+ if (!insideOneContainer) {
    192197 edges.add(new Edge(from, to));
    193198 }
    194199 }
    import java.util.Optional;
    77
    88 import org.junit.jupiter.api.AfterEach;
    99 import org.junit.jupiter.api.BeforeEach;
    10-import org.junit.jupiter.api.DisplayNameGeneration;
    1110 import org.junit.jupiter.api.Nested;
    1211 import org.junit.jupiter.api.Test;
    1312 import org.junit.jupiter.api.extension.ExtendWith;
    import victor.training.petclinic.domain.Visit;
    2423 import victor.training.petclinic.repository.OwnerRepository;
    2524 import victor.training.petclinic.repository.PetRepository;
    2625 import victor.training.petclinic.repository.VisitRepository;
    27-import victor.training.petclinic.tools.PrettyTestNames;
    2826
    2927 import static org.assertj.core.api.Assertions.assertThat;
    3028 import static org.assertj.core.api.Assertions.assertThatThrownBy;
    import static org.mockito.Mockito.when;
    5654 * Spring context, no database, so the whole class runs in milliseconds.
    5755 */
    5856 @ExtendWith(MockitoExtension.class)
    59-@DisplayNameGeneration(PrettyTestNames.class)
    6057 class CreateVisitShould {
    6158 private static final int OWNER_ID = 7;
    6259 private static final int PET_ID = 13;
    import org.springframework.transaction.annotation.Transactional;
    1616 import victor.training.petclinic.domain.Owner;
    1717 import victor.training.petclinic.domain.Pet;
    1818 import victor.training.petclinic.domain.PetType;
    19+import victor.training.petclinic.domain.Vet;
    1920 import victor.training.petclinic.domain.Visit;
    2021 import victor.training.petclinic.repository.OwnerRepository;
    2122 import victor.training.petclinic.repository.PetRepository;
    23+import victor.training.petclinic.repository.VetRepository;
    2224 import victor.training.petclinic.repository.VisitRepository;
    2325
    2426 import static org.assertj.core.api.Assertions.assertThat;
    27+import static org.assertj.core.api.Assertions.tuple;
    2528
    2629 @SpringBootTest
    2730 @AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY)
    class ListVisitsToolTest {
    3639 PetRepository petRepository;
    3740 @Autowired
    3841 VisitRepository visitRepository;
    42+ @Autowired
    43+ VetRepository vetRepository;
    3944
    4045 @AfterEach
    4146 void clearAuth() {
     
    11 package victor.training.petclinic.mcp;
    22
    3+import victor.training.petclinic.DefaultFixture;
    34 import java.nio.charset.StandardCharsets;
    45 import java.util.Base64;
    56 import java.util.Map;
    import static org.assertj.core.api.Assertions.assertThat;
    3233 * user assertion.
    3334 */
    3435 @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
    36+@DefaultFixture // owners 1 and 2 of the seed are the identities the bearer tokens name
    3537 @AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY)
    3638 class McpStreamableIdentityTest {
    3739
    import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.
    2323 import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
    2424
    2525 /** Boots the app against the local dev Postgres reached through the latency proxy started by petclinic-database.
    26- * Skipped on machines without the proxy on localhost:15432 (e.g. CI). */
    27-@SpringBootTest
    26+ * Skipped on machines without the proxy on localhost:15432 (e.g. CI).
    27+ * Reads whatever that database holds and never migrates it: the tests' Flyway config (no seed,
    28+ * src/test/resources/config/) would refuse a dev database the seed was applied to — and a
    29+ * test has no business migrating somebody's running app either. */
    30+@SpringBootTest(properties = "spring.flyway.enabled=false")
    2831 @AutoConfigureMockMvc
    2932 @ExtendWith(JUnitPerfInterceptor.class)
    3033 @EnabledIf("latencyProxyReachable")
     
    11 package victor.training.petclinic.rest;
    22
    3+import victor.training.petclinic.DefaultFixture;
    34 import static org.assertj.core.api.Assertions.assertThat;
    45 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
    56 import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
    import java.util.Map;
    1415 import java.util.stream.StreamSupport;
    1516
    1617 import io.zonky.test.db.AutoConfigureEmbeddedDatabase;
    17-import org.junit.jupiter.api.DisplayNameGeneration;
    1818 import org.junit.jupiter.api.MethodOrderer;
    1919 import org.junit.jupiter.api.Order;
    2020 import org.junit.jupiter.api.Test;
    import com.fasterxml.jackson.databind.JsonNode;
    3131 import com.fasterxml.jackson.databind.ObjectMapper;
    3232
    3333 import victor.training.petclinic.genseq.GenerateSequence;
    34-import victor.training.petclinic.tools.PrettyTestNames;
    3534
    3635 /**
    3736 * The same journey as petclinic-test/src/add-visit.spec.ts, one layer down: no Chromium, no Angular,
    import victor.training.petclinic.tools.PrettyTestNames;
    5251 @AutoConfigureEmbeddedDatabase(provider = AutoConfigureEmbeddedDatabase.DatabaseProvider.ZONKY)
    5352 @AutoConfigureMockMvc
    5453 @WithMockUser(roles = "OWNER_ADMIN")
    55-@DisplayNameGeneration(PrettyTestNames.class)
    5654 // The diagram lays its sections out in the order the tests ran, so without a pinned order the
    5755 // picture is JUnit's arbitrary method order — and adding a test reshuffles every section, which
    5856 // the differ can only report as a rewrite. @Order makes the diagram read the way this file does.
    class AddVisitApiTest {
    7169 // every one of them.
    7270 @GenerateSequence
    7371 @Test
    72+ @DefaultFixture // books onto an owner's pet the seed already holds
    7473 @Order(1)
    7574 void addsAVisitToAnExistingPet() throws Exception {
    7675 given("an owner with at least one pet exists");
    public class VisitTest {
    252252 });
    253253 }
    254254
    255- @Test
    256- void create_withVet() throws Exception {
    257- VisitDto newVisit = new VisitDto()
    258- .setPetId(petId)
    259- .setDate(LocalDate.now())
    260- .setDescription("annual checkup")
    261- .setVetId(vet.getId());
    262-
    263- String location = mockMvc.perform(post("/api/visits")
    264- .content(mapper.writeValueAsString(newVisit))
    265- .contentType(MediaType.APPLICATION_JSON_VALUE))
    266- .andExpect(status().isCreated())
    267- .andReturn().getResponse().getHeader("Location");
    268-
    269- VisitDto created = callGet(idFrom(location));
    270- assertThat(created.getVetId()).isEqualTo(vet.getId());
    271- assertThat(created.getVetFirstName()).isEqualTo("James");
    272- assertThat(created.getVetLastName()).isEqualTo("Herriot");
    273- }
    274-
    275255 @Test
    276256 void create_withoutVet() throws Exception {
    277257 VisitDto newVisit = new VisitDto()
    public class VisitTest {
    316296 .content(mapper.writeValueAsString(newVisit))
    317297 .contentType(MediaType.APPLICATION_JSON_VALUE))
    318298 .andExpect(status().isCreated());
    319- entityManager.flush();
    320- entityManager.clear(); // as a fresh request would: the pet's visits are reloaded
    299+ flushAndClear();
    321300
    322301 mockMvc.perform(get("/api/owners/{ownerId}", ownerId))
    323302 .andExpect(status().isOk())
    public class VisitTest {
    347326 .isNull();
    348327 }
    349328
    329+ @Test
    330+ void bookFromOwnerPage_withUnknownVet_notFound() throws Exception {
    331+ VisitFieldsDto newVisit = new VisitFieldsDto()
    332+ .setDate(LocalDate.now())
    333+ .setDescription("ear infection")
    334+ .setVetId(99999);
    335+
    336+ mockMvc.perform(post("/api/owners/{ownerId}/pets/{petId}/visits", ownerId, petId)
    337+ .content(mapper.writeValueAsString(newVisit))
    338+ .contentType(MediaType.APPLICATION_JSON_VALUE))
    339+ .andExpect(status().isNotFound());
    340+ }
    341+
    350342 @Test
    351343 void update_assignsVet() throws Exception {
    352344 VisitFieldsDto update = new VisitFieldsDto()
    public class VisitTest {
    376383 .content(mapper.writeValueAsString(update))
    377384 .contentType(MediaType.APPLICATION_JSON_VALUE))
    378385 .andExpect(status().isOk());
    386+ flushAndClear();
    379387
    380388 VisitDto updated = callGet(visitId);
    381389 assertThat(updated.getVetId()).isNull();
     
    11 package victor.training.petclinic.security;
    22
    3+import victor.training.petclinic.DefaultFixture;
    34 import io.zonky.test.db.AutoConfigureEmbeddedDatabase;
    45 import org.junit.jupiter.api.Test;
    56 import org.springframework.beans.factory.annotation.Autowired;
    class BasicAuthenticationConfigTest {
    3940 }
    4041
    4142 @Test
    43+ @DefaultFixture // logs in as the seed's admin/admin
    4244 void adminUser_canAccessOwners() throws Exception {
    4345 String credentials = Base64.getEncoder().encodeToString("admin:admin".getBytes());
    4446 mockMvc.perform(get("/api/owners")
    import org.junit.jupiter.api.DisplayNameGenerator;
    77
    88 /**
    99 * Turns camelCase class and method names into plain sentences, so a @Nested test class reads as a
    10- * specification rather than as code. Use via @DisplayNameGeneration on the test class.
    10+ * specification rather than as code. The default for every test in this module
    11+ * (src/test/resources/junit-platform.properties), so no class has to opt in.
    1112 */
    1213 public class PrettyTestNames extends DisplayNameGenerator.ReplaceUnderscores {
    1314
     
    1+package victor.training.petclinic.tools;
    2+
    3+import static org.assertj.core.api.Assertions.assertThat;
    4+
    5+import org.junit.jupiter.api.BeforeEach;
    6+import org.junit.jupiter.api.Test;
    7+import org.junit.jupiter.api.TestInfo;
    8+
    9+/**
    10+ * No @DisplayNameGeneration here on purpose: this class reads as a sentence only because
    11+ * junit-platform.properties makes PrettyTestNames the default. Drop that line and this fails.
    12+ */
    13+class PrettyTestNamesTest {
    14+
    15+ private TestInfo info;
    16+
    17+ @BeforeEach
    18+ void captureTheRunningTest(TestInfo info) {
    19+ this.info = info;
    20+ }
    21+
    22+ @Test
    23+ void everyTestReadsAsASentence_withoutOptingIn() {
    24+ assertThat(info.getDisplayName()).isEqualTo("every test reads as a sentence without opting in");
    25+ }
    26+}
     
    1+# Every Spring test starts on an EMPTY database: the schema and the reference rows (pet types,
    2+# specialties), nothing else. The seed — Kevin, Milton, the vets, the admin user — is not loaded
    3+# unless the test declares it: @DefaultFixture (victor.training.petclinic.DefaultFixture), or
    4+# @Sql of a db/fixtures/<name>.sql. What a test starts on is then written on the test itself,
    5+# which is what the review page's Tests tab draws as the test's DB fixture dot.
    6+#
    7+# In config/, not beside it: Spring Boot reads classpath:/config/application.properties on top
    8+# of classpath:/application.properties, so this overrides two keys and inherits the rest of the
    9+# main file. A test-resources application.properties would SHADOW the main one entirely.
    10+spring.flyway.locations=classpath:db/migration,classpath:db/reference
    11+
    12+# A fresh copy of that database for every test method (Zonky clones it from a template
    13+# migrated once, ~ms). Without it, a test that commits — a RANDOM_PORT test, a Cucumber
    14+# scenario, anything that is not @Transactional — leaves its rows to whichever test comes
    15+# next on the same cached context, and "starts empty" would be true only of the first.
    16+zonky.test.database.refresh=after_each_test_method
    Feature: Visits
    88 Then the response status is 201
    99 And "Samantha" has 1 visit with description "rabies shot"
    1010
    11+ Scenario: Book a visit choosing its vet
    12+ Given a vet "James Herriot"
    13+ When I book a visit for "Samantha" on "2026-05-10" with description "annual checkup", choosing vet "James Herriot"
    14+ Then the response status is 201
    15+ And the booked visit shows vet "James Herriot"
    16+
    1117 Scenario: Edit a visit description
    1218 Given a visit for "Samantha" on "2026-04-01" described as "checkup"
    1319 When I update that visit's description to "annual checkup"
     
    1+# Every JUnit Jupiter test is displayed as a sentence: `create_withoutVet` reads "create without vet".
    2+# The display name is what a sequence diagram is titled with (SequenceTraceExtension), so naming it
    3+# here — once, for the whole suite — keeps every picture readable without a per-class opt-in.
    4+junit.jupiter.displayname.generator.default=victor.training.petclinic.tools.PrettyTestNames
    pom.xml+77 −0
     
    1414 <properties>
    1515 <maven.compiler.release>21</maven.compiler.release>
    1616 <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    17+ <!-- the versions both apps already resolve, Spring Boot's and the OTel annotations' own -->
    18+ <aspectj.version>1.9.25.1</aspectj.version>
    19+ <opentelemetry.version>1.49.0</opentelemetry.version>
    20+ <aspectj-maven-plugin.version>1.14.1</aspectj-maven-plugin.version>
    21+ <!-- the test stack Spring Boot 3.5.11 pins in both apps -->
    22+ <junit-jupiter.version>5.12.2</junit-jupiter.version>
    23+ <assertj.version>3.27.7</assertj.version>
    24+ <maven-surefire-plugin.version>3.5.4</maven-surefire-plugin.version>
    1725 </properties>
    26+
    27+ <dependencies>
    28+ <dependency>
    29+ <groupId>org.aspectj</groupId>
    30+ <artifactId>aspectjrt</artifactId>
    31+ <version>${aspectj.version}</version>
    32+ </dependency>
    33+ <!-- provided: both apps already carry it, and without the agent it is a no-op anyway -->
    34+ <dependency>
    35+ <groupId>io.opentelemetry</groupId>
    36+ <artifactId>opentelemetry-api</artifactId>
    37+ <version>${opentelemetry.version}</version>
    38+ <scope>provided</scope>
    39+ </dependency>
    40+
    41+ <dependency>
    42+ <groupId>org.junit.jupiter</groupId>
    43+ <artifactId>junit-jupiter</artifactId>
    44+ <version>${junit-jupiter.version}</version>
    45+ <scope>test</scope>
    46+ </dependency>
    47+ <dependency>
    48+ <groupId>io.opentelemetry</groupId>
    49+ <artifactId>opentelemetry-sdk-testing</artifactId>
    50+ <version>${opentelemetry.version}</version>
    51+ <scope>test</scope>
    52+ </dependency>
    53+ <dependency>
    54+ <groupId>org.assertj</groupId>
    55+ <artifactId>assertj-core</artifactId>
    56+ <version>${assertj.version}</version>
    57+ <scope>test</scope>
    58+ </dependency>
    59+ </dependencies>
    60+
    61+ <build>
    62+ <plugins>
    63+ <!-- Weaves ModuleBoundaryTracing into this jar's own classes at build time: the apps
    64+ get the spans with no agent, flag or build change of their own. -->
    65+ <plugin>
    66+ <groupId>dev.aspectj</groupId>
    67+ <artifactId>aspectj-maven-plugin</artifactId>
    68+ <version>${aspectj-maven-plugin.version}</version>
    69+ <dependencies>
    70+ <dependency>
    71+ <groupId>org.aspectj</groupId>
    72+ <artifactId>aspectjtools</artifactId>
    73+ <version>${aspectj.version}</version>
    74+ </dependency>
    75+ </dependencies>
    76+ <configuration>
    77+ <complianceLevel>21</complianceLevel>
    78+ <encoding>UTF-8</encoding>
    79+ </configuration>
    80+ <executions>
    81+ <execution>
    82+ <goals>
    83+ <goal>compile</goal>
    84+ </goals>
    85+ </execution>
    86+ </executions>
    87+ </plugin>
    88+ <plugin>
    89+ <groupId>org.apache.maven.plugins</groupId>
    90+ <artifactId>maven-surefire-plugin</artifactId>
    91+ <version>${maven-surefire-plugin.version}</version>
    92+ </plugin>
    93+ </plugins>
    94+ </build>
    1895 </project>
     
    1+package victor.training.commons;
    2+
    3+/** What a national number (no {@code +} or {@code 00}) is dialled from: only the calling app knows. */
    4+public interface HomeCountry {
    5+ String homeDialCode();
    6+}
     
    1+package victor.training.commons;
    2+
    3+import io.opentelemetry.api.GlobalOpenTelemetry;
    4+import io.opentelemetry.api.common.AttributeKey;
    5+import io.opentelemetry.api.trace.Span;
    6+import io.opentelemetry.api.trace.SpanBuilder;
    7+import io.opentelemetry.api.trace.StatusCode;
    8+import io.opentelemetry.context.Scope;
    9+import java.util.Arrays;
    10+import java.util.stream.Collectors;
    11+import org.aspectj.lang.ProceedingJoinPoint;
    12+import org.aspectj.lang.reflect.CodeSignature;
    13+import org.aspectj.lang.annotation.Around;
    14+import org.aspectj.lang.annotation.Aspect;
    15+
    16+/**
    17+ * A span for every call that crosses this jar's boundary, in either direction: an app calling
    18+ * into commons, and commons calling back out into app code it was handed. A call that stays on
    19+ * one side gets none.
    20+ *
    21+ * <p>Woven into this jar at build time, so the apps change nothing. Without the OpenTelemetry
    22+ * agent, {@link GlobalOpenTelemetry} is a no-op and so is every span here.
    23+ */
    24+@Aspect
    25+public class ModuleBoundaryTracing {
    26+ /** The lifeline petclinic-test's trace-to-puml.ts draws a span on, and what kind of thing it is. */
    27+ private static final AttributeKey<String> PARTICIPANT = AttributeKey.stringKey("genseq.participant");
    28+ private static final AttributeKey<String> STEREOTYPE = AttributeKey.stringKey("genseq.stereotype");
    29+ /** Where the method is, as the agent stamps its own spans: the diagram links the arrow to it. */
    30+ private static final AttributeKey<String> NAMESPACE = AttributeKey.stringKey("code.namespace");
    31+ private static final AttributeKey<String> FUNCTION = AttributeKey.stringKey("code.function");
    32+ /** Which overload ran — `String, HomeCountry` — so the link opens that one, not the first by name. */
    33+ private static final AttributeKey<String> PARAMETERS = AttributeKey.stringKey("boundary.parameters");
    34+ private static final String APPS = "victor.training.";
    35+ private static final String COMMONS = "victor.training.commons.";
    36+
    37+ // execution() is woven into the callee, which cannot see its caller. This flag is how a
    38+ // commons method tells a call from an app (a crossing) from a call by commons itself.
    39+ private static final ThreadLocal<Boolean> INSIDE = ThreadLocal.withInitial(() -> false);
    40+
    41+ // A record is the data both sides exchange: reading one is not a call into the module.
    42+ @Around("execution(* victor.training.commons..*(..)) && !within(java.lang.Record+)"
    43+ + " && !within(victor.training.commons.ModuleBoundaryTracing)")
    44+ public Object enter(ProceedingJoinPoint call) throws Throwable {
    45+ if (INSIDE.get()) {
    46+ return call.proceed();
    47+ }
    48+ return traced(call, call.getSignature().getDeclaringType(), true);
    49+ }
    50+
    51+ @Around("call(* *(..)) && within(victor.training.commons..*)"
    52+ + " && !within(victor.training.commons.ModuleBoundaryTracing)")
    53+ public Object leave(ProceedingJoinPoint call) throws Throwable {
    54+ Object callee = call.getTarget();
    55+ if (callee == null || !isAppCode(callee.getClass())) {
    56+ return call.proceed(); // the JDK, or commons itself
    57+ }
    58+ // a lambda's class is hidden and named like Foo$$Lambda/0x…: the interface reads better
    59+ Class<?> type = callee.getClass().isHidden() ? call.getSignature().getDeclaringType() : callee.getClass();
    60+ return traced(call, type, false);
    61+ }
    62+
    63+ private static boolean isAppCode(Class<?> type) {
    64+ return type.getName().startsWith(APPS) && !type.getName().startsWith(COMMONS);
    65+ }
    66+
    67+ private static String parameterTypes(ProceedingJoinPoint call) {
    68+ return Arrays.stream(((CodeSignature) call.getSignature()).getParameterTypes())
    69+ .map(Class::getSimpleName)
    70+ .collect(Collectors.joining(", "));
    71+ }
    72+
    73+ private static Object traced(ProceedingJoinPoint call, Class<?> type, boolean entering) throws Throwable {
    74+ String method = call.getSignature().getName();
    75+ SpanBuilder builder = GlobalOpenTelemetry.getTracer("petclinic-commons")
    76+ .spanBuilder(type.getSimpleName() + "." + method)
    77+ .setAttribute(NAMESPACE, type.getName())
    78+ .setAttribute(FUNCTION, method)
    79+ .setAttribute(PARAMETERS, parameterTypes(call))
    80+ .setAttribute("boundary.args", Arrays.toString(call.getArgs()));
    81+ if (entering) { // leaving: drawn on the caller's own lifeline
    82+ builder.setAttribute(PARTICIPANT, "Commons").setAttribute(STEREOTYPE, "module");
    83+ }
    84+ Span span = builder.startSpan();
    85+ boolean wasInside = INSIDE.get();
    86+ INSIDE.set(entering);
    87+ try (Scope ignored = span.makeCurrent()) {
    88+ Object result = call.proceed();
    89+ if (result != null) {
    90+ span.setAttribute("boundary.return", result.toString());
    91+ }
    92+ return result;
    93+ } catch (Throwable e) {
    94+ span.recordException(e);
    95+ span.setStatus(StatusCode.ERROR);
    96+ throw e;
    97+ } finally {
    98+ INSIDE.set(wasInside);
    99+ span.end();
    100+ }
    101+ }
    102+}
     
    1+package victor.training.commons;
    2+
    3+public final class PhoneNumbers {
    4+ private PhoneNumbers() {
    5+ }
    6+
    7+ /** {@code +1 (608) 555-1023} → {@code +16085551023}; a {@code 00} international prefix becomes {@code +}. */
    8+ public static String normalize(String phone) {
    9+ String digits = stripFormatting(phone);
    10+ return digits.startsWith("00") ? "+" + digits.substring(2) : digits;
    11+ }
    12+
    13+ /** Like {@link #normalize(String)}, but a national number is given the caller's dial code. */
    14+ public static String normalize(String phone, HomeCountry homeCountry) {
    15+ String normalized = normalize(phone);
    16+ if (normalized.startsWith("+")) {
    17+ return normalized;
    18+ }
    19+ String withoutTrunkPrefix = normalized.startsWith("0") ? normalized.substring(1) : normalized;
    20+ return homeCountry.homeDialCode() + withoutTrunkPrefix;
    21+ }
    22+
    23+ private static String stripFormatting(String phone) {
    24+ return phone.replaceAll("[^+\\d]", "");
    25+ }
    26+}
     
    1+package victor.training.commons;
    2+
    3+import static org.assertj.core.api.Assertions.assertThat;
    4+
    5+import org.junit.jupiter.params.ParameterizedTest;
    6+import org.junit.jupiter.params.provider.CsvSource;
    7+
    8+class PhoneNumbersTest {
    9+ private static final HomeCountry US = () -> "+1";
    10+
    11+ @ParameterizedTest
    12+ @CsvSource(delimiter = '|', value = {
    13+ "+1 (608) 555-1023 | +16085551023",
    14+ "(0043)152634418 | +43152634418",
    15+ "6085551023 | 6085551023"})
    16+ void keepsOnlyTheDigitsAndAnInternationalPlus(String typed, String normalized) {
    17+ assertThat(PhoneNumbers.normalize(typed)).isEqualTo(normalized);
    18+ }
    19+
    20+ @ParameterizedTest
    21+ @CsvSource(delimiter = '|', value = {
    22+ "8474461990 | +18474461990",
    23+ "0608 555-1023 | +16085551023",
    24+ "+43152634418 | +43152634418"})
    25+ void givesANationalNumberTheHomeDialCode(String typed, String international) {
    26+ assertThat(PhoneNumbers.normalize(typed, US)).isEqualTo(international);
    27+ }
    28+}
     
    1+package victor.training.outside;
    2+
    3+import static org.assertj.core.api.Assertions.assertThat;
    4+
    5+import io.opentelemetry.api.common.AttributeKey;
    6+import io.opentelemetry.sdk.testing.junit5.OpenTelemetryExtension;
    7+import io.opentelemetry.sdk.trace.data.SpanData;
    8+import java.time.LocalDate;
    9+import org.junit.jupiter.api.Test;
    10+import org.junit.jupiter.api.extension.RegisterExtension;
    11+import victor.training.commons.HomeCountry;
    12+import victor.training.commons.PhoneNumbers;
    13+import victor.training.commons.VisitBookedNotification;
    14+
    15+/** Calls commons from outside its package, as the apps do: each crossing is a span, nothing else is. */
    16+class ModuleBoundaryTracingTest {
    17+ private static final AttributeKey<String> PARTICIPANT = AttributeKey.stringKey("genseq.participant");
    18+ private static final AttributeKey<String> STEREOTYPE = AttributeKey.stringKey("genseq.stereotype");
    19+ private static final AttributeKey<String> NAMESPACE = AttributeKey.stringKey("code.namespace");
    20+ private static final AttributeKey<String> FUNCTION = AttributeKey.stringKey("code.function");
    21+ private static final AttributeKey<String> ARGS = AttributeKey.stringKey("boundary.args");
    22+ private static final AttributeKey<String> RETURN = AttributeKey.stringKey("boundary.return");
    23+ private static final AttributeKey<String> PARAMETERS = AttributeKey.stringKey("boundary.parameters");
    24+
    25+ @RegisterExtension
    26+ static final OpenTelemetryExtension otel = OpenTelemetryExtension.create();
    27+
    28+ @Test
    29+ void aCallIntoCommonsIsASpanOnTheModuleLifelineButTheCallsInsideItAreNot() {
    30+ PhoneNumbers.normalize("+1 (608) 555-1023");
    31+
    32+ assertThat(otel.getSpans()).singleElement().satisfies(span -> {
    33+ assertThat(span.getName()).isEqualTo("PhoneNumbers.normalize");
    34+ assertThat(span.getAttributes().get(PARTICIPANT)).isEqualTo("Commons");
    35+ assertThat(span.getAttributes().get(STEREOTYPE)).isEqualTo("module");
    36+ assertThat(span.getAttributes().get(NAMESPACE)).isEqualTo(PhoneNumbers.class.getName());
    37+ assertThat(span.getAttributes().get(FUNCTION)).isEqualTo("normalize");
    38+ assertThat(span.getAttributes().get(PARAMETERS)).isEqualTo("String");
    39+ assertThat(span.getAttributes().get(ARGS)).isEqualTo("[+1 (608) 555-1023]");
    40+ assertThat(span.getAttributes().get(RETURN)).isEqualTo("+16085551023");
    41+ });
    42+ }
    43+
    44+ @Test
    45+ void aCallFromCommonsBackOutIsASpanNestedInTheOneThatEntered() {
    46+ PhoneNumbers.normalize("8474461990", new Clinic());
    47+
    48+ SpanData entered = span("PhoneNumbers.normalize");
    49+ SpanData left = span("Clinic.homeDialCode");
    50+ assertThat(entered.getAttributes().get(PARAMETERS)).as("which overload ran").isEqualTo("String, HomeCountry");
    51+ assertThat(left.getParentSpanId()).isEqualTo(entered.getSpanId());
    52+ assertThat(left.getAttributes().get(PARTICIPANT)).as("drawn on its caller's own lifeline").isNull();
    53+ assertThat(left.getAttributes().get(NAMESPACE)).isEqualTo(Clinic.class.getName());
    54+ assertThat(left.getAttributes().get(FUNCTION)).isEqualTo("homeDialCode");
    55+ }
    56+
    57+ @Test
    58+ void reEnteringCommonsFromAppCodeIsACrossingAgain() {
    59+ HomeCountry asksCommonsBack = () -> PhoneNumbers.normalize("+1");
    60+ PhoneNumbers.normalize("8474461990", asksCommonsBack);
    61+
    62+ assertThat(otel.getSpans()).extracting(SpanData::getName)
    63+ .containsExactlyInAnyOrder(
    64+ "PhoneNumbers.normalize", "HomeCountry.homeDialCode", "PhoneNumbers.normalize");
    65+ }
    66+
    67+ @Test
    68+ void readingASharedRecordIsNotACallIntoTheModule() {
    69+ new VisitBookedNotification("+16085551023", "Leo", LocalDate.of(2026, 10, 1)).ownerPhone();
    70+
    71+ assertThat(otel.getSpans()).isEmpty();
    72+ }
    73+
    74+ private static SpanData span(String name) {
    75+ return otel.getSpans().stream().filter(s -> s.getName().equals(name)).findFirst().orElseThrow();
    76+ }
    77+
    78+ static class Clinic implements HomeCountry {
    79+ @Override
    80+ public String homeDialCode() {
    81+ return "+1";
    82+ }
    83+ }
    84+}
     
    1+@startuml
    2+
    3+title Frontend Logical Architecture
    4+
    5+skinparam shadowing false
    6+' the review page themes this colour for dark mode (--dgm-link)
    7+skinparam hyperlinkColor #1A4FA0
    8+skinparam componentStyle rectangle
    9+skinparam nodesep 20
    10+' the stereotype is the box's folder, relative to petclinic-frontend/, which the test and the
    11+' review page read; it is hidden so the folder is not printed under its name.
    12+' A file belongs to the box with the deepest folder containing it.
    13+' Only production .ts counts (*.spec.ts is skipped, like ArchUnit's DoNotIncludeTests).
    14+' testing/ keeps its box: its stubs are non-spec files, imported by specs alone.
    15+' Imports leaving src/app (../environments) are not boxes and are not checked.
    16+hide stereotype
    17+skinparam ranksep 30
    18+<style>
    19+component {
    20+ BackGroundColor #ECEFF1
    21+ LineColor #546E7A
    22+}
    23+arrow {
    24+ LineColor #78909C
    25+}
    26+</style>
    27+
    28+[app] <<src/app>>
    29+[owners] <<src/app/owners>>
    30+[vets] <<src/app/vets>>
    31+[pets] <<src/app/pets>>
    32+[visits] <<src/app/visits>>
    33+[pettypes] <<src/app/pettypes>>
    34+[specialties] <<src/app/specialties>>
    35+[parts] <<src/app/parts>>
    36+[invoice] <<src/app/invoice>>
    37+[design-system] <<src/app/design-system>>
    38+[generated] <<src/app/generated>>
    39+[testing] <<src/app/testing>>
    40+
    41+' <--> is two arrows, one each way: the test reads it as both dependencies.
    42+' Every feature module is registered by app.module, and every feature service
    43+' takes HttpErrorHandler from the root error.service.
    44+[app] <--> [owners]
    45+[app] <--> [pets]
    46+[app] <--> [visits]
    47+[app] <--> [vets]
    48+[app] <--> [pettypes]
    49+[app] <--> [specialties]
    50+[app] --> [parts]
    51+
    52+[owners] <--> [pets]
    53+[pets] <--> [visits]
    54+[visits] --> [owners]
    55+[pets] --> [pettypes]
    56+[visits] --> [pettypes]
    57+[visits] --> [vets]
    58+[pettypes] --> [specialties]
    59+[vets] --> [specialties]
    60+
    61+[pets] --> [design-system]
    62+[vets] --> [design-system]
    63+[visits] --> [design-system]
    64+
    65+[owners] --> [generated]
    66+[pets] --> [generated]
    67+[visits] --> [generated]
    68+[pettypes] --> [generated]
    69+[specialties] --> [generated]
    70+[vets] --> [generated]
    71+
    72+' layout only, ignored by the test: shared and unused boxes on the bottom row
    73+[specialties] -[hidden]-> [parts]
    74+[specialties] -[hidden]-> [invoice]
    75+[specialties] -[hidden]-> [testing]
    76+
    77+@enduml
     
    1313 "test": "ng test",
    1414 "test-headless": "ng test --no-watch --no-progress --browsers=ChromeHeadlessCI",
    1515 "lint": "ng lint",
    16+ "test:arch": "node --test scripts/packages-arch.test.mjs",
    1617 "e2e": "ng e2e"
    1718 },
    1819 "private": true,
     
    1+// The frontend twin of the backend's PackagesArchTest: docs/frontend-packages.puml is the
    2+// allowed dependency picture, and the code may not import across boxes the picture does not
    3+// connect. An arrow nobody uses does not fail, as in ArchUnit. Zero dependencies on purpose.
    4+import {test} from 'node:test';
    5+import assert from 'node:assert/strict';
    6+import {existsSync, readFileSync, readdirSync, statSync} from 'node:fs';
    7+import {dirname, join, relative, resolve, sep} from 'node:path';
    8+import {fileURLToPath} from 'node:url';
    9+
    10+const FRONTEND = resolve(dirname(fileURLToPath(import.meta.url)), '..');
    11+const DIAGRAM = 'docs/frontend-packages.puml';
    12+const APP = 'src/app';
    13+
    14+const toPosix = p => p.split(sep).join('/');
    15+const rel = abs => toPosix(relative(FRONTEND, abs));
    16+const isProduction = file => file.endsWith('.ts') && !file.endsWith('.spec.ts') && !file.endsWith('.d.ts');
    17+
    18+function parseDiagram() {
    19+ const boxes = new Map(); // label -> folder relative to petclinic-frontend/
    20+ const arrows = new Set(); // "from->to"
    21+ for (const raw of readFileSync(join(FRONTEND, DIAGRAM), 'utf8').split('\n')) {
    22+ const line = raw.trim();
    23+ if (line.startsWith("'")) continue;
    24+ const box = line.match(/^\[([^\]]+)\]\s*(?:as\s+\S+\s*)?<<([^>]+)>>/);
    25+ if (box) boxes.set(box[1], box[2].trim().replace(/\/$/, ''));
    26+ // [a] --> [b], [a] -up-> [b], [a] <--> [b] (both ways); [a] -[hidden]-> [b] is layout only
    27+ const arrow = line.match(/^\[([^\]]+)\]\s*(<?[-.]\S*?[-.]>?)\s*\[([^\]]+)\]/);
    28+ if (arrow && !arrow[2].includes('hidden')) {
    29+ const [, from, shaft, to] = arrow;
    30+ if (shaft.endsWith('>')) arrows.add(`${from}->${to}`);
    31+ if (shaft.startsWith('<')) arrows.add(`${to}->${from}`);
    32+ }
    33+ }
    34+ return {boxes, arrows};
    35+}
    36+
    37+function walk(dir) {
    38+ return readdirSync(dir).flatMap(name => {
    39+ const path = join(dir, name);
    40+ return statSync(path).isDirectory() ? walk(path) : [path];
    41+ });
    42+}
    43+
    44+/** The box whose folder is the deepest one containing the file, or undefined. */
    45+function boxOf(file, boxes) {
    46+ let best;
    47+ for (const [label, folder] of boxes) {
    48+ if ((file === folder || file.startsWith(folder + '/')) && (!best || folder.length > best.folder.length)) {
    49+ best = {label, folder};
    50+ }
    51+ }
    52+ return best?.label;
    53+}
    54+
    55+/** Resolves a relative specifier as TypeScript would: file.ts, then folder/index.ts. */
    56+function resolveImport(fromFile, specifier) {
    57+ const base = resolve(FRONTEND, dirname(fromFile), specifier);
    58+ for (const candidate of [base, `${base}.ts`, join(base, 'index.ts')]) {
    59+ if (existsSync(candidate) && statSync(candidate).isFile()) return rel(candidate);
    60+ }
    61+ if (existsSync(base) && statSync(base).isDirectory()) return rel(base); // a folder with no barrel
    62+ return undefined;
    63+}
    64+
    65+function relativeImports(source) {
    66+ const found = [];
    67+ const pattern = /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s+)(['"])(\.{1,2}\/[^'"]*)\1/g;
    68+ for (const match of source.matchAll(pattern)) {
    69+ found.push({specifier: match[2], line: source.slice(0, match.index).split('\n').length});
    70+ }
    71+ return found;
    72+}
    73+
    74+const {boxes, arrows} = parseDiagram();
    75+const files = walk(join(FRONTEND, APP)).map(rel).filter(isProduction);
    76+
    77+test(`every cross-box import is an arrow in ${DIAGRAM}`, () => {
    78+ const violations = [];
    79+ for (const file of files) {
    80+ const fromBox = boxOf(file, boxes);
    81+ for (const {specifier, line} of relativeImports(readFileSync(join(FRONTEND, file), 'utf8'))) {
    82+ const target = resolveImport(file, specifier);
    83+ if (!target) {
    84+ violations.push(`${file}:${line} imports '${specifier}', which resolves to no file`);
    85+ continue;
    86+ }
    87+ if (!target.startsWith(APP + '/')) continue; // ../environments etc. are outside the picture
    88+ const toBox = boxOf(target, boxes);
    89+ if (fromBox && toBox && fromBox !== toBox && !arrows.has(`${fromBox}->${toBox}`)) {
    90+ violations.push(`${file}:${line} → ${target} needs the arrow [${fromBox}] --> [${toBox}]`);
    91+ }
    92+ }
    93+ }
    94+ assert.ok(violations.length === 0, `\n${violations.length} import(s) not drawn in ${DIAGRAM}:\n `
    95+ + violations.join('\n ') + '\nDraw the arrow if the dependency is intended, or remove the import.\n');
    96+});
    97+
    98+test(`every production .ts file under ${APP} is in a box`, () => {
    99+ const orphans = files.filter(file => !boxOf(file, boxes));
    100+ assert.ok(orphans.length === 0, `\nFiles in no box of ${DIAGRAM}:\n ${orphans.join('\n ')}\n`);
    101+});
    102+
    103+test(`the boxes of ${DIAGRAM} are exactly the folders of ${APP}`, () => {
    104+ const problems = [];
    105+ for (const [label, folder] of boxes) {
    106+ if (!files.some(file => file.startsWith(folder + '/'))) {
    107+ problems.push(`box [${label}] <<${folder}>>: no production .ts file in that folder`);
    108+ }
    109+ }
    110+ const boxed = new Set(boxes.values());
    111+ const topFolders = new Set(files.map(file => file.slice(APP.length + 1).split('/'))
    112+ .filter(parts => parts.length > 1).map(parts => `${APP}/${parts[0]}`));
    113+ for (const folder of [APP, ...topFolders]) {
    114+ if (!boxed.has(folder)) {
    115+ problems.push(`folder ${folder} has no box: add [${folder.split('/').pop()}] <<${folder}>>`);
    116+ }
    117+ }
    118+ assert.ok(problems.length === 0, `\nThe diagram and ${APP} disagree:\n ${problems.join('\n ')}\n`);
    119+});
     
    5151 <div class="form-group">
    5252 <label for="vet" class="col-sm-2 control-label">Vet</label>
    5353 <div class="col-sm-10">
    54- <app-combo inputId="vet" name="vetId" [(ngModel)]="visit.vetId" [options]="vetOptions" valueKey="id"
    55- [placeholder]="noVet"></app-combo>
    54+ <!--
    55+ Planted on purpose for the human-review demo: a native <select> where the design
    56+ system's <app-combo> belongs, so the UX audit has a gap to show. Book a Visit keeps
    57+ the combo. Do not "fix" this.
    58+ -->
    59+ <select id="vet" name="vetId" class="form-control" [(ngModel)]="visit.vetId">
    60+ <option [ngValue]="null">-- none --</option>
    61+ <option *ngFor="let vetOption of vetOptions" [ngValue]="vetOption.id">{{ vetOption.name }}</option>
    62+ </select>
    5663 </div>
    5764 </div>
    5865 </div>
     
    1+# Tempo on its own, no Docker, no Grafana — started by ../start-tempo.sh.
    2+# It is all the sequence diagrams need: OTLP in on :4318, the search/trace API on :3200
    3+# (petclinic-test/src/genseq/tempo-client.ts talks to it directly when TEMPO_URL is set).
    4+# Traces live under ./.tempo-data and are wiped on every start.
    5+stream_over_http_enabled: true
    6+
    7+server:
    8+ http_listen_address: 127.0.0.1
    9+ http_listen_port: 3200
    10+ grpc_listen_address: 127.0.0.1
    11+ grpc_listen_port: 9095
    12+ log_level: error
    13+
    14+distributor:
    15+ receivers:
    16+ otlp:
    17+ protocols:
    18+ http:
    19+ endpoint: "127.0.0.1:4318"
    20+
    21+# Tempo 3 leaves the last 30 s out of every search, so as not to answer with a trace still
    22+# coming in. The diagrams are fetched seconds after a test, and the generator waits for each
    23+# test's window to close anyway; at 30 s it gave up on every one of them.
    24+query_frontend:
    25+ query_end_cutoff: 0s
    26+
    27+storage:
    28+ trace:
    29+ backend: local
    30+ wal:
    31+ path: ./.tempo-data/wal
    32+ local:
    33+ path: ./.tempo-data/blocks
    34+
    35+# Tempo 3 defaults every one of these to /var/tempo, which a non-root user cannot create.
    36+live_store:
    37+ # A trace is searchable once the live store has cut it: 5-10 s after its last span by
    38+ # default, which already eats most of the generator's 14 s of retries.
    39+ max_trace_idle: 1s
    40+ flush_check_period: 1s
    41+ shutdown_marker_dir: ./.tempo-data/live-store/shutdown-marker
    42+ wal:
    43+ path: ./.tempo-data/live-store/wal
    44+block_builder:
    45+ wal:
    46+ path: ./.tempo-data/block-builder/wal
    47+backend_scheduler:
    48+ local_work_path: ./.tempo-data/backend-scheduler
    49+
    50+usage_report:
    51+ reporting_enabled: false
    AGENTS.md+22 −3
     
    3333 `OTEL_EXPORTER_OTLP_ENDPOINT`, `BACKEND_URL`, keeping the dev port as the default. In a
    3434 container the browser's spans reach the collector only through the frontend's nginx
    3535 (`location = /v1/traces` → `lgtm:4318`), the same path `proxy.conf.js` serves under `ng serve`.
    36-- ⚠️ **Specs in `src/` must not create/delete visits or owners.** `visits.spec.ts` compares the
    37- *entire* visit list against the API, so a row appearing mid-run fails an unrelated test —
    38- the suite runs `fullyParallel` against one shared DB.
    36+- **Every test starts on the DB fixture it declares, and declaring nothing means empty** — the
    37+ schema plus the reference rows (pet types, specialties). A spec that reads rows it did not
    38+ write says so: `test.use({dbFixture: 'default'})` for the seed, or the name of a
    39+ `db/fixtures/<name>.sql`; a Cucumber feature or scenario tags itself `@fixture:default`.
    40+ The reset runs before **every** test (`src/support/db-fixture.ts`, through the docker
    41+ stack's `/__reset` sidecar), which is why the suite runs on **one worker**: a reset empties
    42+ the one database the stack shares. On `ng serve` there is no sidecar, and the tests run on
    43+ whatever the database holds, with a warning. The review page draws the declared fixture as
    44+ the test's dot; a test with no dot started empty.
    3945 - A `Backend -> DB` arrow is labelled with **the call the query came from**, not the query —
    4046 `SELECT petclinic` (operation + *database*) is what all sixty queries of an N+1 are named.
    4147 `src/genseq/trace-to-puml.ts` takes the first of: Hibernate's own comment on the statement
     
    104110 method) the commit lands on the SERVER span, so the frame wraps the handler's *body*.
    105111 Framing the span itself would swallow the request and response arrows with it, and the
    106112 picture would lose the call it is about.
    113+- **How a transaction ended is written beside its tab**: `group tx [committed]` in grey,
    114+ `[rolled back]` / `[commit failed]` in amber (PlantUML draws a group's `[…]` tail to the
    115+ right of the tab). The agent records no rollback at all, so the backend's
    116+ `TransactionOutcomeTracing` (a Spring `TransactionExecutionListener`) stamps
    117+ `transaction.outcome` on the span that opened the transaction; a trace without it falls back
    118+ to the commit span, i.e. `committed`.
    107119 - The **`Session.*` / `Hibernate Query` spans are dropped** when a Spring Data repository span
    108120 above them already said the same thing. They are kept with no repository above them — code
    109121 using the EntityManager directly, where the session call is the only account of the request.
     
    130142 `1 / 2` — advertising neither that it existed nor what it would do. `SEQ_INTERACTIVE=0`
    131143 bakes the detail back into the picture, which is what `diagram:lean`/`:static`/`:full` do. The ids are **hashes of the detail, never counters** — `.claude/skills/human-review/scripts/puml-diff.sh` diffs the
    132144 `.puml` textually, so a positional id would repaint everything under an inserted query.
    145+- **A `Browser -> Backend` arrow links to the line that sent that request**, taken from the
    146+ request's own JS stack, never from a search (README, "A browser request links…"). It needs no
    147+ application change and no extra build: `src/support/call-sites.ts` wraps XHR and fetch from
    148+ outside, and the source maps of the `docker` build are served beside the bundles even though
    149+ they are `hidden`. Both fixtures record it for traced tests only. An arrow without a link is
    150+ the honest answer, so do not add a fallback: `npm run trace:diagram` logs
    151+ `n/m browser request(s) traced to their line` per test.
    133152 - The windows file (`test-results/trace-windows.json`) is what a standalone re-render replays,
    134153 so each runner forgets only **its own** entries at start (`*.spec.ts` for Playwright,
    135154 `*.feature` for Cucumber) — wiping it whole would shrink re-renders to the last suite that ran.
    README.md+32 −0
    They are generated artifacts and say so on their first lines; edit the test, not
    7272 backend only attaches the OpenTelemetry Java agent if `:4318` is already listening. Both
    7373 scripts now say so out loud if the order was wrong, instead of silently producing no diagrams.
    7474
    75+**No Docker?** `../start-tempo.sh` in place of `./start-grafana.sh`: Tempo alone, one binary
    76+downloaded on first start, OTLP on the same `:4318` and its API on `:3200`. The diagrams need
    77+nothing more — `run-tests-with-tracing.sh` (both) find it by themselves when no Grafana answers
    78+on `:3300`, and `generate.ts` reads it from `TEMPO_URL`. Same start order: Tempo before backend.
    79+
    7580 **Or skip the host stack entirely** — an isolated instance carries its own Tempo, on ports the
    7681 host picks, from a freshly seeded database (what `/human-review` does, `human-review.json`
    7782 `steps.sequence.app`):
    the SVG. Clicking that arrow in `review/review.html` opens a panel beside it —
    130135
    131136 State is per arrow, not a switch on the page: one arrow open says nothing about the next.
    132137
    138+**A browser request links to the line that sent it, in that run.** The same endpoint is
    139+called from several places (`GET /api/owners/{ownerId}` comes from `owner-detail.component.ts:22`
    140+and from `visit-add.component.ts:47` in one test), so no scan of the source can say which; the
    141+request itself does. No application code changes for it:
    142+
    143+1. `src/support/call-sites.ts` wraps `XMLHttpRequest` and `fetch` from `page.addInitScript`,
    144+ before the app loads, so zone.js and OTel wrap on top of it: by the time a request reaches
    145+ it OTel has set its `traceparent`, and `new Error().stack` still has the whole way down.
    146+2. At the end of the test each stack goes through the source maps served beside the bundles
    147+ (`hidden` only drops the `sourceMappingURL` comment; nginx still serves `main.<hash>.js.map`)
    148+ into `test-results/call-sites/`. `Error.stackTraceLimit` is lifted: the app frame sits
    149+ about 48 frames down, and the default keeps 10.
    150+3. `src/genseq/call-site.ts` joins them on the browser's CLIENT span (`traceparent` is its id)
    151+ and stamps `genseq.call-site` on it, so `trace-spans.json` replays with the links.
    152+
    153+The arrow links to the first application frame, normally the component's `.subscribe(`:
    154+`http.get` is a cold Observable, so the service method has returned and is not on the stack. A
    155+service above a component yields to the component; the other frames go in the tooltip.
    156+
    157+A request sent after a hop has no application frame at all: `router.navigate` crosses a
    158+`Promise.resolve`, so sorting the visits table sends `GET /api/visits` from a stack of pure
    159+rxjs. Chrome's async stacks do not see that hop, because zone.js drains its own microtask queue
    160+inside the click, so the init script also takes the stack wherever Zone schedules a task. Such
    161+a request links to the handler the template called (`onSort`, `onPage`), not to the `navigate()`
    162+helper they share. The line is confirmed against the checkout. No stack, no map or a line that moved means no link on
    163+that arrow, never a guess. A body keeps its `⊕` as a second link beside the words.
    164+
    133165 Two files come out of a run, and both belong to the diagram:
    134166
    135167 | | |
     
    3232 "@types/node": "^25.5.2",
    3333 "axios": "^1.14.0",
    3434 "concurrently": "^9.2.1",
    35+ "source-map": "^0.6.1",
    3536 "tree-kill": "^1.2.2",
    3637 "ts-node": "^10.9.2",
    3738 "typescript": "^6.0.2",
    export default defineConfig({
    1313 // to the per-test artifacts, not in a second top-level folder. They must not
    1414 // nest: the HTML reporter wipes its own folder before writing.
    1515 outputDir: './test-results/artifacts',
    16- fullyParallel: true,
    1716 forbidOnly: !!process.env.CI,
    1817 retries: process.env.CI ? 2 : 0,
    19- // One worker under COVERAGE_DIR as well: per-test coverage dumps the backend's one global
    20- // set of counters after each test, and two tests running at once would share a dump
    21- // (src/support/coverage.ts).
    22- workers: process.env.CI || process.env.COVERAGE_DIR ? 1 : undefined,
    18+ // One test at a time, always. Each test starts on the DB fixture it declares — 'empty'
    19+ // unless it says `test.use({dbFixture: 'default'})` — and the reset before it
    20+ // (src/support/trace-fixture.ts) empties the ONE database the whole stack shares: a second
    21+ // worker's test would lose its rows mid-run. Datasets as projects chained by `dependencies`
    22+ // would keep parallelism inside a group, but Playwright skips every dependent project as
    23+ // soon as one test of its dependency fails, so one red spec would hide the rest of the suite.
    24+ // Per-test coverage needed one worker anyway: it dumps the backend's one global set of
    25+ // counters after each test (src/support/coverage.ts).
    26+ fullyParallel: false,
    27+ workers: 1,
    2328 reporter: [
    2429 ['html', {outputFolder: 'test-results/playwright-report'}],
    2530 ['list'],
     
    1111 #
    1212 # ./start-database.sh # embedded Postgres :5432
    1313 # ./start-grafana.sh # Grafana LGTM + Tempo :3300, OTLP :4318
    14+# or ./start-tempo.sh # Tempo alone, no Docker :3200, OTLP :4318
    1415 # ./start-backend.sh # Spring Boot + OTel agent :8080
    1516 # ./start-frontend.sh # Angular dev server :4200
    1617 #
     
    2223 #
    2324 # Every endpoint comes from the environment when set — BASE_URL, BACKEND_URL,
    2425 # GRAFANA_URL, OTEL_EXPORTER_OTLP_ENDPOINT, the names `start-docker.sh ports`
    25-# prints — and defaults to the dev stack's fixed ports above.
    26+# prints, or TEMPO_URL for a bare Tempo — and defaults to the dev stack's fixed
    27+# ports above: Grafana if it answers on :3300, else ./start-tempo.sh's :3200.
    2628 #
    2729 # It does NOT start or stop anything — it only verifies the stack is reachable,
    2830 # runs both suites (which reuse the running apps), and reports the diagrams
    http_up() { curl -fsS "$1" >/dev/null 2>&1; }
    5456 answers() { curl -s -o /dev/null --max-time 3 "$1"; }
    5557 port_up() { (exec 3<>"/dev/tcp/127.0.0.1/$1") 2>/dev/null && exec 3>&- 3<&-; }
    5658
    59+# No Grafana named and none on :3300, but ./start-tempo.sh's Tempo is up: fetch from it
    60+# directly. Exported, so the diagram generator the suites run reads it too.
    61+if [[ -z "${TEMPO_URL:-}${GRAFANA_URL:-}" ]] && ! http_up "$GRAFANA/api/health" \
    62+ && http_up "http://127.0.0.1:3200/ready"; then
    63+ export TEMPO_URL=http://127.0.0.1:3200
    64+fi
    65+
    5766 # --- preflight: the stack must already be running --------------------------
    5867 down=()
    59-http_up "$GRAFANA/api/health" || down+=("Grafana ($GRAFANA) → ./start-grafana.sh")
    60-answers "$OTLP/v1/traces" || down+=("OTLP collector ($OTLP) → ./start-grafana.sh")
    68+if [[ -n "${TEMPO_URL:-}" ]]; then
    69+ http_up "$TEMPO_URL/ready" || down+=("Tempo ($TEMPO_URL) → ./start-tempo.sh")
    70+else
    71+ http_up "$GRAFANA/api/health" || down+=("Grafana ($GRAFANA) → ./start-grafana.sh, or ./start-tempo.sh (no Docker)")
    72+fi
    73+answers "$OTLP/v1/traces" || down+=("OTLP collector ($OTLP) → ./start-grafana.sh or ./start-tempo.sh")
    6174 ((DEV_STACK)) && { port_up "$DB_PORT" || down+=("Postgres (:$DB_PORT) → ./start-database.sh"); }
    6275 http_up "$BACKEND/api/pettypes" || down+=("Backend ($BACKEND) → ./start-backend.sh")
    6376 http_up "$FRONTEND/" || down+=("Frontend ($FRONTEND) → ./start-frontend.sh")
    if ((${#down[@]})); then
    6780 for d in "${down[@]}"; do printf ' • %s\n' "$d" >&2; done
    6881 die "aborting — nothing was started or stopped."
    6982 fi
    70-log "✅ Stack reachable (Grafana, Tempo/OTLP, Postgres, backend+agent, frontend)."
    83+store="Grafana+Tempo"; [[ -n "${TEMPO_URL:-}" ]] && store="Tempo alone at $TEMPO_URL"
    84+log "✅ Stack reachable ($store, OTLP, Postgres, backend+agent, frontend)."
    7185
    7286 # The backend only attaches the OTel agent when :4318 was already up at boot, so
    7387 # a backend started before Grafana passes every check above yet emits no traces.
    rm -f "$UI_TEST_DIR"/generated/*.genseq.puml "$UI_TEST_DIR"/generated/*.genseq.j
    94108 # tests a branch wrote (src/genseq/sequence-tag.ts). Cucumber already runs every scenario
    95109 # and reads the list itself; Playwright only runs what its --grep lets through, so the
    96110 # selected spec titles join the tag there.
    111+# One worker: every spec shares the one traced stack and its database, so in parallel a
    112+# test that books a visit shifts the rows another is paging through, and both time out
    113+# (visits-paginated, 10 Oct 2026: 2 of 6 red, green on a rerun alone). The coverage run
    114+# already goes one at a time (playwright.config.ts, COVERAGE_DIR).
    97115 if [[ -n "${GENSEQ_SELECT:-}" ]]; then
    98116 log "Also tracing $(grep -c . <<<"$GENSEQ_SELECT") test(s) named in GENSEQ_SELECT."
    99117 log "Running the tagged and the selected Playwright specs…"
    100- SKIP_SERVER_START=1 npx playwright test --grep "$(npx ts-node src/genseq/sequence-tag.ts grep)"
    118+ SKIP_SERVER_START=1 npx playwright test --workers=1 --grep "$(npx ts-node src/genseq/sequence-tag.ts grep)"
    101119 else
    102120 log "Running the tagged Playwright spec…"
    103- npm run test:sequence
    121+ npm run test:sequence -- --workers=1
    104122 fi
    105123 test_status=$?
    106124
    if ((${#diagrams[@]})); then
    117135 for d in "${diagrams[@]}"; do echo " - ${d#"$ROOT"/}"; done
    118136 else
    119137 warn "No .puml collected — is the backend running WITH the OTel agent"
    120- warn "(./start-backend.sh while Grafana is up), so traces reached Tempo?"
    138+ warn "(./start-backend.sh while Grafana or Tempo is up), so traces reached Tempo?"
    121139 fi
    122140
    123141 if [[ $test_status -eq 0 ]]; then
     
    11 import {expect, Page} from '@playwright/test';
    22 import axios from 'axios';
    33
    4-// The sentences of add-visit.spec.ts, as plain functions: named for what the
    5-// reader of a scenario wants to see, not for the widget being clicked. The
    6-// selectors live here so the spec never mentions one.
    4+// The sentences of add-visit.spec.ts and of visit-vet.feature's glue, as plain functions:
    5+// named for what the reader of a scenario wants to see, not for the widget being clicked.
    6+// The selectors live here so neither the spec nor the glue mentions one.
    77
    88 const API_BASE = process.env.API_BASE_URL || 'http://localhost:8080/api';
    99
    10+/** What a visit nobody attended reads as — `NO_VET` in petclinic-frontend's visit-vet.pipe.ts. */
    11+export const NO_VET = 'No vet';
    12+
    1013 export interface OwnerWithPet {
    1114 ownerId: number;
    1215 petId: number;
    export async function fill_visit_date_and_unique_description(page: Page, date: s
    3942 return description;
    4043 }
    4144
    45+/**
    46+ * The vet field is there, and before anyone touches it it already says nobody: the form
    47+ * asks who will attend without forcing an answer. `No vet` is the label `visit-vet.pipe.ts`
    48+ * (NO_VET) gives the empty choice, so the booking form and the visit lists use one word.
    49+ */
    50+export async function expect_visit_form_offers_nobody_yet(page: Page): Promise<void> {
    51+ await expect(vetField(page).locator('option:checked')).toHaveText(NO_VET);
    52+}
    53+
    54+/**
    55+ * Picks the first real vet the form offers and returns the name it showed. "Real" is the
    56+ * first option after the empty one: `<app-combo>` binds its options by *index*, so the
    57+ * option values say nothing about which vet is which — the label is the only handle.
    58+ */
    59+export async function select_first_vet_in_visit_form(page: Page): Promise<string> {
    60+ const firstVet = vetField(page).locator('option:not([value=""])').first();
    61+ const vetName = ((await firstVet.textContent()) || '').trim();
    62+ expect(vetName, 'the booking form offers no vet to choose').not.toEqual('');
    63+ await vetField(page).selectOption({label: vetName});
    64+ return vetName;
    65+}
    66+
    4267 export async function submit_visit_form(page: Page): Promise<void> {
    4368 await page.locator('button[type="submit"]:has-text("Add Visit")').click();
    4469 }
    export async function expect_back_on_owner_detail_page(page: Page, ownerId: numb
    4974 }
    5075
    5176 export async function expect_pet_visit_list_contains(page: Page, date: string, description: string): Promise<void> {
    77+ await expect(petVisitRow(page, date, description)).toBeVisible({timeout: 10_000});
    78+}
    79+
    80+/** The owner's page names the vet on the visit's row — or says `No vet`, never a blank. */
    81+export async function expect_pet_visit_list_shows_vet(
    82+ page: Page, date: string, description: string, vet: string): Promise<void> {
    83+ await expect(petVisitRow(page, date, description).locator('td.visit-vet')).toHaveText(vet, {timeout: 10_000});
    84+}
    85+
    86+export async function open_all_visits_screen(page: Page): Promise<void> {
    87+ await page.goto('/visits');
    88+ await page.locator('h2:has-text("Visits")').waitFor({state: 'visible', timeout: 10_000});
    89+}
    90+
    91+/** The all-visits screen names the same vet on the same visit (found by its unique description). */
    92+export async function expect_all_visits_screen_shows_vet(
    93+ page: Page, description: string, vet: string): Promise<void> {
    94+ const row = page.locator('#visitsTable tbody tr')
    95+ .filter({has: page.locator('td.visit-description', {hasText: description})});
    96+ await expect(row.locator('td.visit-vet')).toHaveText(vet, {timeout: 10_000});
    97+}
    98+
    99+function vetField(page: Page) {
    100+ return page.locator('select#vet');
    101+}
    102+
    103+function petVisitRow(page: Page, date: string, description: string) {
    52104 const petBlock = page.locator('app-pet-list').first();
    53- const row = petBlock.locator('app-visit-list tr').filter({hasText: date}).filter({hasText: description});
    54- await expect(row).toBeVisible({timeout: 10_000});
    105+ return petBlock.locator('app-visit-list tr').filter({hasText: date}).filter({hasText: description});
    55106 }
    const {
    2626 // @generate_sequence turns this run into add-visit.spec.ts.<scenario>.genseq.puml, right here
    2727 // in this folder — one section per tagged test in the file.
    2828
    29+// Books onto an owner's pet that is already there: the Default dataset's.
    30+test.use({dbFixture: 'default'});
    31+
    2932 const VISIT_DATE = '2026-05-12';
    3033
    3134 test('Add a visit to an existing pet from the owner detail page',
     
    1+import {test, expect} from './support/trace-fixture';
    2+import axios from 'axios';
    3+
    4+// What an e2e test starts on, pinned down — the browser suites' half of the backend's
    5+// StartingDatabaseTest: the reference rows alone when it declares nothing, the Default seed
    6+// when it says `test.use({dbFixture: 'default'})`, and never what the test before it wrote.
    7+// Only a docker stack can reset (the /__reset sidecar); on `ng serve` these are skipped.
    8+
    9+const BASE = (process.env.BASE_URL || 'http://127.0.0.1:4200').replace(/\/$/, '');
    10+const API = process.env.API_BASE_URL || 'http://localhost:8080/api';
    11+
    12+test.beforeAll(async () => {
    13+ const res = await axios.get(`${BASE}/__reset`, {validateStatus: () => true, timeout: 5_000})
    14+ .catch(() => undefined);
    15+ test.skip(res?.data?.ok !== true, `no reset sidecar behind ${BASE} — not a docker stack`);
    16+});
    17+
    18+type Owner = {id: number; firstName: string; lastName: string};
    19+const owners = async () => (await axios.get(`${API}/owners`)).data as Owner[];
    20+
    21+test.describe('a test that declares no DB fixture', () => {
    22+ test('starts on an empty database, the reference pet types still there', async () => {
    23+ expect(await owners()).toEqual([]);
    24+ const {data: types} = await axios.get(`${API}/pettypes`);
    25+ expect(types.map((t: {name: string}) => t.name))
    26+ .toEqual(['cat', 'dog', 'lizard', 'snake', 'bird', 'hamster', 'horse']);
    27+
    28+ await axios.post(`${API}/owners`, {firstName: 'Left', lastName: 'Behind', address: 'Nowhere 1',
    29+ city: 'Gone', telephone: '0123456789'});
    30+ });
    31+
    32+ test('never sees what the test before it wrote', async () => {
    33+ expect(await owners()).toEqual([]);
    34+ });
    35+});
    36+
    37+test.describe('a test that declares the Default fixture', () => {
    38+ test.use({dbFixture: 'default'});
    39+
    40+ test('starts on the seed: owner 1 is Kevin McCallister', async () => {
    41+ const kevin = (await owners()).find(o => o.id === 1);
    42+ expect(`${kevin?.firstName} ${kevin?.lastName}`).toBe('Kevin McCallister');
    43+ });
    44+});
     
    1+import {test, expect} from '@playwright/test';
    2+import * as fs from 'fs';
    3+import * as os from 'os';
    4+import * as path from 'path';
    5+import {SourceMapConsumer, SourceMapGenerator} from 'source-map';
    6+import {
    7+ CALL_SITE_ATTRIBUTE, CallFrame, REQUEST_HEADERS_ATTRIBUTE, applicationFrames, callSiteHandle,
    8+ callSiteIndex, idsOfTraceparent, parseStack, replayableHeaders, requestFrames, triggerOf, withCallSites,
    9+} from './call-site';
    10+import type {NormSpan} from './trace-to-puml';
    11+import {CUCUMBER_SOURCES} from './generate';
    12+import {readCallSites, saveCallSites} from '../support/call-sites';
    13+import {forgetWindowsOf} from '../support/trace-window-store';
    14+
    15+const BUNDLE = 'http://127.0.0.1:63285/main.5246b24f48d8b1e7.js';
    16+const POLYFILLS = 'http://127.0.0.1:63285/polyfills.96561b4e147d79cf.js';
    17+const COMPONENT = 'src/app/owners/owner-detail/owner-detail.component.ts';
    18+const SOURCE = [
    19+ 'export class OwnerDetailComponent {',
    20+ ' ngOnInit() {',
    21+ ' this.ownerService.getOwnerById(ownerId).subscribe(',
    22+ ' owner => this.owner = owner);',
    23+ ' }',
    24+ '}',
    25+].join('\n');
    26+
    27+/** A bundle of one minified line: column 100 is the app, column 200 rxjs, 300 a lib's own `src/app`. */
    28+function bundleMap(sourceRoot = 'webpack:///', source = `./${COMPONENT}`): SourceMapConsumer {
    29+ const map = new SourceMapGenerator({file: 'main.js', sourceRoot});
    30+ map.addMapping({source, name: 'subscribe',
    31+ generated: {line: 1, column: 99}, original: {line: 3, column: 44}});
    32+ map.addMapping({source: './node_modules/rxjs/dist/esm/internal/Observable.js',
    33+ generated: {line: 1, column: 199}, original: {line: 30, column: 4}});
    34+ map.addMapping({source: './node_modules/some-lib/src/app/lib.ts',
    35+ generated: {line: 1, column: 299}, original: {line: 7, column: 0}});
    36+ map.setSourceContent(source, SOURCE);
    37+ return new SourceMapConsumer(JSON.parse(map.toString()));
    38+}
    39+
    40+const stack = (...rows: string[]) => ['Error', ...rows.map((r) => ` at ${r}`)].join('\n');
    41+
    42+test('the traceparent names the browser span the request left under', () => {
    43+ expect(idsOfTraceparent('00-f74df0671d03f3d3848ffda9ac315932-eb4261e7b2160a89-01'))
    44+ .toEqual({traceId: 'f74df0671d03f3d3848ffda9ac315932', spanId: 'eb4261e7b2160a89'});
    45+ expect(idsOfTraceparent('not a traceparent')).toBeUndefined();
    46+});
    47+
    48+test('a stack reads top first, and a frame with no script position is skipped', () => {
    49+ expect(parseStack(stack(
    50+ 'proto.send [as __zone_symbol__send] (<anonymous>:24:42)',
    51+ `m.X [as scheduleFn] (${POLYFILLS}:1:32105)`,
    52+ `${POLYFILLS}:1:32496`,
    53+ ))).toEqual([
    54+ {url: POLYFILLS, line: 1, column: 32105},
    55+ {url: POLYFILLS, line: 1, column: 32496},
    56+ ]);
    57+});
    58+
    59+test('only the application frames survive, at their original line', () => {
    60+ const frames = applicationFrames(stack(
    61+ `t.subscribe (${BUNDLE}:1:200)`, // rxjs
    62+ `lib (${BUNDLE}:1:300)`, // a library's own src/app
    63+ `e.ngOnInit (${BUNDLE}:1:100)`,
    64+ ), new Map([[BUNDLE, bundleMap()]]));
    65+ expect(frames).toEqual([{
    66+ file: COMPONENT, line: 3, text: 'this.ownerService.getOwnerById(ownerId).subscribe(',
    67+ }]);
    68+});
    69+
    70+test('ng serve spells the source differently, and it is the same file', () => {
    71+ const frames = applicationFrames(stack(`e.ngOnInit (${BUNDLE}:1:100)`),
    72+ new Map([[BUNDLE, bundleMap('', `webpack:///./${COMPONENT}`)]]));
    73+ expect(frames.map((f) => f.file)).toEqual([COMPONENT]);
    74+});
    75+
    76+test('a script with no map gives no frames rather than a position in minified code', () => {
    77+ expect(applicationFrames(stack(`e.ngOnInit (${BUNDLE}:1:100)`), new Map())).toEqual([]);
    78+});
    79+
    80+test('two stack frames on one line are one frame', () => {
    81+ const frames = applicationFrames(
    82+ stack(`a (${BUNDLE}:1:100)`, `b (${BUNDLE}:1:101)`), new Map([[BUNDLE, bundleMap()]]));
    83+ expect(frames).toHaveLength(1);
    84+});
    85+
    86+// The router's hop: a stack of 226 frames with no application frame, and the click that
    87+// scheduled it one task earlier.
    88+test('a request sent from application code never looks at what scheduled it', () => {
    89+ const frames = requestFrames(
    90+ [stack(`e.ngOnInit (${BUNDLE}:1:100)`), stack(`e.earlier (${BUNDLE}:1:100)`)],
    91+ new Map([[BUNDLE, bundleMap()]]));
    92+ expect(frames).toEqual([expect.not.objectContaining({deferred: true})]);
    93+});
    94+
    95+test('a request with no application frame takes the nearest stack that scheduled it', () => {
    96+ const frames = requestFrames(
    97+ [stack(`t.subscribe (${BUNDLE}:1:200)`), stack(`e.onSort (${BUNDLE}:1:100)`)],
    98+ new Map([[BUNDLE, bundleMap()]]));
    99+ expect(frames).toEqual([expect.objectContaining({file: COMPONENT, line: 3, deferred: true})]);
    100+});
    101+
    102+test('no stack with an application frame means no frames', () => {
    103+ expect(requestFrames([stack(`t.subscribe (${BUNDLE}:1:200)`), 'Error'],
    104+ new Map([[BUNDLE, bundleMap()]]))).toEqual([]);
    105+});
    106+
    107+const frame = (file: string, line = 1): CallFrame => ({file, line});
    108+
    109+test('the trigger is the first application frame: the component that subscribed', () => {
    110+ const component = frame('src/app/a/a.component.ts', 63);
    111+ expect(triggerOf([component, frame('src/app/a/a.component.html', 25)])).toBe(component);
    112+});
    113+
    114+test('a service above a component yields to the component that called it', () => {
    115+ const component = frame('src/app/a/a.component.ts');
    116+ expect(triggerOf([frame('src/app/a/a.service.ts'), component])).toBe(component);
    117+ expect(triggerOf([frame('src/app/http-error.interceptor.ts'), component])).toBe(component);
    118+});
    119+
    120+test('a deferred request links to the handler, not the helper every caller shares', () => {
    121+ const file = 'src/app/visits/visits-page/visits-page.component.ts';
    122+ const deferred = (f: string, line: number): CallFrame => ({file: f, line, deferred: true});
    123+ const onSort = deferred(file, 63);
    124+ expect(triggerOf([deferred(file, 86), onSort,
    125+ deferred('src/app/visits/visits-page/visits-page.component.html', 15)])).toBe(onSort);
    126+});
    127+
    128+test('a service alone on the stack is still the best there is', () => {
    129+ const service = frame('src/app/a/a.service.ts');
    130+ expect(triggerOf([service])).toBe(service);
    131+});
    132+
    133+test('no application frame, no trigger', () => {
    134+ expect(triggerOf([])).toBeUndefined();
    135+});
    136+
    137+const ROOT = '/repo';
    138+const read = (files: Record<string, string>) => (p: string) => files[p];
    139+const attribute = (frames: CallFrame[]) => JSON.stringify(frames);
    140+
    141+test('the handle points at the confirmed line and says what the numbers are', () => {
    142+ const frames = [
    143+ {file: COMPONENT, line: 3, text: 'this.ownerService.getOwnerById(ownerId).subscribe('},
    144+ {file: 'src/app/owners/owner-detail/owner-detail.component.html', line: 25},
    145+ ];
    146+ expect(callSiteHandle(attribute(frames), 'petclinic-frontend', ROOT,
    147+ read({[`${ROOT}/petclinic-frontend/${COMPONENT}`]: SOURCE})))
    148+ .toBe(`src://petclinic-frontend/${COMPONENT}:3`
    149+ + '{Click to open the line that sent this request: '
    150+ + 'owner-detail.component.ts:3 ← owner-detail.component.html:25}');
    151+});
    152+
    153+test('a line the checkout no longer has there gets no link, not the wrong one', () => {
    154+ const frames = [{file: COMPONENT, line: 2, text: 'this.ownerService.getOwnerById(ownerId).subscribe('}];
    155+ expect(callSiteHandle(attribute(frames), 'petclinic-frontend', ROOT,
    156+ read({[`${ROOT}/petclinic-frontend/${COMPONENT}`]: SOURCE}))).toBeUndefined();
    157+});
    158+
    159+test('a file this checkout does not have gets no link', () => {
    160+ expect(callSiteHandle(attribute([frame(COMPONENT)]), 'petclinic-frontend', ROOT, read({})))
    161+ .toBeUndefined();
    162+});
    163+
    164+test('frames that cannot be read get no link', () => {
    165+ expect(callSiteHandle('not json', 'petclinic-frontend', ROOT, read({}))).toBeUndefined();
    166+ expect(callSiteHandle('[]', 'petclinic-frontend', ROOT, read({}))).toBeUndefined();
    167+});
    168+
    169+function span(over: Partial<NormSpan>): NormSpan {
    170+ return {traceId: '', spanId: '', parentSpanId: '', name: 'GET', kind: 'CLIENT',
    171+ serviceName: 'petclinic-frontend', startNano: 0, attributes: {}, ...over};
    172+}
    173+
    174+test('Tempo’s base64 ids meet the header’s hex ones on the browser span', () => {
    175+ // Real ids from one run: 00-7c4ec1e2eb2f07d285a84ca5769679dd-96b94185f14defff-01
    176+ const index = callSiteIndex([{
    177+ traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff', frames: [frame(COMPONENT, 3)],
    178+ }]);
    179+ const [client, other] = withCallSites([
    180+ span({traceId: 'fE7B4usvB9KFqEyldpZ53Q==', spanId: 'lrlBhfFN7/8='}),
    181+ span({traceId: 'fE7B4usvB9KFqEyldpZ53Q==', spanId: 'AAAAAAAAAAA='}),
    182+ ], index);
    183+ expect(JSON.parse(client.attributes[CALL_SITE_ATTRIBUTE])).toEqual([frame(COMPONENT, 3)]);
    184+ expect(other.attributes).toEqual({});
    185+});
    186+
    187+test('only the request span is stamped, and a request with no frames stays bare', () => {
    188+ const ids = {traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff'};
    189+ const located = callSiteIndex([{...ids, frames: [frame(COMPONENT)]}]);
    190+ expect(withCallSites([span({...ids, kind: 'SERVER'})], located)[0].attributes).toEqual({});
    191+ expect(withCallSites([span(ids)], callSiteIndex([{...ids, frames: []}]))[0].attributes).toEqual({});
    192+});
    193+
    194+// The headers ride on the span like the frames, so a re-render from the span cache has them.
    195+test('a request’s headers are stamped beside its frames, and with no frames too', () => {
    196+ const ids = {traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff'};
    197+ const headers = {Accept: 'application/json'};
    198+ const [both] = withCallSites([span(ids)], callSiteIndex([{...ids, frames: [frame(COMPONENT)], headers}]));
    199+ expect(JSON.parse(both.attributes[REQUEST_HEADERS_ATTRIBUTE])).toEqual(headers);
    200+ expect(both.attributes[CALL_SITE_ATTRIBUTE]).toBeDefined();
    201+ const [headersOnly] = withCallSites([span(ids)], callSiteIndex([{...ids, frames: [], headers}]));
    202+ expect(headersOnly.attributes).toEqual({[REQUEST_HEADERS_ATTRIBUTE]: JSON.stringify(headers)});
    203+});
    204+
    205+// The trace context is new on every request of every run: kept, it would repaint every sidecar.
    206+test('the per-run trace context is not a header worth keeping', () => {
    207+ expect(replayableHeaders({traceparent: '00-a-b-01', TraceState: 'x', baggage: 'y', Accept: '*/*'}))
    208+ .toEqual({Accept: '*/*'});
    209+ expect(replayableHeaders({traceparent: '00-a-b-01'})).toBeUndefined();
    210+ expect(replayableHeaders(undefined)).toBeUndefined();
    211+});
    212+
    213+test('a saved request keeps its headers, less the trace context', async () => {
    214+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'call-sites-'));
    215+ const traceparent = '00-7c4ec1e2eb2f07d285a84ca5769679dd-96b94185f14defff-01';
    216+ await saveCallSites(dir, {title: 'Add a visit', source: 'src/add-visit.spec.ts'}, [
    217+ {traceparent, stacks: ['Error'], headers: {traceparent, 'Content-Type': 'application/json'}},
    218+ {traceparent: traceparent.replace('ff-01', 'fe-01'), stacks: ['Error'], headers: {traceparent}},
    219+ ]);
    220+ expect(readCallSites(dir).map((s) => s.headers)).toEqual([{'Content-Type': 'application/json'}, undefined]);
    221+});
    222+
    223+// A Scenario Outline runs every row under one title, so one test can save twice in a run.
    224+test('every row of an Outline adds to its file, and a runner forgets only its own', async () => {
    225+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'call-sites-'));
    226+ const outline = {title: 'Filter owners', source: 'src/owner-search.feature'};
    227+ const raw = (spanId: string) => ({
    228+ traceparent: `00-7c4ec1e2eb2f07d285a84ca5769679dd-${spanId}-01`, stacks: ['Error'],
    229+ });
    230+ await saveCallSites(dir, outline, [raw('96b94185f14defff')]);
    231+ await saveCallSites(dir, outline, [raw('96b94185f14deffe')]);
    232+ await saveCallSites(dir, {title: 'Add a visit', source: 'src/add-visit.spec.ts'}, [raw('96b94185f14deffd')]);
    233+ expect(readCallSites(dir).map((s) => s.spanId).sort())
    234+ .toEqual(['96b94185f14deffd', '96b94185f14deffe', '96b94185f14defff']);
    235+
    236+ forgetWindowsOf(dir, CUCUMBER_SOURCES);
    237+ expect(readCallSites(dir).map((s) => s.spanId)).toEqual(['96b94185f14deffd']);
    238+});
    call-site.ts+213 −0
     
    1+import type {SourceMapConsumer} from 'source-map';
    2+import type {NormSpan} from './trace-to-puml';
    3+import type {ReadSource} from './code-location';
    4+
    5+/**
    6+ * Which line of the frontend sent this request, in this run.
    7+ *
    8+ * The same endpoint is called from several places, so scanning the source for a route would
    9+ * be a guess the diagram then states as a fact. The answer is taken from the request itself:
    10+ * `support/call-sites.ts` captures the JS stack at the moment each XHR or fetch leaves, keyed
    11+ * by the `traceparent` it carries, and everything here turns that stack into a link.
    12+ *
    13+ * Measured on add-visit: the stack is 76–89 frames deep and the application frame sits around
    14+ * the 48th, under rxjs, HttpClient, zone and OTel. `http.get` is a cold Observable, so the
    15+ * service method has already returned: `OwnerService.getOwnerById` is never on the stack, and
    16+ * the top application frame is the component's `.subscribe(`. A request the router deferred
    17+ * has no application frame at all, and is explained by what scheduled it (`requestFrames`).
    18+ */
    19+
    20+/** Where a browser CLIENT span keeps its frames, as JSON — so a replay of the span cache has them. */
    21+export const CALL_SITE_ATTRIBUTE = 'genseq.call-site';
    22+
    23+/**
    24+ * Where a browser CLIENT span keeps the headers its request was sent with, as a JSON object.
    25+ * The browser instrumentation records none, and the request's detail panel shows them and
    26+ * replays the call with them (Victor, 10 Nov 2026); on the span for the same reason as the frames.
    27+ */
    28+export const REQUEST_HEADERS_ATTRIBUTE = 'genseq.request-headers';
    29+
    30+/** One application frame of a request's stack, in the original source. */
    31+export interface CallFrame {
    32+ /** Relative to the frontend module: `src/app/owners/owner.service.ts`. */
    33+ file: string;
    34+ line: number;
    35+ /** That line of the source the bundle was built from, to confirm against the checkout. */
    36+ text?: string;
    37+ /** Not on the request's own stack: it comes from the code that scheduled the task it left in. */
    38+ deferred?: boolean;
    39+}
    40+
    41+/** One request, identified the way its spans are: the header it left the page with. */
    42+export interface CallSite {
    43+ traceId: string;
    44+ spanId: string;
    45+ frames: CallFrame[];
    46+ /** Every header the page set on the request, as it set them — less the per-run trace context. */
    47+ headers?: Record<string, string>;
    48+}
    49+
    50+// The trace context is new on every request of every run: stored, it would repaint each
    51+// diagram's sidecar on every run, and a replay sent with it would join a trace long closed.
    52+const PER_RUN_HEADERS = new Set(['traceparent', 'tracestate', 'baggage']);
    53+
    54+/** The headers worth keeping, or undefined when none are left. */
    55+export function replayableHeaders(
    56+ headers: Record<string, string> | undefined,
    57+): Record<string, string> | undefined {
    58+ const kept = Object.entries(headers ?? {}).filter(([name]) => !PER_RUN_HEADERS.has(name.toLowerCase()));
    59+ return kept.length > 0 ? Object.fromEntries(kept) : undefined;
    60+}
    61+
    62+/** `00-<trace id>-<span id>-01` — the span id in it is the browser's CLIENT span. */
    63+export function idsOfTraceparent(
    64+ traceparent: string,
    65+): Pick<CallSite, 'traceId' | 'spanId'> | undefined {
    66+ const m = /^[0-9a-f]{2}-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/.exec(traceparent.trim());
    67+ return m ? {traceId: m[1], spanId: m[2]} : undefined;
    68+}
    69+
    70+const HEX_ID = /^(?:[0-9a-f]{16}|[0-9a-f]{32})$/;
    71+
    72+/** Tempo answers with base64 ids; a `traceparent` carries them in hex. */
    73+function hexId(id: string): string {
    74+ return HEX_ID.test(id) ? id : Buffer.from(id, 'base64').toString('hex');
    75+}
    76+
    77+/** The script positions of a V8 stack, top first. Frames in eval or the init script have none. */
    78+export function parseStack(stack: string): Array<{url: string; line: number; column: number}> {
    79+ const frames: Array<{url: string; line: number; column: number}> = [];
    80+ for (const row of stack.split('\n')) {
    81+ const m = /(https?:\/\/[^\s()]+):(\d+):(\d+)\)?\s*$/.exec(row);
    82+ if (m) frames.push({url: m[1], line: Number(m[2]), column: Number(m[3])});
    83+ }
    84+ return frames;
    85+}
    86+
    87+// `webpack:///src/app/…` in a production bundle, `webpack:///./src/app/…` from `ng serve`.
    88+// Anchored, because `webpack:///node_modules/some-lib/src/app/…` is somebody else's app.
    89+const APPLICATION_SOURCE = /^(?:webpack:\/\/[^/]*\/)?(?:\.\/)?(src\/app\/[^?]+)/;
    90+
    91+/**
    92+ * The application's own frames of one stack, top first, in the original source. A script with
    93+ * no map contributes nothing: no frame is better than a position in minified code.
    94+ */
    95+export function applicationFrames(
    96+ stack: string, maps: ReadonlyMap<string, SourceMapConsumer>,
    97+): CallFrame[] {
    98+ const frames: CallFrame[] = [];
    99+ for (const {url, line, column} of parseStack(stack)) {
    100+ const map = maps.get(url);
    101+ if (!map) continue;
    102+ // A stack counts columns from 1, a source map from 0.
    103+ const at = map.originalPositionFor({line, column: column - 1});
    104+ const file = at.source ? APPLICATION_SOURCE.exec(at.source)?.[1] : undefined;
    105+ if (!file || !at.source || !at.line) continue;
    106+ const previous = frames[frames.length - 1];
    107+ if (previous?.file === file && previous.line === at.line) continue;
    108+ const text = map.sourceContentFor(at.source, true)?.split('\n')[at.line - 1]?.trim();
    109+ frames.push({file, line: at.line, ...(text ? {text} : {})});
    110+ }
    111+ return frames;
    112+}
    113+
    114+/**
    115+ * The frames that explain one request: its own stack's, or else those of the nearest stack
    116+ * that scheduled the task it left in. Causes are a fallback, so a request that was sent from
    117+ * application code is never told apart from it by something that happened earlier.
    118+ */
    119+export function requestFrames(
    120+ stacks: string[], maps: ReadonlyMap<string, SourceMapConsumer>,
    121+): CallFrame[] {
    122+ for (const [hop, stack] of stacks.entries()) {
    123+ const frames = applicationFrames(stack, maps);
    124+ if (frames.length > 0) return hop === 0 ? frames : frames.map((f) => ({...f, deferred: true}));
    125+ }
    126+ return [];
    127+}
    128+
    129+// Code that passes a request along. Above a component on the stack (a service that subscribes
    130+// on the component's behalf), it says how the request travelled, not what started it.
    131+const PASS_THROUGH = /\.(?:service|interceptor)\.ts$/;
    132+
    133+/**
    134+ * The frame an arrow links to: the application frame nearest what started the request.
    135+ * Usually the first frame there is; a service above a component yields to the component.
    136+ *
    137+ * A deferred request counts from the other end. Its first frame is whatever handed the work
    138+ * over, often one helper every caller shares (`navigate()` behind both a sort and a page
    139+ * change), and the deepest code frame is the handler the template called: `onSort`, `onPage`.
    140+ */
    141+export function triggerOf(frames: CallFrame[]): CallFrame | undefined {
    142+ const code = (f: CallFrame) => !PASS_THROUGH.test(f.file) && !f.file.endsWith('.html');
    143+ if (frames[0]?.deferred) return [...frames].reverse().find(code) ?? frames[0];
    144+ return frames.find((f) => !PASS_THROUGH.test(f.file)) ?? frames[0];
    145+}
    146+
    147+const fileName = (frame: CallFrame) => `${frame.file.split('/').pop()}:${frame.line}`;
    148+
    149+/** Each frame by file and line, not by function: a minified stack has no names worth showing. */
    150+function callSiteTooltip(frames: CallFrame[]): string {
    151+ return `{Click to open the line that sent this request: ${frames.map(fileName).join(' ← ')}}`;
    152+}
    153+
    154+function framesOf(attribute: string): CallFrame[] {
    155+ try {
    156+ return JSON.parse(attribute);
    157+ } catch {
    158+ return [];
    159+ }
    160+}
    161+
    162+/**
    163+ * A `src://` handle on the line that sent this request, or undefined when there is no telling.
    164+ *
    165+ * The line is confirmed against the checkout: the bundle may have been built from another
    166+ * commit than the one the page is reviewing, and there the same number is a different line.
    167+ * Better no link than one that lands on the wrong code.
    168+ */
    169+export function callSiteHandle(
    170+ attribute: string, serviceName: string, repoRoot: string, readSource: ReadSource,
    171+): string | undefined {
    172+ const frames = framesOf(attribute);
    173+ const trigger = triggerOf(frames);
    174+ if (!trigger) return undefined;
    175+ const relative = `${serviceName}/${trigger.file}`;
    176+ const lines = readSource(`${repoRoot}/${relative}`)?.split('\n');
    177+ const line = lines?.[trigger.line - 1];
    178+ if (line === undefined) return undefined;
    179+ if (trigger.text !== undefined && line.trim() !== trigger.text) return undefined;
    180+ return `src://${relative}:${trigger.line}${callSiteTooltip(frames)}`;
    181+}
    182+
    183+/** Keyed the same way on both sides of the join, whatever spelling the ids arrived in. */
    184+function joinKey(ids: Pick<CallSite, 'traceId' | 'spanId'>): string {
    185+ return `${hexId(ids.traceId)}/${hexId(ids.spanId)}`;
    186+}
    187+
    188+/** What one recorded request adds to its span. */
    189+export type RecordedSite = Pick<CallSite, 'frames' | 'headers'>;
    190+
    191+/** Every recorded request, by the span it left the browser under. */
    192+export function callSiteIndex(sites: CallSite[]): Map<string, RecordedSite> {
    193+ return new Map(sites.map((site) => [joinKey(site), {frames: site.frames, headers: site.headers}]));
    194+}
    195+
    196+/**
    197+ * Stamps each browser CLIENT span with the frames of the request it made, and its headers.
    198+ *
    199+ * On the span rather than beside it, so the span cache carries them: a re-render replays that
    200+ * file with nothing running, long after the recordings and the source maps are gone.
    201+ */
    202+export function withCallSites(spans: NormSpan[], index: Map<string, RecordedSite>): NormSpan[] {
    203+ return spans.map((span) => {
    204+ const site = span.kind === 'CLIENT' ? index.get(joinKey(span)) : undefined;
    205+ const headers = replayableHeaders(site?.headers);
    206+ const stamped: Record<string, string> = {
    207+ ...(site?.frames?.length ? {[CALL_SITE_ATTRIBUTE]: JSON.stringify(site.frames)} : {}),
    208+ ...(headers ? {[REQUEST_HEADERS_ATTRIBUTE]: JSON.stringify(headers)} : {}),
    209+ };
    210+ if (Object.keys(stamped).length === 0) return span;
    211+ return {...span, attributes: {...span.attributes, ...stamped}};
    212+ });
    213+}
     
    1+import {expect, test} from '@playwright/test';
    2+import {clientMethodOf, maskSource} from './client-method';
    3+
    4+// The frontend method a request went through, read back from the source: the stack only
    5+// has the component's `.subscribe(` line, since `http.get` is a cold Observable.
    6+
    7+const SERVICE = `
    8+import {HttpClient} from '@angular/common/http';
    9+@Injectable()
    10+export class OwnerService {
    11+ entityUrl = environment.REST_API_URL + 'owners';
    12+ constructor(private http: HttpClient) {}
    13+
    14+ getOwners(): Observable<Owner[]> {
    15+ return this.http
    16+ .get<Owner[]>(this.entityUrl)
    17+ .pipe(catchError(this.handlerError('getOwners', [])));
    18+ }
    19+
    20+ getOwnerById(ownerId: number): Observable<Owner> {
    21+ return this.http.get<Owner>(this.entityUrl + '/' + ownerId);
    22+ }
    23+
    24+ addOwner(owner: Owner): Observable<Owner> {
    25+ return this.http.post<Owner>(this.entityUrl, owner);
    26+ }
    27+
    28+ searchOwners(lastName: string): Observable<Owner[]> {
    29+ let url = this.entityUrl;
    30+ if (lastName !== undefined) {
    31+ url += '?lastName=' + lastName;
    32+ }
    33+ return this.http.get<Owner[]>(url).pipe(catchError(this.handlerError('searchOwners', [])));
    34+ }
    35+}
    36+`;
    37+
    38+const COMPONENT = `import {Component, OnInit} from '@angular/core';
    39+import {OwnerService} from '../owner.service';
    40+import {Router} from '@angular/router';
    41+
    42+export class OwnerListComponent implements OnInit {
    43+ constructor(private router: Router, private ownerService: OwnerService) {
    44+ }
    45+
    46+ ngOnInit() {
    47+ this.load = this.ownerService.getOwners().pipe(
    48+ finalize(() => {
    49+ this.isOwnersDataReceived = true;
    50+ })
    51+ ).subscribe(
    52+ owners => this.owners = owners);
    53+ }
    54+
    55+ searchByLastName(lastName: string) {
    56+ this.load?.unsubscribe();
    57+ const owners$ = lastName ? this.ownerService.searchOwners(lastName) : this.ownerService.getOwners();
    58+ this.load = owners$.subscribe(
    59+ owners => this.owners = owners);
    60+ }
    61+
    62+ onSelect(owner: Owner) {
    63+ this.router.navigate(['/owners', owner.id]);
    64+ }
    65+
    66+ ngOnChanges() {
    67+ this.ownerService.getOwnerById(1).subscribe(owner => {
    68+ this.ownerService.addOwner(owner).subscribe(
    69+ () => {});
    70+ });
    71+ }
    72+}
    73+`;
    74+
    75+const ROOT = '/repo/petclinic-frontend';
    76+const COMPONENT_FILE = 'src/app/owners/owner-list/owner-list.component.ts';
    77+const files: Record<string, string> = {
    78+ [`${ROOT}/${COMPONENT_FILE}`]: COMPONENT,
    79+ [`${ROOT}/src/app/owners/owner.service.ts`]: SERVICE,
    80+};
    81+const read = (p: string) => files[p];
    82+const lineOf = (needle: string) => COMPONENT.split('\n').findIndex((l) => l.includes(needle)) + 1;
    83+const frame = (needle: string) => ({file: COMPONENT_FILE, line: lineOf(needle), text: needle});
    84+const URL = 'http://127.0.0.1:8080/api/owners';
    85+
    86+test('a subscribe on the service call names the service method', () => {
    87+ expect(clientMethodOf([frame('this.ownerService.getOwnerById(1).subscribe(owner => {')],
    88+ {verb: 'GET', url: `${URL}/1`}, ROOT, read)).toBe('OwnerService.getOwnerById');
    89+});
    90+
    91+test('a subscribe at the end of a pipe chain reads the statement back to its start', () => {
    92+ expect(clientMethodOf([frame(').subscribe(')], {verb: 'GET', url: URL}, ROOT, read))
    93+ .toBe('OwnerService.getOwners');
    94+});
    95+
    96+test('a call nested in another call’s callback is its own statement', () => {
    97+ expect(clientMethodOf([frame('this.ownerService.addOwner(owner).subscribe(')],
    98+ {verb: 'POST', url: URL}, ROOT, read)).toBe('OwnerService.addOwner');
    99+});
    100+
    101+test('a local observable is followed to the assignment that built it, and the URL picks the branch', () => {
    102+ const sent = frame('this.load = owners$.subscribe(');
    103+ expect(clientMethodOf([sent], {verb: 'GET', url: `${URL}?lastName=Dav`}, ROOT, read))
    104+ .toBe('OwnerService.searchOwners');
    105+ expect(clientMethodOf([sent], {verb: 'GET', url: URL}, ROOT, read))
    106+ .toBe('OwnerService.getOwners');
    107+});
    108+
    109+test('no service call on the line names the component method it is written in', () => {
    110+ expect(clientMethodOf([frame('this.router.navigate([\'/owners\', owner.id]);')],
    111+ {verb: 'GET', url: URL}, ROOT, read)).toBe('OwnerListComponent.onSelect');
    112+});
    113+
    114+test('a line the checkout no longer has is named by file and line, never read into', () => {
    115+ expect(clientMethodOf([{file: COMPONENT_FILE, line: lineOf(').subscribe('), text: 'gone();'}],
    116+ {verb: 'GET', url: URL}, ROOT, read)).toBe(`owner-list.component.ts:${lineOf(').subscribe(')}`);
    117+});
    118+
    119+test('no call site, no self-call', () => {
    120+ expect(clientMethodOf([], {verb: 'GET', url: URL}, ROOT, read)).toBeUndefined();
    121+});
    122+
    123+test('masking keeps every offset and blanks strings and comments', () => {
    124+ const source = `a('x;y') // b(\n/* c( */ d("e")`;
    125+ const masked = maskSource(source);
    126+ expect(masked.length).toBe(source.length);
    127+ expect(masked).toBe(`a(' ') \n d(" ")`);
    128+});
     
    1+import type {CallFrame} from './call-site';
    2+import type {ReadSource} from './code-location';
    3+import {triggerOf} from './call-site';
    4+
    5+/**
    6+ * Which frontend method a browser request was made through: `OwnerService.getOwnerById`.
    7+ *
    8+ * The diagram draws it as a call arriving on the Browser lifeline from the left edge, the
    9+ * request leaving from inside its activation, the way a repository call on the Backend fires
    10+ * its queries (Victor, 10 Nov 2026). The stack cannot say it: `http.get` is a cold Observable, so the
    11+ * service method has returned by the time the request leaves, and the top application frame
    12+ * is the component's `.subscribe(` (see call-site.ts). The source can.
    13+ *
    14+ * From the line that subscribed, the statement it ends is read back, and every
    15+ * `this.<field>.<method>(` in it whose field is typed with a class this app imports is a
    16+ * candidate; a statement that subscribes to a local (`owners$.subscribe(`) is followed to
    17+ * the assignment that filled it. One candidate is the answer. Several — `lastName ?
    18+ * searchOwners(…) : getOwners()` — are told apart by the request itself: the HTTP verb the
    19+ * method's body calls, then which of its string literals the URL carries (`'?lastName='`).
    20+ *
    21+ * No answer is still a name, never a guess: the component method the line sits in
    22+ * (`OwnerListComponent.searchByLastName`), or else the file and line.
    23+ */
    24+
    25+/** The request as the browser's CLIENT span recorded it. */
    26+export interface SentRequest {
    27+ verb?: string;
    28+ url?: string;
    29+}
    30+
    31+/** Blanks strings and comments, keeping every offset, so a scan never matches inside one. */
    32+export function maskSource(source: string): string {
    33+ let out = '';
    34+ let i = 0;
    35+ while (i < source.length) {
    36+ const ch = source[i];
    37+ const next = source[i + 1];
    38+ let end = i + 1;
    39+ if (ch === '/' && next === '/') {
    40+ end = source.indexOf('\n', i);
    41+ if (end < 0) end = source.length;
    42+ } else if (ch === '/' && next === '*') {
    43+ end = source.indexOf('*/', i + 2);
    44+ end = end < 0 ? source.length : end + 2;
    45+ } else if (ch === '\'' || ch === '"' || ch === '`') {
    46+ end = i + 1;
    47+ while (end < source.length && source[end] !== ch) end += source[end] === '\\' ? 2 : 1;
    48+ end = Math.min(end + 1, source.length);
    49+ out += ch + source.slice(i + 1, end - 1).replace(/[^\n]/g, ' ') + (end - 1 > i ? ch : '');
    50+ i = end;
    51+ continue;
    52+ } else {
    53+ out += ch;
    54+ i++;
    55+ continue;
    56+ }
    57+ out += source.slice(i, end).replace(/[^\n]/g, ' ');
    58+ i = end;
    59+ }
    60+ return out;
    61+}
    62+
    63+/** Offset of the first character of a 1-based line. */
    64+function offsetOfLine(source: string, line: number): number {
    65+ let at = 0;
    66+ for (let n = 1; n < line && at >= 0; n++) at = source.indexOf('\n', at) + 1;
    67+ return at;
    68+}
    69+
    70+/**
    71+ * Where the statement that `at` sits in begins: walking back over balanced brackets, it is
    72+ * the character after a `;` or an unmatched opening bracket — the `{` of the function body
    73+ * or the `(` of the callback the statement is nested in.
    74+ */
    75+function statementStart(masked: string, at: number): number {
    76+ let depth = 0;
    77+ for (let i = at - 1; i >= 0; i--) {
    78+ const ch = masked[i];
    79+ if (ch === ')' || ch === ']' || ch === '}') depth++;
    80+ else if (ch === '(' || ch === '[' || ch === '{') {
    81+ if (depth === 0) return i + 1;
    82+ depth--;
    83+ } else if (ch === ';' && depth === 0) return i + 1;
    84+ }
    85+ return 0;
    86+}
    87+
    88+/** Where the statement starting at `from` ends: its `;` or the bracket that closes around it. */
    89+function statementEnd(masked: string, from: number): number {
    90+ let depth = 0;
    91+ for (let i = from; i < masked.length; i++) {
    92+ const ch = masked[i];
    93+ if (ch === '(' || ch === '[' || ch === '{') depth++;
    94+ else if (ch === ')' || ch === ']' || ch === '}') {
    95+ if (depth === 0) return i;
    96+ depth--;
    97+ } else if (ch === ';' && depth === 0) return i;
    98+ }
    99+ return masked.length;
    100+}
    101+
    102+const SERVICE_CALL = /\bthis\s*\.\s*([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]*)\s*(?:<[^()]*?>)?\s*\(/g;
    103+const LOCAL_RECEIVER = /(^|[^\w$.])([A-Za-z_$][\w$]*)\s*\.\s*(?:pipe|subscribe)\s*\(/g;
    104+
    105+interface Candidate {
    106+ type: string;
    107+ method: string;
    108+}
    109+
    110+function callsIn(masked: string, from: number, to: number): Candidate[] {
    111+ const calls: Candidate[] = [];
    112+ for (const m of masked.slice(from, to).matchAll(SERVICE_CALL)) calls.push({type: m[1], method: m[2]});
    113+ return calls;
    114+}
    115+
    116+function escapeRegExp(s: string): string {
    117+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    118+}
    119+
    120+/** The candidates of the statement around `at`, by field name for now. */
    121+function calledFrom(masked: string, at: number): Candidate[] {
    122+ const start = statementStart(masked, at);
    123+ const calls = callsIn(masked, start, at);
    124+ if (calls.length > 0) return calls;
    125+ // `this.load = owners$.subscribe(`: the request was built where the local was filled.
    126+ const lineEnd = masked.indexOf('\n', at);
    127+ for (const m of masked.slice(start, lineEnd < 0 ? masked.length : lineEnd).matchAll(LOCAL_RECEIVER)) {
    128+ const local = m[2];
    129+ if (local === 'this') continue;
    130+ const assigned = new RegExp(`(?:^|[^\\w$.])${escapeRegExp(local)}\\s*=(?!=)`, 'g');
    131+ let last: number | undefined;
    132+ for (const a of masked.slice(0, start).matchAll(assigned)) last = a.index! + a[0].length;
    133+ if (last !== undefined) return callsIn(masked, last, statementEnd(masked, last));
    134+ }
    135+ return [];
    136+}
    137+
    138+/** `ownerService` → `OwnerService`, from its declaration: a constructor parameter, a field, `inject(…)`. */
    139+function typeOfField(masked: string, field: string): string | undefined {
    140+ const name = escapeRegExp(field);
    141+ const typed = new RegExp(`(?:^|[^\\w$.])${name}\\s*[?!]?\\s*:\\s*([A-Z][\\w$]*)`).exec(masked);
    142+ if (typed) return typed[1];
    143+ return new RegExp(`(?:^|[^\\w$.])${name}\\s*=\\s*inject\\s*\\(\\s*([A-Z][\\w$]*)`).exec(masked)?.[1];
    144+}
    145+
    146+/** The file a class is imported from, when the import is this app's own (relative). */
    147+function importedFile(source: string, type: string, fromFile: string): string | undefined {
    148+ for (const m of source.matchAll(/import\s*\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g)) {
    149+ const names = m[1].split(',').map((n) => n.trim().split(/\s+as\s+/).pop());
    150+ if (!names.includes(type) || !m[2].startsWith('.')) continue;
    151+ const parts = fromFile.split('/').slice(0, -1);
    152+ for (const segment of `${m[2]}.ts`.split('/')) {
    153+ if (segment === '..') parts.pop();
    154+ else if (segment !== '.') parts.push(segment);
    155+ }
    156+ return parts.join('/');
    157+ }
    158+ return undefined;
    159+}
    160+
    161+/** The body of `method` as declared in a class's source, or undefined when it is not. */
    162+function bodyOf(source: string, method: string): string | undefined {
    163+ const masked = maskSource(source);
    164+ const declared = new RegExp(
    165+ `^[ \\t]*(?:(?:public|private|protected|async|static|override)\\s+)*${escapeRegExp(method)}`
    166+ + '\\s*(?:<[^()]*?>)?\\s*\\(', 'm').exec(masked);
    167+ if (!declared) return undefined;
    168+ const open = masked.indexOf('{', declared.index + declared[0].length);
    169+ if (open < 0) return undefined;
    170+ let depth = 0;
    171+ for (let i = open; i < masked.length; i++) {
    172+ if (masked[i] === '{') depth++;
    173+ else if (masked[i] === '}' && --depth === 0) return source.slice(open, i + 1);
    174+ }
    175+ return source.slice(open);
    176+}
    177+
    178+const VERB_CALL = /\.\s*(get|post|put|delete|patch|head|options)\s*[<(]/gi;
    179+
    180+/** How well a method's body explains the request: verb first, then literals the URL carries. */
    181+function fit(body: string, request: SentRequest): {verb: boolean; matched: number; missed: number} {
    182+ const verbs = [...maskSource(body).matchAll(VERB_CALL)].map((m) => m[1].toUpperCase());
    183+ const verb = !request.verb || verbs.length === 0 || verbs.includes(request.verb.toUpperCase());
    184+ let matched = 0;
    185+ let missed = 0;
    186+ for (const m of body.matchAll(/'([^'\\\n]*)'|"([^"\\\n]*)"|`([^`$\\]*)`/g)) {
    187+ const literal = m[1] ?? m[2] ?? m[3] ?? '';
    188+ if (literal.length < 2) continue;
    189+ if (request.url?.includes(literal)) matched++;
    190+ else missed++;
    191+ }
    192+ return {verb, matched, missed};
    193+}
    194+
    195+interface Resolved {
    196+ label: string;
    197+ matched: number;
    198+ missed: number;
    199+}
    200+
    201+function resolveCandidates(
    202+ candidates: Candidate[], component: string, componentFile: string, frontendRoot: string,
    203+ readSource: ReadSource, request: SentRequest,
    204+): string | undefined {
    205+ const masked = maskSource(component);
    206+ const resolved: Resolved[] = [];
    207+ const seen = new Set<string>();
    208+ for (const {type: field, method} of candidates) {
    209+ const type = typeOfField(masked, field);
    210+ const file = type ? importedFile(component, type, componentFile) : undefined;
    211+ const source = file ? readSource(`${frontendRoot}/${file}`) : undefined;
    212+ const body = source ? bodyOf(source, method) : undefined;
    213+ if (!type || !body) continue;
    214+ const label = `${type}.${method}`;
    215+ if (seen.has(label)) continue;
    216+ seen.add(label);
    217+ const score = fit(body, request);
    218+ if (score.verb) resolved.push({label, matched: score.matched, missed: score.missed});
    219+ }
    220+ if (resolved.length <= 1) return resolved[0]?.label;
    221+ resolved.sort((a, b) => b.matched - a.matched || a.missed - b.missed);
    222+ const [best, second] = resolved;
    223+ return best.matched !== second.matched || best.missed !== second.missed ? best.label : undefined;
    224+}
    225+
    226+const NOT_A_METHOD = new Set(['if', 'for', 'while', 'switch', 'catch', 'function', 'return', 'constructor']);
    227+const MEMBER_DECL = /^[ \t]*(?:(?:public|private|protected|async|static|override)\s+)*([A-Za-z_$][\w$]*)\s*\([^;]*$/;
    228+
    229+/** `OwnerListComponent.searchByLastName`: the member the line is written in. */
    230+function enclosingMethod(component: string, line: number): string | undefined {
    231+ const lines = maskSource(component).split('\n');
    232+ const cls = /\bclass\s+([A-Za-z_$][\w$]*)/.exec(component)?.[1];
    233+ for (let i = Math.min(line, lines.length) - 1; i >= 0; i--) {
    234+ const m = MEMBER_DECL.exec(lines[i]);
    235+ if (!m || NOT_A_METHOD.has(m[1]) || !/\{\s*$/.test(lines[i])) continue;
    236+ return cls ? `${cls}.${m[1]}` : m[1];
    237+ }
    238+ return undefined;
    239+}
    240+
    241+/**
    242+ * The label of the call that one browser request was made through, or undefined when it has no call site.
    243+ * `frontendRoot` is the frontend module's directory; the frames are relative to it.
    244+ */
    245+export function clientMethodOf(
    246+ frames: CallFrame[], request: SentRequest, frontendRoot: string, readSource: ReadSource,
    247+): string | undefined {
    248+ const trigger = triggerOf(frames);
    249+ if (!trigger) return undefined;
    250+ const fallback = `${trigger.file.split('/').pop()}:${trigger.line}`;
    251+ const component = readSource(`${frontendRoot}/${trigger.file}`);
    252+ if (component === undefined) return fallback;
    253+ const lineText = component.split('\n')[trigger.line - 1];
    254+ // A line the checkout no longer has is not the line that ran: no reading into it.
    255+ if (lineText === undefined || (trigger.text !== undefined && lineText.trim() !== trigger.text)) {
    256+ return fallback;
    257+ }
    258+ const masked = maskSource(component);
    259+ const lineStart = offsetOfLine(component, trigger.line);
    260+ const subscribed = masked.slice(lineStart, lineStart + lineText.length).lastIndexOf('subscribe');
    261+ const at = lineStart + (subscribed >= 0 ? subscribed : lineText.length);
    262+ const candidates = calledFrom(masked, at);
    263+ return resolveCandidates(candidates, component, trigger.file, frontendRoot, readSource, request)
    264+ ?? enclosingMethod(component, trigger.line) ?? fallback;
    265+}
     
    11 import {test, expect} from '@playwright/test';
    22 import {
    3- METHOD_LINK_MARKER, lineOfMethod, linkedMethodLabel, methodHandle, sourcePathOf,
    3+ METHOD_LINK_MARKER, lineOfMethod, linkedMethodLabel, methodHandle, overloadOf, sourcePathOf,
    44 } from './code-location';
    55
    66 const CONTROLLER = `package victor.training.petclinic.rest;
    test('the marker rides inside the link, so the whole label is the target', () =>
    102102 test('without a handle the label is exactly what it always was', () => {
    103103 expect(linkedMethodLabel('book-visit', undefined)).toBe('book-visit');
    104104 });
    105+
    106+// petclinic-commons is linked into both services, so a span opened inside it carries the
    107+// service that loaded it — and its class lives under neither service's folder.
    108+test('a class from the shared library links into the library, whichever service ran it', () => {
    109+ const handle = methodHandle(
    110+ {'code.namespace': 'victor.training.commons.PhoneNumbers', 'code.function': 'normalize'},
    111+ 'notification-service', '/repo', (p) => (p ===
    112+ '/repo/petclinic-commons/src/main/java/victor/training/commons/PhoneNumbers.java'
    113+ ? 'class PhoneNumbers {\n static String normalize(String phone) {' : undefined));
    114+ expect(handle).toBe('src://petclinic-commons/src/main/java/victor/training/commons/PhoneNumbers'
    115+ + '.java:2{Click to open PhoneNumbers.normalize}');
    116+});
    117+
    118+// Two methods named `normalize`: the javadoc of the second even names the first, and the
    119+// second calls the first. Each arrow has to open the overload it drew.
    120+const PHONE_NUMBERS = `package victor.training.commons;
    121+
    122+public final class PhoneNumbers {
    123+ /** {@code +1 (608) 555-1023} → {@code +16085551023}. */
    124+ public static String normalize(String phone) {
    125+ return phone;
    126+ }
    127+
    128+ /** Like {@link #normalize(String)}, but a national number is given the caller's dial code. */
    129+ public static String normalize(String phone,
    130+ HomeCountry homeCountry) {
    131+ String normalized = normalize(phone);
    132+ return homeCountry.homeDialCode() + normalized;
    133+ }
    134+}
    135+`;
    136+const PHONE_NUMBERS_PATH =
    137+ '/repo/petclinic-commons/src/main/java/victor/training/commons/PhoneNumbers.java';
    138+const phoneHandle = (attributes: Record<string, string>) => methodHandle(
    139+ {'code.namespace': 'victor.training.commons.PhoneNumbers', 'code.function': 'normalize',
    140+ ...attributes},
    141+ 'petclinic-backend', '/repo', (p) => (p === PHONE_NUMBERS_PATH ? PHONE_NUMBERS : undefined));
    142+
    143+test('an overload is found by the parameter types the span was stamped with', () => {
    144+ expect(phoneHandle({'boundary.parameters': 'String, HomeCountry'})).toContain('PhoneNumbers.java:10{');
    145+ expect(phoneHandle({'boundary.parameters': 'String'})).toContain('PhoneNumbers.java:5{');
    146+});
    147+
    148+// A span recorded before the types were stamped still printed its arguments.
    149+test('without stamped types, the number of arguments picks the overload', () => {
    150+ expect(phoneHandle({'boundary.args': '[8474461990, victor.training.X@64fb61b]'}))
    151+ .toContain('PhoneNumbers.java:10{');
    152+ expect(phoneHandle({'boundary.args': '[+18474461990]'})).toContain('PhoneNumbers.java:5{');
    153+});
    154+
    155+test('with nothing to tell overloads apart, the first one by name is the answer', () => {
    156+ expect(phoneHandle({})).toContain('PhoneNumbers.java:5{');
    157+});
    158+
    159+test('a record or a list among the arguments counts as one', () => {
    160+ expect(overloadOf({'boundary.args': '[Owner[id=1, name=x], [a, b], 3]'})).toEqual({arity: 3});
    161+ expect(overloadOf({'boundary.args': '[]'})).toEqual({arity: 0});
    162+});
    163+
    164+test('parameter types are compared erased and unqualified, annotations ignored', () => {
    165+ const source = [
    166+ 'class C {',
    167+ ' void put(int id) {}',
    168+ ' void put(@RequestBody final java.util.Map<String, List<Integer>> byId, String... rest) {}',
    169+ '}',
    170+ ].join('\n');
    171+ expect(lineOfMethod(source, 'put', {types: ['Map', 'String[]']})).toBe(3);
    172+ expect(lineOfMethod(source, 'put', {types: ['int']})).toBe(2);
    173+});
    export function sourcePathOf(serviceName: string, namespace: string): string | u
    5050 return `${serviceName}/src/main/java/${topLevel.split('.').join('/')}.java`;
    5151 }
    5252
    53+/**
    54+ * Jars linked into every service. A span opened inside one carries the service that loaded
    55+ * it, so its class is looked for there first and then here.
    56+ */
    57+export const LIBRARY_MODULES = ['petclinic-commons'];
    58+
    5359 /** A call, not a declaration: `x.foo(`, `y = foo(`, `return foo(`, `foo(bar(`. */
    5460 const CALLED_NOT_DECLARED = /[.=(,]\s*$/;
    5561
    62+/**
    63+ * Which of a method's overloads ran, as the span knows it.
    64+ *
    65+ * `types` is the real answer: the simple names of the declared parameter types, which
    66+ * petclinic-commons' boundary aspect stamps as `boundary.parameters` (`String, HomeCountry`).
    67+ * `arity` is the next best, counted from the arguments the same aspect printed — a span
    68+ * recorded before the types were stamped still tells `normalize(phone)` from
    69+ * `normalize(phone, country)` by how many values went in.
    70+ */
    71+export interface Overload {
    72+ types?: string[];
    73+ arity?: number;
    74+}
    75+
    5676 /**
    5777 * The 1-based line `fn` is declared on, or 0 when the source never declares it.
    5878 *
    const CALLED_NOT_DECLARED = /[.=(,]\s*$/;
    6080 * Spring Data base interface declares, say — and the honest one: the class is still
    6181 * where the reader wants to land, and the line would be a guess.
    6282 *
    63- * The scan is deliberately as small as the one in test-location.ts. It takes the first
    83+ * The scan is deliberately as small as the one in test-location.ts. A declaration is a
    6484 * line that mentions the name in a position a declaration can occupy, which rules out the
    65- * two shapes that actually occur above a declaration in this codebase: the method being
    66- * called on a receiver, and the method being called to fill a variable or a return. It is
    67- * not a Java parser and does not try to be one; a wrong line inside the right file is a
    68- * cheap mistake, and no line at all is the fallback.
    85+ * shapes that actually occur above a declaration in this codebase: the method being
    86+ * called on a receiver, called to fill a variable or a return, or named in a comment
    87+ * (`{@link #normalize(String)}`). It is not a Java parser and does not try to be one.
    88+ *
    89+ * With overloads, the name alone lands on the first of them — `PhoneNumbers.normalize`
    90+ * once opened the 1-arg method for an arrow that had called the 2-arg one. So when the
    91+ * span says which overload ran, the declaration whose parameters match it wins; failing
    92+ * that, the first one by name.
    6993 */
    70-export function lineOfMethod(source: string, fn: string): number {
    94+export function lineOfMethod(source: string, fn: string, overload: Overload = {}): number {
    7195 const mention = new RegExp(`(^|[^\\w$.])${escapeRegExp(fn)}\\s*\\(`);
    7296 const lines = source.split(/\r?\n/);
    97+ const declared: {line: number; types: string[]}[] = [];
    7398 for (let i = 0; i < lines.length; i++) {
    7499 const at = lines[i].search(mention);
    75100 if (at < 0) continue;
    76101 const before = lines[i].slice(0, at + (lines[i][at] === fn[0] ? 0 : 1));
    77102 if (CALLED_NOT_DECLARED.test(before)) continue;
    78103 if (/^\s*(?:return|throw)\b/.test(lines[i])) continue;
    79- return i + 1;
    104+ if (/^\s*(?:\/\/|\/\*|\*)/.test(lines[i])) continue;
    105+ declared.push({line: i + 1, types: parameterTypesFrom(lines, i, lines[i].indexOf('(', at))});
    80106 }
    81- return 0;
    107+ const {types, arity} = overload;
    108+ const exact = types && declared.find((d) => sameTypes(d.types, types));
    109+ const counted = arity !== undefined ? declared.find((d) => d.types.length === arity) : undefined;
    110+ return (exact || counted || declared[0])?.line ?? 0;
    82111 }
    83112
    113+/**
    114+ * The parameter types of the declaration whose `(` is at `lines[row][column]`, as simple
    115+ * names: `(@RequestBody final Map<String, Integer> m, String... rest)` → `Map`, `String[]`.
    116+ * Erased and unqualified, which is what `Class.getSimpleName()` gives on the other side.
    117+ */
    118+export function parameterTypesFrom(lines: string[], row: number, column: number): string[] {
    119+ let text = '';
    120+ let depth = 0;
    121+ for (let i = row; i < lines.length; i++) {
    122+ for (let j = i === row ? column : 0; j < lines[i].length; j++) {
    123+ const ch = lines[i][j];
    124+ if (ch === '(') depth++;
    125+ else if (ch === ')' && --depth === 0) return splitParameters(text.slice(1));
    126+ text += ch;
    127+ }
    128+ text += ' ';
    129+ }
    130+ return [];
    131+}
    132+
    133+function splitParameters(list: string): string[] {
    134+ const cleaned = list.replace(/@[\w.]+(\s*\([^)]*\))?/g, ' ');
    135+ const params: string[] = [];
    136+ let depth = 0;
    137+ let current = '';
    138+ for (const ch of cleaned) {
    139+ if (ch === '<') depth++;
    140+ if (ch === '>') depth--;
    141+ if (ch === ',' && depth === 0) {
    142+ params.push(current);
    143+ current = '';
    144+ } else {
    145+ current += ch;
    146+ }
    147+ }
    148+ params.push(current);
    149+ return params.map(simpleTypeOf).filter((t) => t !== '');
    150+}
    151+
    152+/** `final java.util.Map<K, V> byId` → `Map`; `String... rest` → `String[]`. */
    153+function simpleTypeOf(parameter: string): string {
    154+ let generic = parameter;
    155+ while (/<[^<>]*>/.test(generic)) generic = generic.replace(/<[^<>]*>/g, '');
    156+ const words = generic.replace(/\bfinal\b/g, ' ').replace(/\.\.\./g, '[] ')
    157+ .replace(/\s*\[\s*\]/g, '[]').trim().split(/\s+/);
    158+ if (words.length < 2) return '';
    159+ const type = words.slice(0, -1).join('');
    160+ return type.slice(type.lastIndexOf('.') + 1);
    161+}
    162+
    163+function sameTypes(declared: string[], ran: string[]): boolean {
    164+ return declared.length === ran.length && declared.every((t, i) => t === ran[i]);
    165+}
    166+
    167+/**
    168+ * The overload a span names: its stamped parameter types when it has them, else the
    169+ * number of arguments it printed. `[8474461990, a.B@64fb61b]` is two; brackets nest, so a
    170+ * record's `Owner[id=1, name=x]` is one.
    171+ */
    172+export function overloadOf(attributes: Record<string, string>): Overload {
    173+ const stamped = attributes[PARAMETERS_ATTRIBUTE];
    174+ if (stamped !== undefined) {
    175+ return {types: stamped.split(',').map((t) => t.trim()).filter((t) => t !== '')};
    176+ }
    177+ const printed = attributes['boundary.args'];
    178+ if (printed === undefined || !/^\[.*\]$/s.test(printed)) return {};
    179+ const inner = printed.slice(1, -1);
    180+ if (inner.trim() === '') return {arity: 0};
    181+ let depth = 0;
    182+ let arity = 1;
    183+ for (const ch of inner) {
    184+ if (ch === '[' || ch === '(' || ch === '{') depth++;
    185+ else if (ch === ']' || ch === ')' || ch === '}') depth--;
    186+ else if (ch === ',' && depth === 0) arity++;
    187+ }
    188+ return {arity};
    189+}
    190+
    191+/** Stamped by petclinic-commons' ModuleBoundaryTracing: `String, HomeCountry`. */
    192+export const PARAMETERS_ATTRIBUTE = 'boundary.parameters';
    193+
    84194 function escapeRegExp(s: string): string {
    85195 return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    86196 }
    export function methodHandle(
    104214 const namespace = attributes['code.namespace'];
    105215 const fn = attributes['code.function'];
    106216 if (!namespace || !fn) return undefined;
    107- const relative = sourcePathOf(serviceName, namespace);
    108- if (!relative) return undefined;
    109- const text = readSource(`${repoRoot}/${relative}`);
    110- if (text === undefined) return undefined;
    111- const line = lineOfMethod(text, fn);
    112- return `src://${relative}${line > 0 ? `:${line}` : ''}${methodLinkTooltip(namespace, fn)}`;
    217+ for (const module of [serviceName, ...LIBRARY_MODULES]) {
    218+ const relative = sourcePathOf(module, namespace);
    219+ const text = relative ? readSource(`${repoRoot}/${relative}`) : undefined;
    220+ if (text === undefined) continue;
    221+ const line = lineOfMethod(text, fn, overloadOf(attributes));
    222+ return `src://${relative}${line > 0 ? `:${line}` : ''}${methodLinkTooltip(namespace, fn)}`;
    223+ }
    224+ return undefined;
    113225 }
    114226
    115227 /**
     
    1+import {expect, test} from '@playwright/test';
    2+import type {NormSpan} from './trace-to-puml';
    3+import {handlerHandle, handlerOf, moduleHandlers, routeOf} from './controller-location';
    4+
    5+// A request's arrow opens the controller method it landed on, resolved by its route against
    6+// the service's sources: the trace carries the route and no code.
    7+
    8+const OWNERS = `package victor.training.petclinic.rest;
    9+
    10+@RestController
    11+@RequestMapping("/api/owners")
    12+@PreAuthorize("hasRole(@roles.OWNER_ADMIN)")
    13+public class OwnerRestController {
    14+
    15+ @Operation(operationId = "listOwners", summary = "List owners")
    16+ @ApiResponse(responseCode = "200", description = "OK",
    17+ content = @Content(mediaType = "application/json",
    18+ array = @ArraySchema(schema = @Schema(implementation = OwnerDto.class))))
    19+ @GetMapping(produces = "application/json")
    20+ public List<OwnerDto> listOwners(@RequestParam String lastName) {
    21+ return null;
    22+ }
    23+
    24+ @GetMapping("/{ownerId}")
    25+ public OwnerDto getOwner(@PathVariable int ownerId) {
    26+ return null;
    27+ }
    28+
    29+ @PostMapping("{ownerId}/pets/{petId}/visits")
    30+ public ResponseEntity<VisitDto> addVisitToOwner(@PathVariable int ownerId) {
    31+ return null;
    32+ }
    33+
    34+ @RequestMapping(value = "/legacy", method = {RequestMethod.PUT, RequestMethod.PATCH})
    35+ public void legacy() {
    36+ }
    37+}
    38+`;
    39+
    40+const NOTIFICATIONS = `package victor.training.notification;
    41+
    42+@RestController
    43+public class NotificationController {
    44+ @PostMapping(VisitBookedNotification.PATH)
    45+ public void visitBooked(@RequestBody VisitBookedNotification n) {
    46+ }
    47+}
    48+`;
    49+
    50+// The shared constant lives in a library linked into both the service and its client.
    51+const SHARED = `package victor.training.commons;
    52+public record VisitBookedNotification(String phone) {
    53+ public static final String PATH = "/api/notifications/visit-booked";
    54+}
    55+`;
    56+
    57+const REPO = '/repo';
    58+const JAVA = (module: string) => `${REPO}/${module}/src/main/java/victor/training`;
    59+const files: Record<string, string> = {
    60+ [`${JAVA('petclinic-backend')}/petclinic/rest/OwnerRestController.java`]: OWNERS,
    61+ [`${JAVA('notification-service')}/notification/NotificationController.java`]: NOTIFICATIONS,
    62+ [`${JAVA('petclinic-commons')}/commons/VisitBookedNotification.java`]: SHARED,
    63+};
    64+const read = (p: string) => files[p];
    65+const walk = (dir: string) => Object.keys(files).filter((f) => f.startsWith(`${dir}/`));
    66+const lineOf = (source: string, needle: string) =>
    67+ source.split('\n').findIndex((l) => l.includes(needle)) + 1;
    68+
    69+test('a method mapping joins its class’s base path, past every annotation between them', () => {
    70+ const handlers = moduleHandlers('petclinic-backend', REPO, read, walk);
    71+ const file = 'petclinic-backend/src/main/java/victor/training/petclinic/rest/OwnerRestController.java';
    72+ expect(handlers.get('GET /api/owners')).toEqual(
    73+ {file, line: lineOf(OWNERS, 'listOwners(@'), name: 'OwnerRestController.listOwners'});
    74+ expect(handlers.get('GET /api/owners/{ownerId}')?.name).toBe('OwnerRestController.getOwner');
    75+ expect(handlers.get('POST /api/owners/{ownerId}/pets/{petId}/visits')?.line)
    76+ .toBe(lineOf(OWNERS, 'addVisitToOwner('));
    77+ expect(handlers.get('PUT /api/owners/legacy')?.name).toBe('OwnerRestController.legacy');
    78+ expect(handlers.get('PATCH /api/owners/legacy')?.name).toBe('OwnerRestController.legacy');
    79+});
    80+
    81+test('a route named by a constant in a shared library resolves', () => {
    82+ const handlers = moduleHandlers('notification-service', REPO, read, walk);
    83+ expect(handlers.get('POST /api/notifications/visit-booked')?.name)
    84+ .toBe('NotificationController.visitBooked');
    85+});
    86+
    87+const server = (attributes: Record<string, string>, name = 'GET /api/owners/{ownerId}'): NormSpan => ({
    88+ traceId: 't', spanId: 's', parentSpanId: 'c', name, kind: 'SERVER',
    89+ serviceName: 'petclinic-backend', startNano: 0, attributes,
    90+});
    91+
    92+test('the route comes from the span’s attributes, else from its name', () => {
    93+ expect(routeOf(server({'http.request.method': 'GET', 'http.route': '/api/owners/{ownerId}'})))
    94+ .toBe('GET /api/owners/{ownerId}');
    95+ expect(routeOf(server({}))).toBe('GET /api/owners/{ownerId}');
    96+ expect(routeOf(server({}, 'GET'))).toBeUndefined();
    97+});
    98+
    99+test('the handle opens the declaration and says which method it is', () => {
    100+ const handlers = moduleHandlers('petclinic-backend', REPO, read, walk);
    101+ expect(handlerHandle(server({}), handlers)).toBe(
    102+ 'src://petclinic-backend/src/main/java/victor/training/petclinic/rest/OwnerRestController.java:'
    103+ + `${lineOf(OWNERS, 'getOwner(@')}{Click to open OwnerRestController.getOwner}`);
    104+ expect(handlerHandle(server({}, 'GET /api/nowhere'), handlers)).toBeUndefined();
    105+ expect(handlerHandle({...server({}), kind: 'CLIENT'}, handlers)).toBeUndefined();
    106+});
    107+
    108+// The arrow names the controller as well as linking it: a third line, or a self-call.
    109+test('the handler is named as the diagram says it, beside its handle', () => {
    110+ const handlers = moduleHandlers('petclinic-backend', REPO, read, walk);
    111+ expect(handlerOf(server({}), handlers)).toEqual({
    112+ name: 'OwnerRestController.getOwner', handle: handlerHandle(server({}), handlers),
    113+ });
    114+ expect(handlerOf(server({}, 'GET /api/nowhere'), handlers)).toBeUndefined();
    115+});
     
    1+import type {NormSpan} from './trace-to-puml';
    2+import type {ReadSource} from './code-location';
    3+import {LIBRARY_MODULES, methodLinkTooltip} from './code-location';
    4+
    5+/**
    6+ * The method a request lands on, for the arrow that carries it.
    7+ *
    8+ * A SERVER span knows its route (`GET /api/owners/{ownerId}`) and nothing about the code
    9+ * that answered it: the agent opens no span for a controller method. So the route is
    10+ * resolved against the service's own sources, the way Spring resolves it — the class-level
    11+ * `@RequestMapping` joined to the method's `@GetMapping` — and the arrow's words open the
    12+ * first class of the application the request reaches (Victor, 10 Nov 2026).
    13+ *
    14+ * A line scan, not a Java parser: the three things it needs — a class's base path, a
    15+ * method's mapping annotation, and the declaration under it — are each one line of Java,
    16+ * and a mapping annotation appears nowhere else. The same scan the review page runs for its
    17+ * detail panel's head (human-review's `spring_handlers`), so the two never disagree.
    18+ */
    19+
    20+/** Lists every file under a directory, recursively, as absolute paths. */
    21+export type WalkFiles = (dir: string) => string[];
    22+
    23+export interface Handler {
    24+ /** Repo-relative: `petclinic-backend/src/main/java/…/OwnerRestController.java`. */
    25+ file: string;
    26+ line: number;
    27+ /** `OwnerRestController.getOwner`. */
    28+ name: string;
    29+}
    30+
    31+const HTTP_VERBS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'];
    32+const MAPPING = /^@(Get|Post|Put|Delete|Patch|Request)Mapping\b/;
    33+const REQUEST_METHOD = /RequestMethod\.(GET|POST|PUT|DELETE|PATCH)\b/g;
    34+const NAMED_PATH = /\b(?:value|path)\s*=\s*\{?\s*"([^"]*)"/;
    35+const POSITIONAL_PATH = /^\s*\{?\s*"([^"]*)"/;
    36+const NAMED_CONSTANT = /\b(?:value|path)\s*=\s*\{?\s*([A-Za-z_][\w.]*)/;
    37+const POSITIONAL_CONSTANT = /^\s*\{?\s*([A-Za-z_][\w.]*)\s*(?:[,}]|$)/;
    38+const TYPE_DECL = /\b(?:class|interface|record)\s+([A-Za-z_]\w*)/;
    39+const METHOD_NAME = /([A-Za-z_]\w*)\s*\(/;
    40+const STRING_CONSTANT = /static\s+final\s+String\s+([A-Za-z_]\w*)\s*=\s*"([^"]*)"/g;
    41+
    42+/** The parenthesised text of the annotation that starts on `lines[row]`, and its last row. */
    43+function annotationArgs(lines: string[], row: number): {args: string; end: number} {
    44+ let text = '';
    45+ let depth = 0;
    46+ let started = false;
    47+ for (let j = row; j < lines.length; j++) {
    48+ for (const ch of lines[j]) {
    49+ if (ch === '(') {
    50+ depth++;
    51+ started = true;
    52+ if (depth === 1) continue;
    53+ } else if (ch === ')' && --depth === 0) {
    54+ return {args: text, end: j};
    55+ }
    56+ if (started) text += ch;
    57+ }
    58+ // A bare `@GetMapping` ends on its own line; one whose `(` wraps goes on.
    59+ if (!started) return {args: '', end: j};
    60+ text += ' ';
    61+ }
    62+ return {args: text, end: lines.length - 1};
    63+}
    64+
    65+function mappingPath(args: string, constants: Map<string, string>): string {
    66+ const literal = NAMED_PATH.exec(args) ?? POSITIONAL_PATH.exec(args);
    67+ if (literal) return literal[1];
    68+ // `@PostMapping(VisitBookedNotification.PATH)`: a route the service and its client share.
    69+ const ref = NAMED_CONSTANT.exec(args) ?? POSITIONAL_CONSTANT.exec(args);
    70+ if (!ref) return '';
    71+ return constants.get(ref[1]) ?? constants.get(ref[1].split('.').pop()!) ?? '';
    72+}
    73+
    74+/** `{ownerId:\d+}` is `{ownerId}` on the span: Spring reports the template, not the regex. */
    75+function normalizeRoute(route: string): string {
    76+ const parts = route.replace(/\{([^}:]+):[^}]*\}/g, '{$1}').split('/').filter((p) => p !== '');
    77+ return `/${parts.join('/')}`;
    78+}
    79+
    80+/** Every `VERB /route` one controller source handles, into `found`. */
    81+export function controllerRoutes(
    82+ source: string, file: string, constants: Map<string, string>, found: Map<string, Handler>,
    83+): void {
    84+ const lines = source.split(/\r?\n/);
    85+ let base = '';
    86+ let cls = file.split('/').pop()!.replace(/\.java$/, '');
    87+ let pending: {verbs: string[]; path: string} | undefined;
    88+ for (let i = 0; i < lines.length; i++) {
    89+ const stripped = lines[i].trim();
    90+ if (!stripped || /^(?:\/\/|\/\*|\*)/.test(stripped)) continue;
    91+ if (stripped.startsWith('@')) {
    92+ const {args, end} = annotationArgs(lines, i);
    93+ const mapping = MAPPING.exec(stripped);
    94+ if (mapping) {
    95+ const verbs = mapping[1] !== 'Request' ? [mapping[1].toUpperCase()]
    96+ : [...args.matchAll(REQUEST_METHOD)].map((m) => m[1]);
    97+ pending = {verbs: verbs.length > 0 ? verbs : HTTP_VERBS, path: mappingPath(args, constants)};
    98+ }
    99+ // Annotations pile up until a declaration consumes them: `@Operation`, `@PreAuthorize`
    100+ // and a four-line `@ApiResponse` between a mapping and its method cost nothing.
    101+ i = end;
    102+ continue;
    103+ }
    104+ const declared = TYPE_DECL.exec(stripped);
    105+ if (declared) {
    106+ cls = declared[1];
    107+ base = pending?.path ?? '';
    108+ pending = undefined;
    109+ continue;
    110+ }
    111+ const name = pending ? METHOD_NAME.exec(stripped) : null;
    112+ if (name && pending) record(found, pending, base, file, i + 1, `${cls}.${name[1]}`);
    113+ pending = undefined;
    114+ }
    115+}
    116+
    117+function record(
    118+ found: Map<string, Handler>, pending: {verbs: string[]; path: string}, base: string,
    119+ file: string, line: number, name: string,
    120+): void {
    121+ for (const verb of pending.verbs) {
    122+ const key = `${verb} ${normalizeRoute(`${base}/${pending.path}`)}`;
    123+ if (!found.has(key)) found.set(key, {file, line, name});
    124+ }
    125+}
    126+
    127+/** `VERB /route` → the method that handles it, for every controller of one module. */
    128+export function moduleHandlers(
    129+ module: string, repoRoot: string, readSource: ReadSource, walk: WalkFiles,
    130+): Map<string, Handler> {
    131+ const constants = new Map<string, string>();
    132+ const controllers: Array<{file: string; text: string}> = [];
    133+ // The jars linked into every service are read for their constants too: a route a service
    134+ // and its client share (`VisitBookedNotification.PATH`) is declared in petclinic-commons.
    135+ const sources = [module, ...LIBRARY_MODULES.filter((m) => m !== module)].flatMap((m) =>
    136+ walk(`${repoRoot}/${m}/src/main/java`).filter((f) => f.endsWith('.java')).sort()
    137+ .map((absolute) => ({absolute, own: m === module})));
    138+ for (const {absolute, own} of sources) {
    139+ const text = readSource(absolute);
    140+ if (text === undefined) continue;
    141+ const file = absolute.slice(repoRoot.length + 1);
    142+ if (text.includes('static final String')) {
    143+ const cls = file.split('/').pop()!.replace(/\.java$/, '');
    144+ for (const m of text.matchAll(STRING_CONSTANT)) {
    145+ constants.set(`${cls}.${m[1]}`, m[2]);
    146+ if (!constants.has(m[1])) constants.set(m[1], m[2]);
    147+ }
    148+ }
    149+ if (own && /@(?:Rest)?Controller\b/.test(text)) controllers.push({file, text});
    150+ }
    151+ // Second, so a mapping may name a constant declared in a file the walk reached later.
    152+ const found = new Map<string, Handler>();
    153+ for (const {file, text} of controllers) controllerRoutes(text, file, constants, found);
    154+ return found;
    155+}
    156+
    157+/** `GET /api/owners/{ownerId}`, from the span's own attributes before its name. */
    158+export function routeOf(span: NormSpan): string | undefined {
    159+ const named = /^([A-Z]+)\s+(\/\S*)$/.exec(span.name.trim());
    160+ const verb = span.attributes['http.request.method'] ?? span.attributes['http.method'] ?? named?.[1];
    161+ const route = span.attributes['http.route'] ?? named?.[2];
    162+ return verb && route ? `${verb.toUpperCase()} ${normalizeRoute(route)}` : undefined;
    163+}
    164+
    165+/** The method a SERVER span's request landed on — its name and its `src://` handle — or undefined. */
    166+export function handlerOf(
    167+ span: NormSpan, handlers: Map<string, Handler>,
    168+): {name: string; handle: string} | undefined {
    169+ const route = span.kind === 'SERVER' ? routeOf(span) : undefined;
    170+ const hit = route ? handlers.get(route) : undefined;
    171+ if (!hit) return undefined;
    172+ const [cls, fn] = [hit.name.slice(0, hit.name.lastIndexOf('.')), hit.name.split('.').pop()!];
    173+ return {name: hit.name, handle: `src://${hit.file}:${hit.line}${methodLinkTooltip(cls, fn)}`};
    174+}
    175+
    176+/** The `src://` handle on the method a SERVER span's request landed on, or undefined. */
    177+export function handlerHandle(span: NormSpan, handlers: Map<string, Handler>): string | undefined {
    178+ return handlerOf(span, handlers)?.handle;
    179+}
    test('an id is plain lowercase alphanumerics', () => {
    3131 const collector = new DetailCollector();
    3232 expect(collector.add(entry('select 1'))).toMatch(/^[a-z0-9]{7,}$/);
    3333 });
    34+
    35+// Version 3: a request arrow's entry carries the request, for the review page to replay.
    36+test('an entry carries the request it was sent with, through the index', () => {
    37+ const collector = new DetailCollector();
    38+ const request = {method: 'POST', url: 'http://h/api/visits', headers: {Accept: '*/*'}, body: '{"a":1}'};
    39+ const id = collector.add({...entry('POST http://h/api/visits'), request});
    40+ expect(DETAIL_INDEX_VERSION).toBe(3);
    41+ expect(collector.toIndex().details[id].request).toEqual(request);
    42+});
    export interface DetailStep {
    1515 alternate?: DetailStep;
    1616 }
    1717
    18+/**
    19+ * An HTTP request as it was sent, for a panel to offer to send again. The steps already say it
    20+ * to a reader; this says it to a tool — the review page's "Open in Bruno" replays the call from
    21+ * it (Victor, 10 Nov 2026), so nothing here is a template: `url` is the absolute URL with the
    22+ * real ids in it, and `body` the payload exactly as it went out, not pretty-printed.
    23+ */
    24+export interface HttpRequest {
    25+ method: string;
    26+ url: string;
    27+ headers?: Record<string, string>;
    28+ body?: string;
    29+}
    30+
    1831 /** Everything one arrow can reveal, in the order the clicks reveal it. */
    1932 export interface DetailEntry {
    2033 title: string;
    2134 steps: DetailStep[];
    35+ /** On an HTTP request arrow: the request itself, in a shape a client can replay. */
    36+ request?: HttpRequest;
    2237 }
    2338
    2439 export interface DetailIndex {
    export interface DetailIndex {
    2641 details: Record<string, DetailEntry>;
    2742 }
    2843
    29-export const DETAIL_INDEX_VERSION = 2; // 2: a step can carry an `alternate` to toggle to
    44+// 2: a step can carry an `alternate` to toggle to.
    45+// 3: an HTTP request arrow's entry carries the `request` it sent, for the page to replay.
    46+export const DETAIL_INDEX_VERSION = 3;
    3047
    3148 // FNV-1a. The id has to depend on the arrow's *content* alone: a counter would
    3249 // renumber every arrow below an inserted one, and the review page renders a
     
    11 import {test, expect} from '@playwright/test';
    22 import {parseTempoTrace} from './trace-to-puml';
    33 import {DETAIL_INDEX_VERSION} from './detail-index';
    4+import {DEFAULT_DIAGRAM_OPTIONS} from './options';
    45 import * as fs from 'fs';
    56 import * as path from 'path';
    67 import {CachedSource, GenerateDeps, JAVA_SOURCES, TestWindow, detailsPathFor, diagramPathFor, generateFromWindows, mergeCachedSources, ownedSourcesFromEnv, renderScenarios, slugify, spanCachePathFor, uniqueSlugs} from './generate';
    test('what the markers reveal is filed beside the diagram, and is not itself a d
    5960 [{source: 'src/add-visit.spec.ts',
    6061 scenarios: [{title: 'Add a visit', traces: [parseTempoTrace(fixture)]}]}],
    6162 '/out', {writeFile: (p, c) => { written[p] = c; }, log: () => {}},
    62- {sql: 'statement', httpBodies: true, interactive: true},
    63+ {sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'},
    6364 );
    6465
    6566 expect(detailsPathFor('/out', 'src/add-visit.spec.ts', 'add-a-visit'))
    test('the static path writes the picture alone, and drops a sidecar left by an e
    8384 scenarios: [{title: 'Add a visit', traces: [parseTempoTrace(fixture)]}]}],
    8485 '/out',
    8586 {writeFile: (p, c) => { written[p] = c; }, removeFile: (p) => removed.push(p), log: () => {}},
    86- {sql: 'values', httpBodies: true, interactive: false},
    87+ {sql: 'values', httpBodies: true, interactive: false, handler: 'panel'},
    8788 );
    8889 expect(Object.keys(written)).toEqual(['/out/generated/add-visit.spec.ts.add-a-visit.genseq.puml']);
    8990 expect(removed).toEqual(['/out/generated/add-visit.spec.ts.add-a-visit.genseq.json']);
    test('a diagram no scenario draws any more is swept', () => {
    110111 ],
    111112 log: () => {},
    112113 },
    113- {sql: 'statement', httpBodies: true, interactive: true},
    114+ {sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'},
    114115 );
    115116 expect(removed).toEqual([
    116117 '/out/generated/add-visit.spec.ts.cancel-a-visit.genseq.puml',
    test('generateFromWindows writes one puml per scenario', async () => {
    141142 const addVisit = written['/out/generated/add-visit.spec.ts.add-a-visit.genseq.puml'];
    142143 // The scenario names the picture; the file it lives in is in the footer, and the
    143144 // divider that used to separate chapters is gone with the chapters.
    144- expect(addVisit).toContain('title Add a visit');
    145+ expect(addVisit).toContain('title Test: Add a visit');
    145146 expect(addVisit).not.toContain('== ');
    146147 // …and the other scenario is a picture of its own, not a section further down this one
    147148 expect(addVisit).not.toContain('Cancel a visit');
    148149 expect(written['/out/generated/add-visit.spec.ts.cancel-a-visit.genseq.puml'])
    149- .toContain('title Cancel a visit');
    150+ .toContain('title Test: Cancel a visit');
    150151 // the arrow is wrapped in its reveal link now, so match the label inside it
    151152 expect(addVisit).toContain('addVisit\\nPOST /api/visits');
    152153 expect(addVisit).toContain("' ⚠️ GENERATED FILE — DO NOT EDIT");
    test('each section header links to the test that drew it', () => {
    167168 },
    168169 );
    169170 expect(written['/out/petclinic-test/generated/add-visit.spec.ts.add-a-visit.genseq.puml']).toContain(
    170- 'title [[src://petclinic-test/src/add-visit.spec.ts:1{Click to open the test} Add a visit]]');
    171+ 'title [[src://petclinic-test/src/add-visit.spec.ts:1{Click to open the test} Test: Add a visit]]');
    171172 });
    172173
    173174 // Nothing to read the source with means nothing to point at: a header that links into a
    test('a header stays plain when the test source cannot be read', () => {
    180181 '/out', {writeFile: (p, c) => { written[p] = c; }, log: () => {}},
    181182 );
    182183 expect(written['/out/generated/add-visit.spec.ts.add-a-visit.genseq.puml'])
    183- .toContain('title Add a visit');
    184+ .toContain('title Test: Add a visit');
    184185 });
    185186
    186187 test('generateFromWindows skips (no throw) when a test has zero traces', async () => {
    test('renderScenarios redraws from the cache at another detail level, touching n
    325326 }]));
    326327
    327328 renderScenarios(cached, '/out', {writeFile: (p, c) => { written[p] = c; }, log: () => {}},
    328- {sql: 'off', httpBodies: false, interactive: false});
    329+ {sql: 'off', httpBodies: false, interactive: false, handler: 'panel'});
    329330
    330331 const puml = written['/out/generated/add-visit.spec.ts.add-a-visit.genseq.puml'];
    331- expect(puml).toContain('title Add a visit');
    332+ expect(puml).toContain('title Test: Add a visit');
    332333 expect(puml).not.toContain('SELECT');
    333334 });
    334335
    test('a petclinic-test source keeps its module-relative title', () => {
    397398 );
    398399 expect(Object.values(written)[0]).toContain('in src/add-visit.spec.ts —');
    399400 });
    401+
    402+// The whole join: the request's frames reach the arrow through the span, and through the span
    403+// cache too, so a replay with nothing running keeps every link.
    404+test('a recorded call site reaches the arrow, and survives in the span cache', async () => {
    405+ const written: Record<string, string> = {};
    406+ const source = 'this.visitService.addVisit(visit).subscribe(';
    407+ const file = 'src/app/visits/visit-add/visit-add.component.ts';
    408+ const logged: string[] = [];
    409+ const spans = JSON.parse(JSON.stringify(fixture));
    410+ const client = spans.batches[0].scopeSpans[0].spans[1];
    411+ client.traceId = 'fE7B4usvB9KFqEyldpZ53Q==';
    412+ client.spanId = 'lrlBhfFN7/8=';
    413+ spans.batches[1].scopeSpans[0].spans[0].parentSpanId = 'lrlBhfFN7/8=';
    414+ await generateFromWindows(
    415+ [{title: 'Add a visit', source: 'src/add-visit.spec.ts', startMs: 0, endMs: 10_000}],
    416+ '/repo/petclinic-test',
    417+ {
    418+ searchTraceIds: async () => ['t1'],
    419+ getTrace: async () => spans,
    420+ writeFile: (p, c) => {written[p] = c;},
    421+ readFile: (p) => (p.endsWith(file) ? `${'\n'.repeat(2)} ${source}\n` : undefined),
    422+ log: (m) => logged.push(m),
    423+ readCallSites: () => [{
    424+ traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff',
    425+ frames: [{file, line: 3, text: source}],
    426+ }],
    427+ },
    428+ );
    429+ const puml = written['/repo/petclinic-test/generated/add-visit.spec.ts.add-a-visit.genseq.puml'];
    430+ expect(puml).toContain(`[[src://petclinic-frontend/${file}:3{Click to open the line that sent `
    431+ + 'this request: visit-add.component.ts:3} ');
    432+ expect(written['/repo/petclinic-test/test-results/trace-spans.json']).toContain('genseq.call-site');
    433+ expect(logged.join('\n')).toContain('1/1 browser request(s) traced to their line');
    434+});
    435+
    436+// The recorded headers reach the request's panel through the span cache, and the route the
    437+// SERVER span matched names its controller under the words (Victor, 10 Nov 2026).
    438+test('a request’s headers reach its panel, and its controller its call', async () => {
    439+ const written: Record<string, string> = {};
    440+ const controllerFile = '/repo/petclinic-backend/src/main/java/VisitController.java';
    441+ const controllerSource = [
    442+ '@RestController', '@RequestMapping("/api")', 'public class VisitController {',
    443+ ' @PostMapping("/visits")', ' public Visit addVisit(Visit visit) {', ' }', '}',
    444+ ].join('\n');
    445+ const spans = JSON.parse(JSON.stringify(fixture));
    446+ const client = spans.batches[0].scopeSpans[0].spans[1];
    447+ client.traceId = 'fE7B4usvB9KFqEyldpZ53Q==';
    448+ client.spanId = 'lrlBhfFN7/8=';
    449+ spans.batches[1].scopeSpans[0].spans[0].parentSpanId = 'lrlBhfFN7/8=';
    450+ await generateFromWindows(
    451+ [{title: 'Add a visit', source: 'src/add-visit.spec.ts', startMs: 0, endMs: 10_000}],
    452+ '/repo/petclinic-test',
    453+ {
    454+ searchTraceIds: async () => ['t1'],
    455+ getTrace: async () => spans,
    456+ writeFile: (p, c) => {written[p] = c;},
    457+ readFile: (p) => (p === controllerFile ? controllerSource : undefined),
    458+ walkFiles: (dir) => (controllerFile.startsWith(`${dir}/`) ? [controllerFile] : []),
    459+ log: () => undefined,
    460+ readCallSites: () => [{
    461+ traceId: '7c4ec1e2eb2f07d285a84ca5769679dd', spanId: '96b94185f14defff', frames: [],
    462+ headers: {'Content-Type': 'application/json'},
    463+ }],
    464+ },
    465+ {}, {...DEFAULT_DIAGRAM_OPTIONS, handler: 'call'},
    466+ );
    467+ const puml = written['/repo/petclinic-test/generated/add-visit.spec.ts.add-a-visit.genseq.puml'];
    468+ expect(puml).toContain('Backend -> Backend: [[src://petclinic-backend/src/main/java/VisitController.java:5'
    469+ + '{Click to open VisitController.addVisit} VisitController.addVisit \u2197]]');
    470+ const details = JSON.parse(written['/repo/petclinic-test/generated/add-visit.spec.ts.add-a-visit.genseq.json']);
    471+ const request = Object.values<any>(details.details).find((e) => e.request);
    472+ expect(request.request.headers).toEqual({'Content-Type': 'application/json'});
    473+ expect(request.steps[0].text).toMatch(/^POST \/api\/visits\nContent-Type: application\/json\n\n\{/);
    474+ expect(written['/repo/petclinic-test/test-results/trace-spans.json']).toContain('genseq.request-headers');
    475+});
    generate.ts+102 −9
     
    11 import * as fs from 'fs';
    22 import * as path from 'path';
    33 import {readWindows} from '../support/trace-window-store';
    4-import {parseTempoTrace, renderDiagram, DiagramScenario, MethodLinks, NormSpan,
    5- PARTICIPANT_ATTRIBUTE, TEST_PARTICIPANT} from './trace-to-puml';
    4+import {readCallSites} from '../support/call-sites';
    5+import {parseTempoTrace, renderDiagram, ClientMethods, Controllers, DiagramScenario, MethodLinks,
    6+ NormSpan, PARTICIPANT_ATTRIBUTE, TEST_PARTICIPANT} from './trace-to-puml';
    67 import {tempoConfigFromEnv, searchTraceIds, getTrace} from './tempo-client';
    78 import {DEFAULT_DIAGRAM_OPTIONS, DiagramOptions, describeOptions, optionsFromEnv} from './options';
    89 import {lineOfHandle, lineOfStep, lineOfTest, stepHandle, testHandle} from './test-location';
    9-import {methodHandle} from './code-location';
    10+import {methodHandle, overloadOf} from './code-location';
    11+import {CALL_SITE_ATTRIBUTE, CallFrame, CallSite, callSiteHandle, callSiteIndex, withCallSites}
    12+ from './call-site';
    13+import {Handler, WalkFiles, handlerOf, moduleHandlers} from './controller-location';
    14+import {clientMethodOf} from './client-method';
    1015 import {defaultOperations} from './openapi-operations';
    1116
    1217 export interface TestWindow {
    export interface RenderDeps {
    3035 /** Optional: what a directory holds, so a renamed or deleted scenario's diagram can be
    3136 * swept rather than left on disk describing a run that no longer happens. */
    3237 listFiles?: (dir: string) => string[];
    38+ /** Optional: every file under a directory, so a request's route can be resolved against
    39+ * the controllers of the service it reached. Without it no request names its controller. */
    40+ walkFiles?: WalkFiles;
    3341 log: (msg: string) => void;
    3442 }
    3543
    export interface GenerateDeps extends RenderDeps {
    3745 searchTraceIds: (traceql: string, startMs: number, endMs: number) => Promise<string[]>;
    3846 getTrace: (traceId: string) => Promise<unknown>;
    3947 sleep?: (ms: number) => Promise<void>;
    48+ /** Optional: which line sent each request. Without it the request arrows carry no link. */
    49+ readCallSites?: () => CallSite[];
    4050 }
    4151
    4252 /** One source file's scenarios, as fetched from Tempo — the input a re-render replays. */
    export async function generateFromWindows(
    215225 options: DiagramOptions = DEFAULT_DIAGRAM_OPTIONS,
    216226 ): Promise<string[]> {
    217227 const fetched: CachedSource[] = [];
    228+ const callSites = callSiteIndex(deps.readCallSites?.() ?? []);
    218229 // One diagram per source file, one section per scenario in it — so the picture
    219230 // is filed where its test is, and reads in the order the file does.
    220231 for (const [source, group] of groupBySource(windows)) {
    export async function generateFromWindows(
    231242 }
    232243 const traces: NormSpan[][] = [];
    233244 for (const id of ids) {
    234- traces.push(parseTempoTrace(await deps.getTrace(id)));
    245+ traces.push(withCallSites(parseTempoTrace(await deps.getTrace(id)), callSites));
    235246 }
    236247 scenarios.push({title: w.title, traces: chronological(traces),
    237248 ...(w.selected ? {selected: true} : {})});
    238- deps.log(`✅ "${w.title}": ${ids.length} trace(s)`);
    249+ deps.log(`✅ "${w.title}": ${ids.length} trace(s)${locatedRequests(traces)}`);
    239250 } catch (err) {
    240251 // One scenario's Tempo error must not cost every other diagram — the runner
    241252 // deletes them all up front, so an abort here leaves fewer on disk than it found.
    export async function generateFromWindows(
    257268 return renderScenarios(fetched, rootDir, deps, options);
    258269 }
    259270
    271+/** Said out loud, because a request that lost its link does not show up any other way. */
    272+function locatedRequests(traces: NormSpan[][]): string {
    273+ const browser = traces.flat().filter(
    274+ (s) => s.kind === 'CLIENT' && s.serviceName === 'petclinic-frontend');
    275+ if (browser.length === 0) return '';
    276+ const located = browser.filter((s) => CALL_SITE_ATTRIBUTE in s.attributes);
    277+ return `, ${located.length}/${browser.length} browser request(s) traced to their line`;
    278+}
    279+
    260280 /**
    261281 * Point every section header at the test that produced it.
    262282 *
    function methodLinksFor(rootDir: string, deps: RenderDeps, source: string): Meth
    304324 return stepHandle(repoRelative(rootDir, source),
    305325 lineOfStep(text, span.name, lineOfHandle(scenario?.link)));
    306326 }
    327+ // A browser request knows the line that sent it, in this run: no lookup, only a check
    328+ // that this checkout still has that line.
    329+ const callSite = span.attributes[CALL_SITE_ATTRIBUTE];
    330+ if (callSite) return callSiteHandle(callSite, span.serviceName, repoRoot, readFile);
    307331 // One trace draws the same repository method many times over — an N+1 is exactly what
    308332 // these pictures are for — and each hit would otherwise re-read and re-scan the file.
    333+ // The overload is part of the key: `normalize(phone)` and `normalize(phone, country)`
    334+ // share a class and a name and live on different lines.
    309335 const key = [span.serviceName, span.attributes['code.namespace'],
    310- span.attributes['code.function']].join('#');
    336+ span.attributes['code.function'], JSON.stringify(overloadOf(span.attributes))].join('#');
    311337 if (!cache.has(key)) {
    312338 cache.set(key, methodHandle(span.attributes, span.serviceName, repoRoot, readFile));
    313339 }
    function methodLinksFor(rootDir: string, deps: RenderDeps, source: string): Meth
    315341 };
    316342 }
    317343
    344+/**
    345+ * Name the frontend method each browser request went through — see client-method.ts.
    346+ * Needs the working tree like the links above; without `readFile` the requests are drawn
    347+ * straight off the Browser lifeline, as they always were.
    348+ */
    349+function clientMethodsFor(rootDir: string, deps: RenderDeps): ClientMethods {
    350+ const readFile = deps.readFile;
    351+ if (!readFile) return () => undefined;
    352+ const repoRoot = path.dirname(rootDir);
    353+ const cache = new Map<string, string | undefined>();
    354+ return (span) => {
    355+ const attribute = span.attributes[CALL_SITE_ATTRIBUTE];
    356+ if (span.kind !== 'CLIENT' || !attribute) return undefined;
    357+ const key = `${span.spanId}#${attribute}`;
    358+ if (!cache.has(key)) {
    359+ let frames: CallFrame[] = [];
    360+ try {
    361+ frames = JSON.parse(attribute);
    362+ } catch {
    363+ // an unreadable attribute is a request with no known call site
    364+ }
    365+ const request = {
    366+ verb: span.attributes['http.method'] ?? span.attributes['http.request.method'],
    367+ url: span.attributes['http.url'] ?? span.attributes['url.full'],
    368+ };
    369+ cache.set(key, clientMethodOf(frames, request, `${repoRoot}/${span.serviceName}`, readFile));
    370+ }
    371+ return cache.get(key);
    372+ };
    373+}
    374+
    375+/**
    376+ * The controller method each request landed on — a method the trace never names, resolved by
    377+ * the SERVER span's route against that service's sources (controller-location.ts), scanned
    378+ * once per module. Needs the working tree and a way to walk it; without either, no request
    379+ * names its controller.
    380+ */
    381+function controllersFor(rootDir: string, deps: RenderDeps): Controllers {
    382+ const {readFile, walkFiles: walk} = deps;
    383+ if (!readFile || !walk) return () => undefined;
    384+ const repoRoot = path.dirname(rootDir);
    385+ const handlers = new Map<string, Map<string, Handler>>();
    386+ return (span) => {
    387+ if (span.kind !== 'SERVER') return undefined;
    388+ let routes = handlers.get(span.serviceName);
    389+ if (!routes) {
    390+ routes = moduleHandlers(span.serviceName, repoRoot, readFile, walk);
    391+ handlers.set(span.serviceName, routes);
    392+ }
    393+ return handlerOf(span, routes);
    394+ };
    395+}
    396+
    318397 /** `source` resolved against the repo root — the path the review page can open. */
    319398 function repoRelative(rootDir: string, source: string): string {
    320399 return path.normalize(path.join(path.basename(rootDir), source));
    function titleFor(rootDir: string, source: string): string {
    331410 return source.startsWith('..') ? repoRelative(rootDir, source) : source;
    332411 }
    333412
    413+/** Every file under `dir`, absolute; nothing for a directory that is not there. */
    414+function walkFiles(dir: string): string[] {
    415+ if (!fs.existsSync(dir)) return [];
    416+ return fs.readdirSync(dir, {recursive: true, withFileTypes: true})
    417+ .filter((entry) => entry.isFile())
    418+ .map((entry) => path.join(entry.parentPath ?? (entry as any).path, entry.name));
    419+}
    420+
    334421 /** Draw the diagrams from spans already in hand — the offline half of the pipeline. */
    335422 export function renderScenarios(
    336423 sources: CachedSource[], rootDir: string, deps: RenderDeps,
    export function renderScenarios(
    338425 ): string[] {
    339426 const written: string[] = [];
    340427 warnOnCollisions(sources, deps);
    428+ const controllers = controllersFor(rootDir, deps);
    341429 for (const {source, scenarios} of sources) {
    342430 const linked = linkScenarios(scenarios, rootDir, source, deps);
    343431 const title = titleFor(rootDir, source);
    344432 const methodLinks = methodLinksFor(rootDir, deps, source);
    433+ const clientMethods = clientMethodsFor(rootDir, deps);
    345434 const slugs = uniqueSlugs(linked.map((s) => s.title));
    346435 const mine = new Set<string>();
    347436 linked.forEach((scenario, i) => {
    export function renderScenarios(
    351440 // One scenario per call, so the divider `renderDiagram` draws is this diagram's
    352441 // own header and the picture under it is one test from end to end.
    353442 const {puml, details} = renderDiagram(
    354- title, [scenario], options, defaultOperations(), methodLinks);
    443+ title, [scenario], options, defaultOperations(), methodLinks, clientMethods, controllers);
    355444 // A scenario whose traces draw nothing — a lone click, a run that recorded no
    356445 // server span — used to cost a header inside a shared file and nothing more. On its
    357446 // own it would be a file containing a title and no conversation, which the review
    export async function runGenerate(owned?: RegExp): Promise<void> {
    490579 readFile: (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : undefined),
    491580 removeFile: (p) => fs.rmSync(p, {force: true}),
    492581 listFiles: (d) => (fs.existsSync(d) ? fs.readdirSync(d) : []),
    582+ walkFiles,
    493583 log: (m) => console.log(m),
    494584 }, options);
    495585 console.log(`📊 Re-rendered ${paths.length} diagram(s) from ${cacheFile} — Grafana not needed`);
    export async function runGenerate(owned?: RegExp): Promise<void> {
    511601 return;
    512602 }
    513603
    514- // GRAFANA_URL names whose Tempo this is: an isolated `start-docker.sh up --otel`
    515- // instance prints its own under `ports`; unset, the dev stack's :3300.
    604+ // TEMPO_URL is a bare Tempo (../start-tempo.sh, no Docker); otherwise GRAFANA_URL names
    605+ // whose Tempo this is: an isolated `start-docker.sh up --otel` instance prints its own
    606+ // under `ports`; unset, the dev stack's :3300.
    516607 const cfg = tempoConfigFromEnv();
    517608 console.log(`🔭 Tempo behind ${cfg.baseUrl}`);
    518609 const deps: GenerateDeps = {
    519610 searchTraceIds: (q, s, e) => searchTraceIds(cfg, q, s, e),
    520611 getTrace: (id) => getTrace(cfg, id),
    612+ readCallSites: () => readCallSites(path.join(root, 'test-results', 'call-sites')),
    521613 writeFile: writeFileMakingDirs,
    522614 readFile: (p) => (fs.existsSync(p) ? fs.readFileSync(p, 'utf-8') : undefined),
    523615 removeFile: (p) => fs.rmSync(p, {force: true}),
    524616 listFiles: (d) => (fs.existsSync(d) ? fs.readdirSync(d) : []),
    617+ walkFiles,
    525618 log: (m) => console.log(m),
    526619 };
    527620
     
    1+import {expect, test} from '@playwright/test';
    2+import type {NormSpan} from './trace-to-puml';
    3+import {recordedRequest, requestMessage} from './http-request';
    4+
    5+// The request an HTTP arrow's panel shows and the review page replays: the URL that was
    6+// really sent, with its ids, never the route the server matched it to.
    7+
    8+const span = (kind: string, name: string, attributes: Record<string, string>): NormSpan => ({
    9+ traceId: 't', spanId: kind, parentSpanId: '', name, kind, serviceName: 'petclinic-backend',
    10+ startNano: 0, attributes,
    11+});
    12+const server = (attributes: Record<string, string> = {}) =>
    13+ span('SERVER', 'GET /api/owners/{ownerId}', attributes);
    14+
    15+test('the URL is the one the client sent, with its ids', () => {
    16+ const browser = span('CLIENT', 'GET', {'http.method': 'GET', 'http.url': 'http://127.0.0.1:8080/api/owners/1'});
    17+ expect(recordedRequest(server(), browser, undefined))
    18+ .toEqual({method: 'GET', url: 'http://127.0.0.1:8080/api/owners/1'});
    19+ const java = span('CLIENT', 'POST', {'http.request.method': 'POST',
    20+ 'url.full': 'http://notification:8090/api/notifications/visit-booked'});
    21+ expect(recordedRequest(server(), java, undefined).url)
    22+ .toBe('http://notification:8090/api/notifications/visit-booked');
    23+});
    24+
    25+test('with no client to ask, the URL is put back together from the server’s account', () => {
    26+ const received = server({'url.scheme': 'http', 'server.address': 'notification', 'server.port': '8090',
    27+ 'url.path': '/api/owners/1', 'url.query': 'x=1'});
    28+ expect(recordedRequest(received, undefined, undefined).url).toBe('http://notification:8090/api/owners/1?x=1');
    29+ // A parent that did not send it (a test's own span) is no client either.
    30+ expect(recordedRequest(received, span('INTERNAL', 'when', {'http.url': 'http://wrong'}), undefined).url)
    31+ .toBe('http://notification:8090/api/owners/1?x=1');
    32+});
    33+
    34+// An address made up here would send a replay somewhere nobody listens.
    35+test('a server that does not say where it was reached gives the path alone', () => {
    36+ expect(recordedRequest(server({'url.path': '/api/owners/1'}), undefined, undefined).url).toBe('/api/owners/1');
    37+ expect(recordedRequest(server({'http.target': '/api/owners?lastName=P'}), undefined, undefined).url)
    38+ .toBe('/api/owners?lastName=P');
    39+ expect(recordedRequest(server(), undefined, undefined).url).toBe('/api/owners/{ownerId}');
    40+});
    41+
    42+test('the method comes from the attributes, else from a span name', () => {
    43+ expect(recordedRequest(server({'http.request.method': 'put'}), undefined, undefined).method).toBe('PUT');
    44+ expect(recordedRequest(span('SERVER', 'DELETE /api/pets/{id}', {}), undefined, undefined).method)
    45+ .toBe('DELETE');
    46+});
    47+
    48+test('the headers come from the client span, and a broken record is no headers', () => {
    49+ const sent = (headers: string) => span('CLIENT', 'GET', {'genseq.request-headers': headers});
    50+ expect(recordedRequest(server(), sent('{"Accept":"application/json"}'), undefined).headers)
    51+ .toEqual({Accept: 'application/json'});
    52+ expect(recordedRequest(server(), sent('not json'), undefined)).not.toHaveProperty('headers');
    53+ expect(recordedRequest(server(), sent('{}'), undefined)).not.toHaveProperty('headers');
    54+ expect(recordedRequest(server(), sent('["a"]'), undefined)).not.toHaveProperty('headers');
    55+});
    56+
    57+test('the body is kept raw, and an empty one is no body', () => {
    58+ expect(recordedRequest(server(), undefined, '{"a": 1}').body).toBe('{"a": 1}');
    59+ expect(recordedRequest(server(), undefined, ' ')).not.toHaveProperty('body');
    60+});
    61+
    62+test('the message is a request line, a line per header, a blank line and the body', () => {
    63+ expect(requestMessage({method: 'POST', url: 'http://h/api/visits',
    64+ headers: {'Content-Type': 'application/json', Accept: '*/*'}, body: '{"a":1}'}))
    65+ .toBe('POST http://h/api/visits\nContent-Type: application/json\nAccept: */*\n\n{\n "a": 1\n}');
    66+ expect(requestMessage({method: 'GET', url: 'http://h/api/owners/1'})).toBe('GET http://h/api/owners/1');
    67+});
     
    1+import type {NormSpan} from './trace-to-puml';
    2+import type {HttpRequest} from './detail-index';
    3+import {REQUEST_HEADERS_ATTRIBUTE} from './call-site';
    4+import {formatJsonDetail} from './json-label';
    5+
    6+/**
    7+ * An HTTP arrow's request, as it went over the wire: what its detail panel shows, and what
    8+ * the review page's "Open in Bruno" sends again (Victor, 10 Nov 2026).
    9+ *
    10+ * The arrow is drawn from the SERVER span, which knows the route the request matched
    11+ * (`/api/owners/{ownerId}`) — a template, which no client can send. The span that *sent* it
    12+ * knows the URL with the real ids in it, so that comes first: the browser's CLIENT span
    13+ * records `http.url`, a Java client's `url.full`. Without a CLIENT parent (a service a test
    14+ * called straight), the URL is put back together from what the server says it received.
    15+ */
    16+
    17+const VERB = /^([A-Z]+)\b/;
    18+
    19+function clientOf(parent: NormSpan | undefined): NormSpan | undefined {
    20+ return parent?.kind === 'CLIENT' ? parent : undefined;
    21+}
    22+
    23+function methodOf(server: NormSpan, client: NormSpan | undefined): string {
    24+ for (const span of [client, server]) {
    25+ const method = span?.attributes['http.method'] ?? span?.attributes['http.request.method'];
    26+ if (method) return method.toUpperCase();
    27+ }
    28+ return VERB.exec(server.name.trim())?.[1] ?? VERB.exec(client?.name.trim() ?? '')?.[1] ?? 'GET';
    29+}
    30+
    31+/**
    32+ * `http://127.0.0.1:8080/api/owners/1?x=y`, from the server's own account of the request.
    33+ * A server that does not say which host it was reached at (a test's in-process call) gives
    34+ * the path alone: an address made up here would send a replay somewhere nobody listens.
    35+ */
    36+function urlFromServer(server: NormSpan): string {
    37+ const a = server.attributes;
    38+ const target = a['http.target'];
    39+ const path = a['url.path'] ?? target?.split('?')[0] ?? server.name.trim().split(/\s+/)[1] ?? '/';
    40+ const query = a['url.query'] ?? (target?.includes('?') ? target.slice(target.indexOf('?') + 1) : undefined);
    41+ const local = `${path}${query ? `?${query}` : ''}`;
    42+ const host = a['server.address'] ?? a['net.host.name'];
    43+ if (!host) return local;
    44+ const port = a['server.port'] ?? a['net.host.port'];
    45+ return `${a['url.scheme'] ?? a['http.scheme'] ?? 'http'}://${host}${port ? `:${port}` : ''}${local}`;
    46+}
    47+
    48+function headersOf(span: NormSpan | undefined): Record<string, string> | undefined {
    49+ const raw = span?.attributes[REQUEST_HEADERS_ATTRIBUTE];
    50+ if (!raw) return undefined;
    51+ try {
    52+ const parsed = JSON.parse(raw);
    53+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
    54+ const entries = Object.entries(parsed).filter(([, v]) => typeof v === 'string') as [string, string][];
    55+ return entries.length > 0 ? Object.fromEntries(entries) : undefined;
    56+ } catch {
    57+ return undefined;
    58+ }
    59+}
    60+
    61+/**
    62+ * The request a SERVER span received, with `parent` the span that sent it. `body` is the raw
    63+ * payload, or undefined to leave it out — the diagram's options decide whether it is carried.
    64+ * A span cache recorded before the headers were captured simply has none to give.
    65+ */
    66+export function recordedRequest(
    67+ server: NormSpan, parent: NormSpan | undefined, body: string | undefined,
    68+): HttpRequest {
    69+ const client = clientOf(parent);
    70+ const url = client?.attributes['http.url'] ?? client?.attributes['url.full'] ?? urlFromServer(server);
    71+ const headers = headersOf(client) ?? headersOf(server);
    72+ return {
    73+ method: methodOf(server, client),
    74+ url,
    75+ ...(headers ? {headers} : {}),
    76+ ...(body?.trim() ? {body} : {}),
    77+ };
    78+}
    79+
    80+/**
    81+ * The request as an HTTP message: the request line, a `Name: value` line per header, then a
    82+ * blank line and the body. The shape every HTTP client and RFC prints, so a reader takes it in
    83+ * at a glance — and the body pretty-printed, which is the one liberty taken with the wire.
    84+ */
    85+export function requestMessage(request: HttpRequest): string {
    86+ const head = [`${request.method} ${request.url}`,
    87+ ...Object.entries(request.headers ?? {}).map(([name, value]) => `${name}: ${value}`)];
    88+ const body = formatJsonDetail(request.body);
    89+ return body ? `${head.join('\n')}\n\n${body}` : head.join('\n');
    90+}
    import {test, expect} from '@playwright/test';
    22 import {DEFAULT_DIAGRAM_OPTIONS, describeOptions, optionsFromEnv} from './options';
    33
    44 test('an empty environment reveals everything the traces carry, a click at a time', () => {
    5- expect(optionsFromEnv({})).toEqual({sql: 'statement', httpBodies: true, interactive: true});
    5+ expect(optionsFromEnv({})).toEqual({sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'});
    66 expect(optionsFromEnv({})).toEqual(DEFAULT_DIAGRAM_OPTIONS);
    77 });
    88
    test('SEQ_INTERACTIVE=0 goes back to baking the detail into the picture', () =>
    3434 });
    3535
    3636 test('describeOptions states the level a generated file was rendered at', () => {
    37- expect(describeOptions({sql: 'values', httpBodies: true, interactive: false}))
    38- .toBe('SQL shown, with values · HTTP bodies shown');
    39- expect(describeOptions({sql: 'off', httpBodies: false, interactive: false}))
    40- .toBe('SQL not shown · HTTP bodies not shown');
    37+ expect(describeOptions({sql: 'values', httpBodies: true, interactive: false, handler: 'panel'}))
    38+ .toBe('SQL shown, with values · HTTP bodies shown · controllers in the request panel');
    39+ expect(describeOptions({sql: 'off', httpBodies: false, interactive: false, handler: 'call'}))
    40+ .toBe('SQL not shown · HTTP bodies not shown · controllers as calls');
    4141 });
    4242
    4343 // An interactive diagram shows none of it up front, so naming a level would lie —
    44-// what the header owes the reader is what a click will bring up.
    44+// what the header owes the reader is what a click will bring up. An HTTP request always
    45+// opens, its URL and headers there to show with every payload switched off.
    4546 test('describeOptions names what is revealable rather than what is drawn', () => {
    46- expect(describeOptions({sql: 'statement', httpBodies: true, interactive: true}))
    47- .toBe('simplified · click an arrow to reveal its SQL / JSON payloads');
    48- expect(describeOptions({sql: 'statement', httpBodies: false, interactive: true}))
    49- .toBe('simplified · click an arrow to reveal its SQL');
    50- expect(describeOptions({sql: 'off', httpBodies: false, interactive: true}))
    51- .toBe('simplified · nothing recorded to reveal');
    47+ expect(describeOptions({sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'}))
    48+ .toBe('simplified · click an arrow to reveal its SQL / HTTP requests / JSON payloads'
    49+ + ' · controllers in the request panel');
    50+ expect(describeOptions({sql: 'statement', httpBodies: false, interactive: true, handler: 'panel'}))
    51+ .toBe('simplified · click an arrow to reveal its SQL / HTTP requests · controllers in the request panel');
    52+ expect(describeOptions({sql: 'off', httpBodies: false, interactive: true, handler: 'call'}))
    53+ .toBe('simplified · click an arrow to reveal its HTTP requests · controllers as calls');
    54+});
    55+
    56+// The request's words open the request now, so the controller needs a place of its own; both
    57+// shapes are kept to compare on a real diagram (Victor, 10 Nov 2026).
    58+test('SEQ_HANDLER leaves the controller to the panel, or draws it as a call', () => {
    59+ expect(optionsFromEnv({}).handler).toBe('panel');
    60+ expect(optionsFromEnv({SEQ_HANDLER: 'call'}).handler).toBe('call');
    61+ expect(optionsFromEnv({SEQ_HANDLER: ' CALL '}).handler).toBe('call');
    62+ expect(optionsFromEnv({SEQ_HANDLER: 'line'}).handler).toBe('panel');
    63+ expect(optionsFromEnv({SEQ_HANDLER: 'constructor'}).handler).toBe('panel');
    64+ expect(optionsFromEnv({SEQ_HANDLER: 'yolo'}).handler).toBe(DEFAULT_DIAGRAM_OPTIONS.handler);
    5265 });
    5366
    5467 // Payloads are free behind a click and expensive baked in, so the default follows the
    options.ts+38 −5
     
    66 /** `off` — no SQL; `statement` — the SQL as sent, `?` for each bound value; `values` — `?` filled in. */
    77 export type SqlDetail = 'off' | 'statement' | 'values';
    88
    9+/**
    10+ * Where an HTTP arrow names the controller method its request landed on: `panel` — only in the
    11+ * head of the panel the arrow's words open, the review page resolving it from the route; `call`
    12+ * — a self-call on the receiving lifeline, everything the request did nested inside it.
    13+ *
    14+ * There used to be a `line`: a third line under the arrow's words, a link of its own. Two
    15+ * links on one label, each with its own hover and click, for a name the panel's head already
    16+ * gives — it read as clutter (Victor, 10 Nov 2026). `line` is still accepted, as `panel`.
    17+ */
    18+export type HandlerPlacement = 'panel' | 'call';
    19+
    920 export interface DiagramOptions {
    1021 sql: SqlDetail;
    1122 /** Draw the JSON request/response payloads of each REST round-trip as notes. */
    export interface DiagramOptions {
    1627 * say *what* the diagram carries; this says whether it is baked in or clicked open.
    1728 */
    1829 interactive: boolean;
    30+ /**
    31+ * The words of a request arrow open the request itself now, so the controller needs a
    32+ * place of its own. Both shapes are kept so they can be compared on a real diagram before
    33+ * one of them wins (Victor, 10 Nov 2026).
    34+ */
    35+ handler: HandlerPlacement;
    1936 }
    2037
    2138 /** The default diagram: simplified, with everything the traces carry a click away. */
    export const DEFAULT_DIAGRAM_OPTIONS: DiagramOptions = {
    2340 sql: 'statement',
    2441 httpBodies: true,
    2542 interactive: true,
    43+ handler: 'panel',
    2644 };
    2745
    2846 // Object.create(null): a plain literal answers to `constructor` and `__proto__`,
    const SQL_ALIASES: Record<string, SqlDetail> = Object.assign(Object.create(null)
    3351 values: 'values', params: 'values',
    3452 });
    3553
    54+// Object.create(null) for the reason SQL_ALIASES gives.
    55+const HANDLER_ALIASES: Record<string, HandlerPlacement> = Object.assign(Object.create(null), {
    56+ panel: 'panel', line: 'panel', call: 'call',
    57+});
    58+
    3659 const TRUTHY = /^(1|true|yes|on)$/i;
    3760 const FALSY = /^(0|false|no|off)$/i;
    3861
    export function optionsFromEnv(
    5578 // a request arrow and found it had nothing to say.
    5679 httpBodies: boolFrom(env.SEQ_HTTP_BODIES, interactive),
    5780 interactive,
    81+ handler: HANDLER_ALIASES[env.SEQ_HANDLER?.trim().toLowerCase() ?? '']
    82+ ?? DEFAULT_DIAGRAM_OPTIONS.handler,
    5883 };
    5984 }
    6085
    61-/** What an interactive diagram lets the reader click open, in the order it appears. */
    86+/**
    87+ * What an interactive diagram lets the reader click open, in the order it appears. An HTTP
    88+ * request always opens — its URL and headers are there to show with no payload switched on.
    89+ */
    6290 export function revealable(o: DiagramOptions): string[] {
    6391 const kinds: string[] = [];
    6492 if (o.sql !== 'off') kinds.push('SQL');
    93+ kinds.push('HTTP requests');
    6594 if (o.httpBodies) kinds.push('JSON payloads');
    6695 return kinds;
    6796 }
    6897
    98+const HANDLER_PLACES: Record<HandlerPlacement, string> = {
    99+ panel: 'controllers in the request panel',
    100+ call: 'controllers as calls',
    101+};
    102+
    69103 /** The line a generated file carries so its own detail level is never a guess. */
    70104 export function describeOptions(o: DiagramOptions): string {
    105+ const handler = HANDLER_PLACES[o.handler];
    71106 if (o.interactive) {
    72- const kinds = revealable(o);
    73- if (kinds.length === 0) return 'simplified · nothing recorded to reveal';
    74- return `simplified · click an arrow to reveal its ${kinds.join(' / ')}`;
    107+ return `simplified · click an arrow to reveal its ${revealable(o).join(' / ')} · ${handler}`;
    75108 }
    76109 const sql = {off: 'not shown', statement: 'shown, values as ?', values: 'shown, with values'}[o.sql];
    77- return `SQL ${sql} · HTTP bodies ${o.httpBodies ? 'shown' : 'not shown'}`;
    110+ return `SQL ${sql} · HTTP bodies ${o.httpBodies ? 'shown' : 'not shown'} · ${handler}`;
    78111 }
    test('tempoConfigFromEnv falls back to localhost grafana admin', () => {
    1010 expect(cfg.password).toBe('admin');
    1111 });
    1212
    13+test('TEMPO_URL talks to Tempo itself: no Grafana proxy, no login', async () => {
    14+ const cfg = tempoConfigFromEnv({TEMPO_URL: 'http://localhost:3200', GRAFANA_URL: 'http://localhost:3300'});
    15+ expect(buildTraceUrl(cfg, 'abc123')).toBe('http://localhost:3200/api/traces/abc123');
    16+ expect(buildSearchUrl(cfg, '{}', 1, 2, 3)).toMatch(/^http:\/\/localhost:3200\/api\/search\?/);
    17+ const seen: any[] = [];
    18+ const fakeFetch = async (_url: string, init: any) => {
    19+ seen.push(init.headers);
    20+ return {ok: true, json: async () => ({traces: []})};
    21+ };
    22+ await searchTraceIds(cfg, '{}', 0, 1, fakeFetch as any);
    23+ expect(seen[0].Authorization).toBeUndefined();
    24+});
    25+
    1326 test('authHeader builds Basic base64', () => {
    1427 const h = authHeader({ baseUrl: 'x', user: 'admin', password: 'admin' });
    1528 expect(h).toBe('Basic ' + Buffer.from('admin:admin').toString('base64'));
    export interface TempoConfig {
    22 baseUrl: string;
    33 user: string;
    44 password: string;
    5+ /** baseUrl is Tempo itself (../start-tempo.sh, no Grafana): no datasource proxy, no login. */
    6+ direct?: boolean;
    57 }
    68
    79 type FetchFn = (url: string, init?: any) => Promise<{ ok: boolean; status?: number; json: () => Promise<any> }>;
    type FetchFn = (url: string, init?: any) => Promise<{ ok: boolean; status?: numb
    911 const TEMPO_DS = 'tempo';
    1012
    1113 export function tempoConfigFromEnv(env: NodeJS.ProcessEnv = process.env): TempoConfig {
    14+ if (env.TEMPO_URL) {
    15+ return {baseUrl: env.TEMPO_URL, user: '', password: '', direct: true};
    16+ }
    1217 return {
    1318 baseUrl: env.GRAFANA_URL ?? 'http://localhost:3300',
    1419 user: env.GRAFANA_USER ?? 'admin',
    export function authHeader(cfg: TempoConfig): string {
    2126 }
    2227
    2328 function proxyBase(cfg: TempoConfig): string {
    24- return `${cfg.baseUrl}/api/datasources/proxy/uid/${TEMPO_DS}`;
    29+ return cfg.direct ? cfg.baseUrl : `${cfg.baseUrl}/api/datasources/proxy/uid/${TEMPO_DS}`;
    30+}
    31+
    32+function authHeaders(cfg: TempoConfig): Record<string, string> {
    33+ return cfg.direct ? {} : {Authorization: authHeader(cfg)};
    2534 }
    2635
    2736 export function buildSearchUrl(
    export async function searchTraceIds(
    4554 fetchFn: FetchFn = fetch as any,
    4655 ): Promise<string[]> {
    4756 const url = buildSearchUrl(cfg, traceql, Math.floor(startMs / 1000), Math.ceil(endMs / 1000), 50);
    48- const res = await fetchFn(url, { headers: { Authorization: authHeader(cfg) } });
    57+ const res = await fetchFn(url, { headers: authHeaders(cfg) });
    4958 if (!res.ok) {
    5059 throw new Error(`Tempo search failed: HTTP ${res.status}`);
    5160 }
    export async function getTrace(
    5867 ): Promise<unknown> {
    5968 const url = buildTraceUrl(cfg, traceId);
    6069 const res = await fetchFn(url, {
    61- headers: { Authorization: authHeader(cfg), Accept: 'application/json' },
    70+ headers: { ...authHeaders(cfg), Accept: 'application/json' },
    6271 });
    6372 if (!res.ok) {
    6473 throw new Error(`Tempo getTrace failed: HTTP ${res.status}`);
    test('the header wraps the title in the link, and is the bare title without one'
    5454 });
    5555
    5656 // A @SpringBootTest's section is titled with the sentence JUnit shows for the method
    57-// (@DisplayNameGeneration(PrettyTestNames)), which is never written in the .java file —
    57+// (PrettyTestNames, the backend's default generator), which is never written in the .java file —
    5858 // the method is `adds_a_visit_to_an_existing_pet`. Matching the title as a literal
    5959 // substring therefore finds nothing, and the diagram loses the line it links to.
    6060 const JAVA = [
    const JAVA_METHOD = /\bvoid\s+([A-Za-z_$][\w$]*)\s*\(/;
    2727 * `sourceName` is the file the text came from. In a .java file the method declaration is
    2828 * looked for **first**, because there the title is never written out anyway: a
    2929 * @SpringBootTest's section is titled with the sentence JUnit displays for the method
    30- * (@DisplayNameGeneration(PrettyTestNames)), and the method is
    30+ * (PrettyTestNames, the backend's default generator), and the method is
    3131 * `adds_a_visit_to_an_existing_pet`. Rather than reimplement the generator, the method
    3232 * name goes through the same camelCase/snake_case-to-words reading the DSL narration
    3333 * uses; the two agree because they are the same transformation.
    import {test, expect} from '@playwright/test';
    22 import * as fs from 'fs';
    33 import * as path from 'path';
    44 import {NormSpan, parseTempoTrace, renderDiagram, renderPuml, spansToPuml} from './trace-to-puml';
    5-import {DiagramOptions} from './options';
    5+import {DEFAULT_DIAGRAM_OPTIONS, DiagramOptions} from './options';
    66
    77 // The diagrams are interactive by default now, so every assertion about a *baked in*
    88 // label has to say so — which is also the regression test that the static path, the
    99 // one CI and the committed snapshots depend on, still renders exactly what it did.
    10-const STATIC: DiagramOptions = {sql: 'statement', httpBodies: false, interactive: false};
    10+const STATIC: DiagramOptions = {sql: 'statement', httpBodies: false, interactive: false, handler: 'panel'};
    1111
    1212 const fixture = JSON.parse(
    1313 fs.readFileSync(path.join(__dirname, '__fixtures__', 'add-visit-trace.json'), 'utf-8'),
    test('spansToPuml footers the diagram with its provenance', () => {
    8181 // a PlantUML footer renders at the very bottom — provenance, not diagram content
    8282 expect(puml).toContain('footer @generate_sequence');
    8383 // the title stays a clean single line, no subtitle, no note, no caption
    84- expect(puml).toContain('\ntitle add a visit\n');
    84+ expect(puml).toContain('\ntitle Test: add a visit\n');
    8585 expect(puml).not.toContain('note across');
    8686 expect(puml).not.toContain('caption');
    8787 });
    test('renderPuml drops traces that would draw nothing', () => {
    202202
    203203 // Two scenarios in, one drawn — so what comes out is a solo diagram: the survivor
    204204 // names the picture, and there is no chapter left to divide it from.
    205- expect(puml).toContain('title Add a visit');
    205+ expect(puml).toContain('title Test: Add a visit');
    206206 expect(puml).not.toContain('Clicked around');
    207207 expect(puml).not.toContain('== ');
    208208 expect(puml).toContain(ADD_VISIT);
    test('a Java scenario is headed by its class, a Playwright one by itself', () =>
    228228 const java = renderPuml(
    229229 'petclinic-backend/src/test/java/victor/training/petclinic/rest/AddVisitApiTest.java',
    230230 [{title: 'remembers the vet who attended it', traces: [parseTempoTrace(fixture)]}]);
    231- expect(java).toContain('title AddVisitApiTest: remembers the vet who attended it');
    231+ expect(java).toContain('title Test: AddVisitApiTest: remembers the vet who attended it');
    232232
    233233 const spec = renderPuml('add-visit.spec.ts',
    234234 [{title: 'Add a visit', traces: [parseTempoTrace(fixture)]}]);
    235- expect(spec).toContain('title Add a visit');
    235+ expect(spec).toContain('title Test: Add a visit');
    236236
    237237 // Two chapters in one Java file are two qualified headings, for the same reason.
    238238 const two = renderPuml(
    const MARKER = /\[\[genseq:\/\/([a-z0-9-]+)\{[^}]*\} [^\]]*\]\]/;
    253253
    254254 interface Step {label: string; text: string; alternate?: {label: string; text: string}}
    255255
    256-const detailOn = (line: string, details: Record<string, {title: string; steps: Step[]}>) =>
    256+interface Entry {title: string; steps: Step[]; request?: {url: string}}
    257+
    258+const detailOn = (line: string, details: Record<string, Entry>) =>
    257259 details[MARKER.exec(line)![1]];
    258260
    259261 const lineWith = (puml: string, needle: string) =>
    test('a statement with no bound parameters offers only the statement', () => {
    302304 test('the payloads become markers on the request and the response, not notes', () => {
    303305 const {puml, details} = renderDiagram('add-visit.spec.ts',
    304306 [{title: 'Add a visit', traces: [parseTempoTrace(fixture)]}],
    305- {sql: 'statement', httpBodies: true, interactive: true});
    307+ {sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'});
    306308
    307309 expect(puml).not.toContain('note over');
    308310
    311+ // The request as an HTTP message: its request line, then — after a blank line — the body.
    309312 const request = detailOn(lineWith(puml, 'Browser -> Backend:'), details.details);
    310- expect(request.steps[0].label).toBe('request body');
    313+ expect(request.steps[0].label).toBe('request');
    314+ expect(request.steps[0].text).toMatch(/^POST \/api\/visits\n\n\{\n/);
    311315 expect(request.steps[0].text).toContain('"description": "annual checkup"');
    312316
    313317 const response = detailOn(lineWith(puml, 'Backend --> Browser:'), details.details);
    test('a body recorded on a SERVER span reveals on its arrow, not on the calls it
    333337 span('r4', 'r3', 'send-sms', 'INTERNAL', 'mailer', {'genseq.participant': 'SMS gateway'}),
    334338 ];
    335339 const {puml, details} = renderDiagram('add-visit.spec.ts', [{title: 'x', traces: [spans]}],
    336- {sql: 'statement', httpBodies: true, interactive: true});
    340+ {sql: 'statement', httpBodies: true, interactive: true, handler: 'panel'});
    337341
    338342 const request = detailOn(lineWith(puml, 'Backend -> mailer:'), details.details);
    339343 expect(request.steps[0].text).toContain('"petName": "Leo"');
    test('a body recorded on a SERVER span reveals on its arrow, not on the calls it
    343347 // Behind a click a payload costs the picture nothing, so an interactive diagram carries
    344348 // it by default; baked in it is a wall of JSON, so a static one still has to ask.
    345349 // Withholding it by default only meant a reviewer clicked a request arrow and found
    346-// that it had nothing to say.
    347-test('payloads are revealable by default, and nothing is when there is nothing to reveal', () => {
    350+// that it had nothing to say. The request itself opens either way: its URL and headers
    351+// are no payload (Victor, 10 Nov 2026).
    352+test('payloads are revealable by default, and a request opens without them', () => {
    348353 const plain = renderDiagram('x', [{title: 'x', traces: [parseTempoTrace(fixture)]}]);
    349354 expect(lineWith(plain.puml, 'Browser -> Backend:')).toMatch(MARKER);
    355+ expect(lineWith(plain.puml, 'Backend --> Browser:')).toMatch(MARKER);
    350356
    351357 const withheld = renderDiagram('x', [{title: 'x', traces: [parseTempoTrace(fixture)]}],
    352- {sql: 'statement', httpBodies: false, interactive: true});
    353- expect(lineWith(withheld.puml, 'Browser -> Backend:')).not.toMatch(MARKER);
    358+ {sql: 'statement', httpBodies: false, interactive: true, handler: 'panel'});
    359+ const request = detailOn(lineWith(withheld.puml, 'Browser -> Backend:'), withheld.details.details);
    360+ expect(request.steps).toEqual([{label: 'request', text: 'POST /api/visits'}]);
    361+ expect(lineWith(withheld.puml, 'Backend --> Browser:')).not.toMatch(MARKER);
    354362
    355363 const none = renderDiagram('x', [{title: 'x', traces: [parseTempoTrace(fixture)]}],
    356- {sql: 'off', httpBodies: false, interactive: true});
    357- expect(none.puml).not.toMatch(MARKER);
    358- expect(none.details.details).toEqual({});
    364+ {sql: 'off', httpBodies: false, interactive: true, handler: 'panel'});
    365+ expect(Object.values(none.details.details).map((e) => e.title))
    366+ .toEqual(['Browser → Backend: POST /api/visits']);
    359367 });
    360368
    361369 // The review page renders a *textual* diff of the .puml, so an id that moved with
    test('a marker id follows the arrow content, not its position in the file', () =
    372380
    373381 test('an interactive diagram tones its link markup down', () => {
    374382 const {puml} = renderDiagram('x', [{title: 'x', traces: [parseTempoTrace(fixture)]}],
    375- {sql: 'values', httpBodies: true, interactive: true});
    383+ {sql: 'values', httpBodies: true, interactive: true, handler: 'panel'});
    376384 expect(puml).toContain('skinparam hyperlinkUnderline false');
    377385 expect(puml).toContain('skinparam hyperlinkColor');
    378386 // The ⊕ on each revealable arrow is the affordance; a paragraph explaining it is not.
    const selectOwners = {
    446454 attributes: {'db.system': 'postgresql', 'db.statement': 'select o1_0.id from owners o1_0'},
    447455 };
    448456
    457+// Every frame says how its transaction ended, beside the tab — PlantUML's `[…]` tail.
    458+const COMMITTED_TX = 'group tx [committed]';
    459+
    449460 const inTransaction: NormSpan[] = [
    450461 backendSpan('t1', '', 'GET /api/owners', 'SERVER'),
    451462 backendSpan('t2', 't1', 'OwnerRepository.findById', 'INTERNAL'),
    const inTransaction: NormSpan[] = [
    456467
    457468 test('a transaction is drawn as a frame around what ran inside it', () => {
    458469 const body = spansToPuml(inTransaction, 'tx', STATIC).split('\n').map((l) => l.trim());
    459- const open = body.indexOf('group tx');
    470+ const open = body.indexOf(COMMITTED_TX);
    460471 const close = body.indexOf('end', open);
    461472 expect(open).toBeGreaterThan(-1);
    462473 expect(close).toBeGreaterThan(open);
    test('the commit is the frame, not another arrow inside it', () => {
    474485 test('a query inside a frame does not repeat the frame\'s label', () => {
    475486 // split on the whole line: "Backend" contains "end"
    476487 const inside = spansToPuml(inTransaction, 'tx', STATIC)
    477- .split('group tx\n')[1].split('\nend\n')[0];
    488+ .split(`${COMMITTED_TX}\n`)[1].split('\nend\n')[0];
    478489 expect(inside).not.toContain('OwnerRepository.findById');
    479490 expect(inside).toContain('select owners');
    480491 });
    test('a handler-level transaction frames its body, not the request', () => {
    511522 (l) => l.startsWith('Browser -> Backend:') && l.includes('POST /api/visits'));
    512523 expect(request).toBeGreaterThan(-1);
    513524 expect(body).toContain('Backend --> Browser: 201');
    514- const open = body.indexOf('group tx');
    525+ const open = body.indexOf(COMMITTED_TX);
    515526 const close = body.indexOf('end', open);
    516527 expect(open).toBeGreaterThan(request);
    517528 expect(body.slice(open, close).filter((l) => l.startsWith('Backend -> DB:'))).toHaveLength(2);
    test('a service-level transaction is a frame inside the call that opened it', ()
    538549 const call = body.indexOf('Backend -> Backend: VisitService.book');
    539550 expect(call).toBeGreaterThan(-1);
    540551 expect(body[call + 1]).toBe('activate Backend');
    541- const open = body.indexOf('group tx', call);
    552+ const open = body.indexOf(COMMITTED_TX, call);
    542553 expect(open).toBeGreaterThan(call);
    543554 const close = body.indexOf('end', open);
    544555 expect(body.slice(open, close).filter((l) => l.startsWith('Backend -> DB:'))).toHaveLength(2);
    test('a service-level transaction is a frame inside the call that opened it', ()
    547558 // The frame's job is to show an extent. Whatever opened it is named by the call the frame
    548559 // sits inside, so a longer label would only repeat the line above it.
    549560 test('the frame is called tx, wherever it was opened', () => {
    550- expect(spansToPuml(onController, 'tx', STATIC)).toContain('group tx\n');
    551- expect(spansToPuml(onService, 'tx', STATIC)).toContain('group tx\n');
    561+ expect(spansToPuml(onController, 'tx', STATIC)).toContain(`${COMMITTED_TX}\n`);
    562+ expect(spansToPuml(onService, 'tx', STATIC)).toContain(`${COMMITTED_TX}\n`);
    552563 expect(spansToPuml(onService, 'tx', STATIC)).not.toContain('transaction ·');
    553564 });
    554565
    566+// ── How the transaction ended ───────────────────────────────────────────────────
    567+// The agent draws a commit as a span and a rollback as nothing, so the outcome comes from
    568+// the backend's TransactionOutcomeTracing, stamped on the span that opened the transaction.
    569+// A trace from before that existed still has its commit span, which says "committed".
    570+
    571+const outcome = (spans: NormSpan[], value: string): NormSpan[] => spans
    572+ .filter((s) => s.name !== 'Transaction.commit')
    573+ .map((s) => (s.spanId === 't2'
    574+ ? {...s, attributes: {...s.attributes, 'transaction.outcome': value}} : s));
    575+
    576+test('a committed transaction says so beside its tab, in grey', () => {
    577+ const puml = spansToPuml(outcome(inTransaction, 'committed'), 'tx', STATIC);
    578+ expect(puml).toContain(`${COMMITTED_TX}\n`);
    579+ // grey from the header, not an inline <color>: that splits the label into three runs
    580+ expect(puml).toContain('skinparam sequenceGroupFontColor #888888\n');
    581+});
    582+
    583+test('a rolled-back transaction is framed too, and says so in amber', () => {
    584+ const body = spansToPuml(outcome(inTransaction, 'rolled back'), 'tx', STATIC)
    585+ .split('\n').map((l) => l.trim());
    586+ const open = body.indexOf('group tx [<color:#B26A00>rolled back</color>]');
    587+ expect(open).toBeGreaterThan(-1);
    588+ expect(body.slice(open, body.indexOf('end', open))
    589+ .filter((l) => l.startsWith('Backend -> DB:'))).toHaveLength(1);
    590+});
    591+
    592+test('the stamped outcome wins over the commit span', () => {
    593+ const failed = inTransaction.map((s) => (s.spanId === 't2'
    594+ ? {...s, attributes: {'transaction.outcome': 'commit failed'}} : s));
    595+ const puml = spansToPuml(failed, 'tx', STATIC);
    596+ expect(puml).toContain('group tx [<color:#B26A00>commit failed</color>]');
    597+ expect(puml).not.toContain(COMMITTED_TX);
    598+});
    599+
    555600 // ── A @SpringBootTest drives the very same renderer ─────────────────────────────
    556601 // Nothing in a trace otherwise separates the test from the code it drives: a MockMvc
    557602 // call and the controller it reaches run in one JVM under one `service.name`. The test
    test('a crossing arrow with nothing to reveal links to the method it was opened
    719764 + '{Click to open Sender.visitBooked} notify-visit-booked \u2197]]');
    720765 });
    721766
    767+// ── A browser request links to the line that sent it ─────────────────────────────
    768+// The link comes from the request's own stack, so it hangs off the browser's CLIENT span
    769+// (genseq/call-site.ts) while the arrow is drawn from the SERVER span under it.
    770+const SENT_FROM = 'src://petclinic-frontend/src/app/a.component.ts:22'
    771+ + '{Click to open the line that sent this request: a.component.ts:22}';
    772+
    773+function browserRequest(browserAttributes: Record<string, string>): NormSpan[] {
    774+ return [
    775+ {traceId: 'c', spanId: 'c-xhr', parentSpanId: '', name: 'POST', kind: 'CLIENT',
    776+ serviceName: 'petclinic-frontend', startNano: 1_000 * 1e6, attributes: browserAttributes},
    777+ {traceId: 'c', spanId: 'c-server', parentSpanId: 'c-xhr', name: 'POST /api/visits',
    778+ kind: 'SERVER', serviceName: 'petclinic-backend', startNano: 1_100 * 1e6,
    779+ attributes: {'http.status_code': '201'}},
    780+ ];
    781+}
    782+
    783+const located = (span: NormSpan) => (span.spanId === 'c-xhr' ? SENT_FROM : undefined);
    784+const CALL_SITE = {'genseq.call-site': '[{"file":"src/app/a.component.ts","line":22}]'};
    785+const REQUEST = '{Click for the request as it was sent}';
    786+
    787+test('a baked-in browser request links its words to the line that sent it', () => {
    788+ const puml = renderDiagram('src/add-visit.spec.ts',
    789+ [{title: 'books a visit', traces: [browserRequest(CALL_SITE)]}],
    790+ STATIC, new Map(), located).puml;
    791+ expect(puml).toContain(`Browser -> Backend: [[${SENT_FROM} POST /api/visits ↗]]\n`);
    792+});
    793+
    794+// The words open the request on every HTTP arrow now (Victor, 10 Nov 2026); a request whose
    795+// frontend method is unknown has no call arriving from the left to carry the line that sent
    796+// it, so that line is a bare ↗ beside them.
    797+test('a request with no known frontend method keeps the line that sent it beside its words', () => {
    798+ const {puml, details} = renderDiagram('src/add-visit.spec.ts',
    799+ [{title: 'books a visit', traces: [browserRequest({...CALL_SITE, 'http.request.body': '{"a":1}'})]}],
    800+ undefined, new Map(), located);
    801+ const [id] = Object.keys(details.details);
    802+ expect(puml).toContain(`Browser -> Backend: [[genseq://${id}${REQUEST} POST /api/visits ⊕]] `
    803+ + `[[${SENT_FROM} ↗]]\n`);
    804+});
    805+
    806+test('a request with no known line opens its request and nothing else', () => {
    807+ const {puml, details} = renderDiagram('src/add-visit.spec.ts',
    808+ [{title: 'books a visit', traces: [browserRequest({'http.request.body': '{"a":1}'})]}],
    809+ undefined, new Map(), located);
    810+ const [id] = Object.keys(details.details);
    811+ expect(puml).toContain(`Browser -> Backend: [[genseq://${id}${REQUEST} POST /api/visits ⊕]]\n`);
    812+ expect(puml).not.toContain('src://');
    813+});
    814+
    815+test('a call site the checkout could not confirm leaves the arrow plain', () => {
    816+ const puml = renderDiagram('src/add-visit.spec.ts',
    817+ [{title: 'books a visit', traces: [browserRequest(CALL_SITE)]}],
    818+ STATIC, new Map(), () => undefined).puml;
    819+ expect(puml).toContain('Browser -> Backend: POST /api/visits\n');
    820+});
    821+
    822+// ── A browser request is drawn as the frontend method it went through ────────────
    823+// A call arriving on the Browser from the left edge names the service method
    824+// (genseq/client-method.ts) and opens the line that sent the request; the request leaves from
    825+// inside its activation, its words open the request as it was sent, and the controller method
    826+// it landed on (genseq/controller-location.ts) is drawn where SEQ_HANDLER says.
    827+const LANDED_ON = 'src://petclinic-backend/src/main/java/VisitController.java:30'
    828+ + '{Click to open VisitController.addVisit}';
    829+const controller = (span: NormSpan) => (span.kind === 'SERVER'
    830+ ? {name: 'VisitController.addVisit', handle: LANDED_ON} : undefined);
    831+const throughVisitService = (span: NormSpan) =>
    832+ span.spanId === 'c-xhr' ? 'VisitService.addVisit' : undefined;
    833+const CALL: DiagramOptions = {...DEFAULT_DIAGRAM_OPTIONS, handler: 'call'};
    834+
    835+const drawn = (attributes: Record<string, string>, options?: DiagramOptions) => renderDiagram(
    836+ 'src/add-visit.spec.ts', [{title: 'books a visit', traces: [browserRequest(attributes)]}],
    837+ options, new Map(), located, throughVisitService, controller);
    838+
    839+test('a browser request arrives on the Browser from the left, its request sent from inside it', () => {
    840+ const {puml, details} = drawn(CALL_SITE);
    841+ const [id] = Object.keys(details.details);
    842+ const lines = puml.split('\n');
    843+ const at = lines.indexOf(`[-> Browser: [[${SENT_FROM} VisitService.addVisit ↗]]`);
    844+ expect(at).toBeGreaterThan(0);
    845+ expect(lines.slice(at + 1)).toEqual([
    846+ 'activate Browser',
    847+ `Browser -> Backend: [[genseq://${id}${REQUEST} POST /api/visits ⊕]]`,
    848+ 'Backend --> Browser: 201',
    849+ 'deactivate Browser',
    850+ '@enduml',
    851+ '',
    852+ ]);
    853+ expect(puml).not.toContain('Browser -> Browser');
    854+});
    855+
    856+// The panel opens on a GET with no body as on a POST with one: it is the request's URL and
    857+// headers a reader came for, and what the review page's "Open in Bruno" sends again.
    858+test('every request arrow opens its request, a GET with no body too', () => {
    859+ const headers = JSON.stringify({Accept: 'application/json, text/plain, */*'});
    860+ const spans: NormSpan[] = [
    861+ {traceId: 'g', spanId: 'g-xhr', parentSpanId: '', name: 'GET', kind: 'CLIENT',
    862+ serviceName: 'petclinic-frontend', startNano: 1e9, attributes: {
    863+ 'http.method': 'GET', 'http.url': 'http://127.0.0.1:8080/api/owners/1',
    864+ 'genseq.request-headers': headers,
    865+ }},
    866+ {traceId: 'g', spanId: 'g-server', parentSpanId: 'g-xhr', name: 'GET /api/owners/{ownerId}',
    867+ kind: 'SERVER', serviceName: 'petclinic-backend', startNano: 1.1e9,
    868+ attributes: {'http.response.status_code': '200'}},
    869+ ];
    870+ const {puml, details} = renderDiagram('src/owners.spec.ts', [{title: 'x', traces: [spans]}]);
    871+ const entry = detailOn(lineWith(puml, 'Browser -> Backend:'), details.details);
    872+ expect(entry).toEqual({
    873+ title: 'Browser → Backend: GET /api/owners/{ownerId}',
    874+ steps: [{label: 'request',
    875+ text: 'GET http://127.0.0.1:8080/api/owners/1\nAccept: application/json, text/plain, */*'}],
    876+ request: {method: 'GET', url: 'http://127.0.0.1:8080/api/owners/1',
    877+ headers: {Accept: 'application/json, text/plain, */*'}},
    878+ });
    879+});
    880+
    881+test('a request’s body is the raw payload for a replay and pretty-printed for a reader', () => {
    882+ const {puml, details} = drawn({...CALL_SITE, 'http.method': 'POST',
    883+ 'http.url': 'http://127.0.0.1:8080/api/visits', 'http.request.body': '{"a":1}'});
    884+ const entry = detailOn(lineWith(puml, 'Browser -> Backend:'), details.details) as any;
    885+ expect(entry.steps).toEqual([
    886+ {label: 'request', text: 'POST http://127.0.0.1:8080/api/visits\n\n{\n "a": 1\n}'}]);
    887+ expect(entry.request).toEqual({method: 'POST', url: 'http://127.0.0.1:8080/api/visits', body: '{"a":1}'});
    888+});
    889+
    890+test('SEQ_HANDLER=call draws the controller as a call that everything the request did nests in', () => {
    891+ const {puml, details} = drawn(CALL_SITE, CALL);
    892+ const [id] = Object.keys(details.details);
    893+ const lines = puml.split('\n');
    894+ const at = lines.indexOf(`Browser -> Backend: [[genseq://${id}${REQUEST} POST /api/visits ⊕]]`);
    895+ expect(at).toBeGreaterThan(0);
    896+ expect(lines.slice(at + 1, at + 6)).toEqual([
    897+ 'activate Backend',
    898+ `Backend -> Backend: [[${LANDED_ON} VisitController.addVisit ↗]]`,
    899+ 'Backend --> Browser: 201',
    900+ 'deactivate Backend',
    901+ 'deactivate Browser',
    902+ ]);
    903+ expect(puml).not.toContain('→ VisitController');
    904+});
    905+
    906+// Any service's controller, and with the request's own work inside its activation — the
    907+// transaction a @Transactional controller opened included.
    908+test('the controller call nests the work of the request, on whichever service it landed', () => {
    909+ const span = (spanId: string, parentSpanId: string, name: string, kind: NormSpan['kind'],
    910+ serviceName: string, attributes: Record<string, string> = {}): NormSpan => ({
    911+ traceId: 'n', spanId, parentSpanId, name, kind, serviceName,
    912+ startNano: Number(spanId.slice(1)) * 1e6, attributes,
    913+ });
    914+ const spans = [
    915+ span('n1', '', 'POST /api/visits', 'SERVER', 'petclinic-backend', {'http.status_code': '201'}),
    916+ span('n2', 'n1', 'POST', 'CLIENT', 'petclinic-backend',
    917+ {'url.full': 'http://notification:8090/api/notifications/visit-booked'}),
    918+ span('n3', 'n2', 'POST /api/notifications/visit-booked', 'SERVER', 'notification-service',
    919+ {'http.response.status_code': '202', 'transaction.outcome': 'committed'}),
    920+ span('n4', 'n3', 'send-sms', 'INTERNAL', 'notification-service'),
    921+ ];
    922+ const handled = (s: NormSpan) => (s.spanId === 'n3'
    923+ ? {name: 'NotificationController.visitBooked', handle: 'src://n/C.java:9{Click}'} : undefined);
    924+ const {puml, details} = renderDiagram('x', [{title: 'x', traces: [spans]}], CALL,
    925+ new Map(), undefined, undefined, handled);
    926+ const lines = puml.split('\n');
    927+ const at = lines.findIndex((l) => l.startsWith('Backend -> NotificationService:'));
    928+ expect(lines.slice(at + 1, at + 9)).toEqual([
    929+ 'activate NotificationService',
    930+ 'NotificationService -> NotificationService: [[src://n/C.java:9{Click} '
    931+ + 'NotificationController.visitBooked ↗]]',
    932+ 'activate NotificationService',
    933+ 'group tx [committed]',
    934+ 'NotificationService -> NotificationService: send-sms',
    935+ 'end',
    936+ 'deactivate NotificationService',
    937+ 'NotificationService --> Backend: 202',
    938+ ]);
    939+ expect(lines[at + 9]).toBe('deactivate NotificationService');
    940+ expect(detailOn(lines[at], details.details).request!.url)
    941+ .toBe('http://notification:8090/api/notifications/visit-booked');
    942+});
    943+
    944+test('a controller call with nothing inside it is a bare arrow, no bar of its own', () => {
    945+ const spans: NormSpan[] = [
    946+ {traceId: 'e', spanId: 'e-test', parentSpanId: '', name: 'when', kind: 'INTERNAL',
    947+ serviceName: 'petclinic-backend', startNano: 1e9, attributes: {'genseq.participant': 'Test'}},
    948+ {traceId: 'e', spanId: 'e-server', parentSpanId: 'e-test', name: 'POST /api/visits',
    949+ kind: 'SERVER', serviceName: 'petclinic-backend', startNano: 1.1e9,
    950+ attributes: {'http.status_code': '201'}},
    951+ ];
    952+ const {puml} = renderDiagram('x', [{title: 'x', traces: [spans]}], CALL,
    953+ new Map(), undefined, undefined, controller);
    954+ expect(puml).toContain('activate Backend\n'
    955+ + `Backend -> Backend: [[${LANDED_ON} VisitController.addVisit \u2197]]\n`
    956+ + 'Backend --> Test: 201\ndeactivate Backend\n');
    957+ expect(puml.match(/^activate Backend$/gm)).toHaveLength(1);
    958+});
    959+
    960+test('a request whose controller is unknown has neither a third line nor a call', () => {
    961+ for (const options of [DEFAULT_DIAGRAM_OPTIONS, CALL]) {
    962+ const {puml} = renderDiagram('src/add-visit.spec.ts',
    963+ [{title: 'books a visit', traces: [browserRequest(CALL_SITE)]}],
    964+ options, new Map(), located, throughVisitService);
    965+ expect(puml).not.toContain('Backend -> Backend');
    966+ expect(lineWith(puml, 'Browser -> Backend:')).not.toContain('\\n[[');
    967+ }
    968+});
    969+
    970+test('a baked-in request has no panel, and names its controller only as a call', () => {
    971+ const panel = drawn(CALL_SITE, STATIC).puml;
    972+ expect(panel).toContain('Browser -> Backend: POST /api/visits\n');
    973+ expect(panel).not.toContain('genseq://');
    974+ expect(panel).not.toContain('VisitController');
    975+ const call = drawn(CALL_SITE, {...STATIC, handler: 'call'}).puml;
    976+ expect(call).toContain(`Browser -> Backend: POST /api/visits\nactivate Backend\n`
    977+ + `Backend -> Backend: [[${LANDED_ON} VisitController.addVisit ↗]]\n`);
    978+});
    979+
    722980 // ── A driver call with no statement is not a query ───────────────────────────────
    723981 // The JDBC instrumentation times every call into the driver, so borrowing or validating a
    724982 // pooled connection arrives as a CLIENT span with the whole database semconv on it and
    test('a DB span with an empty statement draws nothing at all', () => {
    7601018 expect(withStatement).toContain(
    7611019 'Backend -> DB: select owners\\nSELECT o1_0.id\\nFROM owners o1_0');
    7621020 });
    1021+
    1022+// A lifeline that is a module, not a service, says so above its name. The guillemets go
    1023+// into the display name, behind `as`: the review page's parsers read `participant X` and
    1024+// `participant "…" as X`, and nothing after either.
    1025+test('a lifeline declared with a stereotype names its kind above it', () => {
    1026+ const span = (spanId: string, parentSpanId: string, name: string,
    1027+ attributes: Record<string, string>): NormSpan => ({
    1028+ traceId: 'm', spanId, parentSpanId, name, kind: parentSpanId ? 'INTERNAL' : 'SERVER',
    1029+ serviceName: 'petclinic-backend', startNano: Number(spanId.slice(1)) * 1e6, attributes,
    1030+ });
    1031+ const spans = [
    1032+ span('m1', '', 'POST /api/visits', {'http.status_code': '201'}),
    1033+ span('m2', 'm1', 'PhoneNumbers.normalize',
    1034+ {'genseq.participant': 'Commons', 'genseq.stereotype': 'module'}),
    1035+ span('m3', 'm2', 'NotificationServiceClient.homeDialCode', {}),
    1036+ ];
    1037+ const puml = renderPuml('add-visit.spec.ts', [{title: 'x', traces: [spans]}], STATIC);
    1038+
    1039+ expect(puml).toContain('participant "«module»\\nCommons" as Commons');
    1040+ expect(puml).toContain('participant Backend\n');
    1041+ expect(puml).toContain('Backend -> Commons: PhoneNumbers.normalize');
    1042+ expect(puml).toContain('Commons -> Backend: NotificationServiceClient.homeDialCode');
    1043+});
     
    11 import {formatOriginLabel, formatSqlDetail, formatSqlLabel, splitOrigin, summarizeStatement} from './sql-label';
    22 import {formatJsonDetail, jsonNote} from './json-label';
    33 import {DEFAULT_DIAGRAM_OPTIONS, DiagramOptions} from './options';
    4-import {DetailCollector, DetailIndex, DetailStep} from './detail-index';
    4+import {DetailCollector, DetailEntry, DetailIndex, DetailStep} from './detail-index';
    55 import {OperationNames, defaultOperations, operationNameOf} from './openapi-operations';
    66 import {linkedSectionTitle} from './test-location';
    7-import {linkedMethodLabel} from './code-location';
    7+import {METHOD_LINK_MARKER, linkedMethodLabel} from './code-location';
    8+import {CALL_SITE_ATTRIBUTE} from './call-site';
    9+import {recordedRequest, requestMessage} from './http-request';
    810
    911 export interface NormSpan {
    1012 traceId: string;
    const DB_NAME_RE = /^(SELECT|INSERT|UPDATE|DELETE|MERGE)\b/i;
    7375 // Set by petclinic-backend's genseq/Steps.java; the two are one contract.
    7476 export const PARTICIPANT_ATTRIBUTE = 'genseq.participant';
    7577
    78+/** What kind of thing a declared lifeline is — `module`, for a jar linked into the services. */
    79+export const STEREOTYPE_ATTRIBUTE = 'genseq.stereotype';
    80+
    7681 /** The lifeline a @SpringBootTest's own `given`/`when`/`then` marks are drawn on. */
    7782 export const TEST_PARTICIPANT = 'Test';
    7883
    function arrowLabel(
    204209 const MARKER_SCHEME = 'genseq://';
    205210 const MARKER_GLYPH = '⊕';
    206211
    207-/** `label`, wrapped in the link that reveals `steps` — or bare, when there is nothing to reveal. */
    212+/** `label`, wrapped in the link that reveals `entry` — or bare, when there is nothing to reveal. */
    208213 function linkLabel(
    209- label: string, collector: DetailCollector, title: string, steps: DetailStep[], tooltip: string,
    214+ label: string, collector: DetailCollector, entry: DetailEntry, tooltip: string,
    210215 ): string {
    211- if (steps.length === 0) return label;
    212- const id = collector.add({title, steps});
    216+ if (entry.steps.length === 0) return label;
    217+ const id = collector.add(entry);
    213218 return `[[${MARKER_SCHEME}${id}{${tooltip}} ${label} ${MARKER_GLYPH}]]`;
    214219 }
    215220
    221+/** The `⊕` on its own, for a label whose words already link somewhere else. */
    222+function linkMarker(collector: DetailCollector, entry: DetailEntry, tooltip: string): string {
    223+ return `[[${MARKER_SCHEME}${collector.add(entry)}{${tooltip}} ${MARKER_GLYPH}]]`;
    224+}
    225+
    216226 const SQL_TOOLTIP = 'Click for the statement behind this call';
    217227 const BODY_TOOLTIP = 'Click for this call’s JSON body';
    228+const REQUEST_TOOLTIP = 'Click for the request as it was sent';
    218229
    219230 /**
    220231 * One step: the statement as Hibernate sent it. The bound values ride along as the
    const REPOSITORY_SPAN_RE = /Repository\.\w+$/;
    285296 // transaction ended somewhere above, and leaves the reader to guess how far up.
    286297 const TRANSACTION_COMMIT = 'Transaction.commit';
    287298
    299+// How the transaction ended, stamped by the backend's TransactionOutcomeTracing on the span
    300+// that opened it: `committed`, `rolled back` or `commit failed`. The agent draws a commit as
    301+// a span of its own (above) but a rollback as nothing at all, so a transaction that rolled
    302+// back used to get no frame, and a frame could only ever have meant "committed". A trace
    303+// recorded before the attribute existed still has its commit span, and that says the same.
    304+export const TRANSACTION_OUTCOME_ATTRIBUTE = 'transaction.outcome';
    305+const COMMITTED = 'committed';
    306+
    307+// The frame's tab says just `tx` — whatever opened it is named by the call it sits inside,
    308+// so a longer tab would only repeat that. How it ended goes *beside* the tab: PlantUML draws
    309+// a group's `[bracketed]` tail as a second label to the right of it, in the header's grey
    310+// (GROUP_TAIL_STYLE) — the expected case, read as a quiet status. Anything but a commit is
    311+// amber, the case a reader must not skim past; it reads on a light and a dark page as it is.
    312+function transactionHeader(outcome: string): string {
    313+ return outcome === COMMITTED
    314+ ? `group tx [${outcome}]`
    315+ : `group tx [<color:#B26A00>${outcome}</color>]`;
    316+}
    317+
    318+// The tail's style, for every group: grey and plain where the `tx` tab stays bold. In the
    319+// header rather than as an inline <color>, which splits `[committed]` into three text runs —
    320+// and the review page, redrawing labels in its own font, then opens a gap before the `]`.
    321+// #888888 is that page's muted grey, which it re-themes for dark mode.
    322+const GROUP_TAIL_STYLE = [
    323+ 'skinparam sequenceGroupFontColor #888888',
    324+ 'skinparam sequenceGroupFontStyle plain',
    325+];
    326+
    288327 /** The opt-in a reader will find in the source this diagram was drawn from. */
    289328 function optInOf(source: string): string {
    290329 return source.endsWith('.java') ? '@GenerateSequence' : '@generate_sequence';
    function qualifiedTitle(title: string, source: string): string {
    318357 return `${cls}: ${title}`;
    319358 }
    320359
    360+/**
    361+ * The picture's heading says it is a test. Without the word, `Add a visit to an existing
    362+ * pet from the owner detail page` reads like a feature's name, and a reader landing on the
    363+ * picture (a review page, a slide) takes the arrows for how the app always behaves rather
    364+ * than for one recorded run of one test (12 Oct 2026, Victor).
    365+ */
    366+export const TEST_TITLE_PREFIX = 'Test: ';
    367+
    321368 // Left to right is the direction a call travels. Browser and Test never appear together:
    322369 // one is a browser suite's lifeline, the other a @SpringBootTest's, and each drives the
    323370 // backend from the same place on the page.
    export function pumlName(participant: string): string {
    339386 return BARE_IDENTIFIER.test(participant) ? participant : `"${participant}"`;
    340387 }
    341388
    389+function stereotypesOf(scenarios: DiagramScenario[]): Map<string, string> {
    390+ const kinds = new Map<string, string>();
    391+ for (const span of scenarios.flatMap((s) => s.traces.flat())) {
    392+ const participant = span.attributes[PARTICIPANT_ATTRIBUTE]?.trim();
    393+ const kind = span.attributes[STEREOTYPE_ATTRIBUTE]?.trim();
    394+ if (participant && kind) kinds.set(participant, kind);
    395+ }
    396+ return kinds;
    397+}
    398+
    399+// «kind» goes into the display name rather than as PlantUML's own `<<kind>>`: the review
    400+// page's parsers read `participant X` and `participant "…" as X`, and nothing after either.
    401+function participantLine(participant: string, kind: string | undefined): string {
    402+ return kind
    403+ ? `participant "«${kind}»\\n${participant}" as ${pumlName(participant)}`
    404+ : `participant ${pumlName(participant)}`;
    405+}
    406+
    342407 function orderedParticipants(present: Set<string>): string[] {
    343408 const ranked = PARTICIPANT_ORDER.filter((p) => present.has(p));
    344409 const rest = [...present].filter((p) => !PARTICIPANT_ORDER.includes(p)).sort();
    function orderedParticipants(present: Set<string>): string[] {
    348413 function emitTrace(
    349414 spans: NormSpan[], lines: string[], present: Set<string>, options: DiagramOptions,
    350415 collector: DetailCollector, operations: OperationNames, methodLinks: MethodLinks,
    416+ clientMethods: ClientMethods, controllers: Controllers,
    351417 ): void {
    352418 const byId = new Map(spans.map((s) => [s.spanId, s]));
    353419 // Indexed once: filtering the whole span array per span made an N+1-heavy trace
    function emitTrace(
    364430 const childrenOf = (id: string) => childrenByParent.get(id) ?? [];
    365431
    366432 /**
    367- * A span that opened a transaction: the interceptor committed inside it.
    433+ * How the transaction this span opened ended, or undefined when it opened none.
    368434 *
    369- * In this codebase that is every Spring Data repository method, because nothing above
    370- * them is `@Transactional` — so each call gets its own transaction and its own session,
    371- * which is a fact about the design worth being able to see.
    435+ * In this codebase the opener is every Spring Data repository method, because nothing
    436+ * above them is `@Transactional` — so each call gets its own transaction and its own
    437+ * session, which is a fact about the design worth being able to see.
    372438 */
    373- const opensTransaction = (span: NormSpan): boolean =>
    374- childrenOf(span.spanId).some((c) => c.name === TRANSACTION_COMMIT);
    375-
    376- // Just `tx`. The frame's job is to show an *extent*, and whatever opened it is named
    377- // by the call the frame sits inside — a longer label only repeats that.
    378- const TRANSACTION_LABEL = 'tx';
    439+ const transactionOutcome = (span: NormSpan): string | undefined =>
    440+ span.attributes[TRANSACTION_OUTCOME_ATTRIBUTE]?.trim()
    441+ || (childrenOf(span.spanId).some((c) => c.name === TRANSACTION_COMMIT)
    442+ ? COMMITTED : undefined);
    443+ const opensTransaction = (span: NormSpan): boolean => transactionOutcome(span) !== undefined;
    379444
    380445 const nearestAncestor = (span: NormSpan, matches: RegExp): NormSpan | undefined => {
    381446 for (let up = span.parentSpanId ? byId.get(span.parentSpanId) : undefined;
    function emitTrace(
    398463 };
    399464
    400465
    466+ /**
    467+ * The label of the arrow into a SERVER span: its words open the request as it was sent — the
    468+ * concrete URL, the headers, the body (genseq/http-request.ts) — for every HTTP arrow, with a
    469+ * body or without, as the inter-service arrows' words open their payload (Victor, 10 Nov 2026).
    470+ * The panel keeps the title `Browser → Backend: GET /api/owners/{ownerId}`: the review page
    471+ * reads the route back out of it to name the controller in the panel's head.
    472+ *
    473+ * Under `handler: 'panel'` the controller is named only in that panel's head; under `call`
    474+ * it is a self-call on the receiving lifeline (below).
    475+ *
    476+ * A browser request whose frontend method is unknown has no call arriving from the left to
    477+ * carry the line that sent it, so that line hangs off the words as a bare `↗` of its own.
    478+ */
    479+ const requestLabel = (
    480+ span: NormSpan, parent: NormSpan | undefined, pp: string, p: string,
    481+ landedOn: Controller | undefined,
    482+ ): string => {
    483+ const text = arrowLabel(span, p, options, operations);
    484+ const drawnAsCall = parent?.kind === 'CLIENT' && clientMethods(parent) !== undefined;
    485+ const sentFrom = !drawnAsCall && parent?.attributes[CALL_SITE_ATTRIBUTE]
    486+ ? methodLinks(parent) : undefined;
    487+ let words: string;
    488+ if (options.interactive) {
    489+ const body = options.httpBodies ? bodyOf(span, parent, 'http.request.body') : undefined;
    490+ const request = recordedRequest(span, parent, body);
    491+ const steps = [{label: 'request', text: requestMessage(request)}];
    492+ words = linkLabel(text, collector, {title: `${pp} → ${p}: ${span.name}`, steps, request}, REQUEST_TOOLTIP);
    493+ if (sentFrom) words += ` [[${sentFrom} ${METHOD_LINK_MARKER}]]`;
    494+ } else {
    495+ words = linkedMethodLabel(text, sentFrom);
    496+ }
    497+ return words;
    498+ };
    499+
    401500 const walk = (span: NormSpan, out: string[], parentParticipant?: string): void => {
    402501 // Whatever the driver does inside a query is the database's business: drawing a
    403502 // child of a DB span would put an arrow *out* of the DB lifeline, as if the
    function emitTrace(
    438537 // transaction is opened by the request handler itself — `@Transactional` on a
    439538 // controller method — framing the span would swallow the request and the response
    440539 // with it, and the picture would lose the call it is about.
    441- const body = opensTransaction(span) && inner.length > 0
    442- ? [`group ${TRANSACTION_LABEL}`, ...inner, 'end']
    443- : inner;
    540+ const outcome = inner.length > 0 ? transactionOutcome(span) : undefined;
    541+ const body = outcome ? [transactionHeader(outcome), ...inner, 'end'] : inner;
    444542
    445543 if (!crossing && !selfCustom) {
    446544 present.add(p);
    447- out.push(...body);
    545+ // A browser request is drawn as a call too: the frontend method it went through
    546+ // (`OwnerService.getOwnerById`, genseq/client-method.ts), and the request leaving from
    547+ // inside its activation — the shape a repository call has on the Backend. Its words open
    548+ // the line that sent the request, in this run.
    549+ //
    550+ // The call arrives from the left edge, not as a self-call (Victor, 10 Nov 2026): nothing
    551+ // on the Browser calls its own service, the user's click does, and that is off the page.
    552+ const via = span.kind === 'CLIENT' && inner.length > 0 ? clientMethods(span) : undefined;
    553+ if (via) {
    554+ out.push(`[-> ${pumlName(p)}: ${linkedMethodLabel(via, methodLinks(span))}`,
    555+ `activate ${pumlName(p)}`, ...body, `deactivate ${pumlName(p)}`);
    556+ } else {
    557+ out.push(...body);
    558+ }
    448559 return;
    449560 }
    450561
    function emitTrace(
    453564 const bodies = options.httpBodies && !options.interactive && crossing
    454565 ? `${pumlName(pp!)}, ${pumlName(p)}` : undefined;
    455566
    456- if (crossing) {
    567+ // An HTTP request is the arrow into a SERVER span. Its words open the request itself, so
    568+ // the method it landed on — the controller, the first class of the application it reaches
    569+ // (genseq/controller-location.ts) — is drawn where `options.handler` says: a third line of
    570+ // the label, or a self-call that everything the request did nests inside.
    571+ const isRequest = crossing && span.kind === 'SERVER';
    572+ const landedOn = isRequest ? controllers(span) : undefined;
    573+ // The self-call is the first thing inside the request's activation, and gets one of its own
    574+ // only when something ran inside it: the same rule as every other bar on the picture.
    575+ let inside = body;
    576+ if (landedOn && options.handler === 'call') {
    577+ const P = pumlName(p);
    578+ const self = `${P} -> ${P}: ${linkedMethodLabel(landedOn.name, landedOn.handle)}`;
    579+ inside = body.length > 0 ? [self, `activate ${P}`, ...body, `deactivate ${P}`] : [self];
    580+ }
    581+
    582+ if (isRequest) {
    583+ present.add(pp!);
    584+ present.add(p);
    585+ out.push(`${pumlName(pp!)} -> ${pumlName(p)}: ${requestLabel(span, parent, pp!, p, landedOn)}`);
    586+ if (bodies) out.push(...jsonNote(bodies, bodyOf(span, parent, 'http.request.body')));
    587+ } else if (crossing) {
    457588 present.add(pp!);
    458589 present.add(p);
    459590 const steps = p === 'DB'
    function emitTrace(
    465596 // opened is called `SELECT petclinic.owners`.
    466597 const title = p === 'DB' ? text : `${pp} → ${p}: ${span.name}`;
    467598 const tooltip = p === 'DB' ? SQL_TOOLTIP : BODY_TOOLTIP;
    468- // PlantUML gives a message label exactly one link, so the two candidates take turns:
    469- // the ⊕ that unfolds the SQL or the JSON body wins where there is something to unfold,
    470- // and where there is not the slot is free for the code the span was opened on.
    599+ const entry = {title, steps};
    600+ // The ⊕ that unfolds the SQL or the JSON body wins where there is something to unfold,
    601+ // and where there is not the words are free for the code the span was opened on.
    471602 // `notify-visit-booked` is that case — a crossing @WithSpan arrow that reveals nothing,
    472603 // and until this it was the only kind of arrow in the picture with no way back to its method.
    473- const label = steps.length > 0
    474- ? linkLabel(text, collector, title, steps, tooltip)
    475- : linkedMethodLabel(text, methodLinks(span));
    604+ // Where there is both, the ⊕ is a second link beside the words.
    605+ const wordsTo = methodLinks(span);
    606+ const label = wordsTo && steps.length > 0
    607+ ? `${linkedMethodLabel(text, wordsTo)} ${linkMarker(collector, entry, tooltip)}`
    608+ : steps.length > 0
    609+ ? linkLabel(text, collector, entry, tooltip)
    610+ : linkedMethodLabel(text, wordsTo);
    476611 out.push(`${pumlName(pp!)} -> ${pumlName(p)}: ${label}`);
    477612 if (bodies) out.push(...jsonNote(bodies, bodyOf(span, parent, 'http.request.body')));
    478613 } else {
    function emitTrace(
    482617 out.push(`${pumlName(p)} -> ${pumlName(p)}: ${linkedMethodLabel(span.name, methodLinks(span))}`);
    483618 }
    484619
    485- if (inner.length > 0) out.push(`activate ${pumlName(p)}`);
    486- out.push(...body);
    620+ if (inside.length > 0) out.push(`activate ${pumlName(p)}`);
    621+ out.push(...inside);
    487622 // Only a meaningful return (an HTTP status) earns an arrow back.
    488623 const label = crossing ? returnLabel(span) : undefined;
    489624 if (label) {
    490625 const steps = bodySteps(bodyOf(span, parent, 'http.response.body'), 'response body', options);
    491626 out.push(`${pumlName(p)} --> ${pumlName(pp!)}: ${
    492- linkLabel(label, collector, `${p} → ${pp}: ${label}`, steps, BODY_TOOLTIP)}`);
    627+ linkLabel(label, collector, {title: `${p} → ${pp}: ${label}`, steps}, BODY_TOOLTIP)}`);
    493628 }
    494629 if (bodies) out.push(...jsonNote(bodies, bodyOf(span, parent, 'http.response.body')));
    495- if (inner.length > 0) out.push(`deactivate ${pumlName(p)}`);
    630+ if (inside.length > 0) out.push(`deactivate ${pumlName(p)}`);
    496631 };
    497632
    498633 const roots = spans
    export type MethodLinks = (span: NormSpan, scenario?: DiagramScenario) => string
    520655
    521656 const NO_METHOD_LINKS: MethodLinks = () => undefined;
    522657
    658+/**
    659+ * The frontend method a browser CLIENT span's request went through, as the call arriving
    660+ * on the Browser says it: `OwnerService.getOwnerById` — see client-method.ts. Undefined draws the
    661+ * request straight off the Browser lifeline, as it always was.
    662+ */
    663+export type ClientMethods = (span: NormSpan) => string | undefined;
    664+
    665+const NO_CLIENT_METHODS: ClientMethods = () => undefined;
    666+
    667+/** The method a request landed on: `OwnerRestController.getOwner`, and its `src://` handle. */
    668+export interface Controller {
    669+ name: string;
    670+ handle: string;
    671+}
    672+
    673+/**
    674+ * The controller method a SERVER span's request landed on — see controller-location.ts.
    675+ * Undefined draws the request with no controller: no third line, no self-call.
    676+ */
    677+export type Controllers = (span: NormSpan) => Controller | undefined;
    678+
    679+const NO_CONTROLLERS: Controllers = () => undefined;
    680+
    523681 export interface DiagramScenario {
    524682 title: string;
    525683 traces: NormSpan[][];
    export function renderDiagram(
    541699 options: DiagramOptions = DEFAULT_DIAGRAM_OPTIONS,
    542700 operations: OperationNames = defaultOperations(),
    543701 methodLinks: MethodLinks = NO_METHOD_LINKS,
    702+ clientMethods: ClientMethods = NO_CLIENT_METHODS,
    703+ controllers: Controllers = NO_CONTROLLERS,
    544704 ): RenderedDiagram {
    545705 // A trace can carry spans yet draw nothing — a lone browser `click`, say. Render
    546706 // each in isolation and keep only what has content, so an empty trace cannot
    export function renderDiagram(
    556716 for (const spans of scenario.traces) {
    557717 const traceLines: string[] = [];
    558718 const drawn = new Set<string>();
    559- emitTrace(spans, traceLines, drawn, options, collector, operations, scoped);
    719+ emitTrace(spans, traceLines, drawn, options, collector, operations, scoped, clientMethods,
    720+ controllers);
    560721 if (traceLines.length === 0) continue;
    561722 drawn.forEach((p) => present.add(p));
    562723 lines.push(...traceLines);
    export function renderDiagram(
    585746 // path any more, but `renderDiagram` is a public function and a caller handing it two
    586747 // scenarios means two chapters, which need naming and separating.
    587748 const solo = sections.length === 1 ? sections[0] : undefined;
    749+ const kinds = stereotypesOf(scenarios);
    588750 const header = [
    589751 '@startuml',
    590752 // ' starts a PlantUML comment: this one warns whoever opens the *file*.
    export function renderDiagram(
    594756 `' ⚠️ GENERATED FILE — DO NOT EDIT. Every edit is lost on the next run.`,
    595757 'hide footbox',
    596758 ...interactiveHeader,
    597- `title ${solo ? linkedSectionTitle(qualifiedTitle(solo.title, title), solo.link) : title}`,
    759+ ...GROUP_TAIL_STYLE,
    760+ `title ${solo ? linkedSectionTitle(TEST_TITLE_PREFIX + qualifiedTitle(solo.title, title), solo.link) : title}`,
    598761 // footer (bottom of every page) states the diagram's provenance, naming the opt-in
    599762 // the reader will actually find in the file above: a .feature/.spec.ts carries the
    600763 // `@generate_sequence` tag, a @SpringBootTest the `@GenerateSequence` annotation.
    export function renderDiagram(
    610773 `footer ${provenanceOf(title, scenarios)} in ${title} — `
    611774 + 'generated from real traces of end-to-end '
    612775 + 'test runs, do not edit ❗',
    613- ...orderedParticipants(present).map((p) => `participant ${pumlName(p)}`),
    776+ ...orderedParticipants(present).map((p) => participantLine(p, kinds.get(p))),
    614777 ];
    615778 // A divider per chapter, only where there are chapters to tell apart. PlantUML renders
    616779 // a creole link inside one, and the review page resolves the handle against its own
    export function renderPuml(
    629792 options: DiagramOptions = DEFAULT_DIAGRAM_OPTIONS,
    630793 operations: OperationNames = defaultOperations(),
    631794 methodLinks: MethodLinks = NO_METHOD_LINKS,
    795+ clientMethods: ClientMethods = NO_CLIENT_METHODS,
    796+ controllers: Controllers = NO_CONTROLLERS,
    632797 ): string {
    633- return renderDiagram(title, scenarios, options, operations, methodLinks).puml;
    798+ return renderDiagram(title, scenarios, options, operations, methodLinks, clientMethods, controllers).puml;
    634799 }
    635800
    636801 interface DiagramSection {
     
    1+@fixture:default
    12 Feature: Search owners by last name
    23 As a clinic user
    34 I want to filter owners by typing part of a last name
    async function expectOwnersListed(world: PlaywrightWorld, expected: string[]): P
    2727 /**
    2828 * Remembers every owner the clinic holds, after checking that the ones the
    2929 * Background names are among them — so a changed seed (Flyway's
    30- * db/seed/R__seed.sql) fails on the Given instead of looking like a broken search.
    30+ * db/seed/R__seed.sql, which the feature asks for with @fixture:default) fails on
    31+ * the Given instead of looking like a broken search.
    3132 */
    3233 Given('the clinic has these owners', async function (this: PlaywrightWorld, owners: DataTable) {
    3334 const {data} = await axios.get(`${API_BASE}/owners`, {timeout: 10_000});
    3435 if (!Array.isArray(data) || data.length === 0) {
    35- throw new Error('The API returned no owners — is the backend up and the DB seeded by Flyway?');
    36+ throw new Error('The API returned no owners — is the backend up, and was the DB reset to ' +
    37+ 'the @fixture:default this feature declares?');
    3638 }
    3739 const names: string[] = data.map(fullName);
    3840 expect(names).toEqual(expect.arrayContaining(owners.raw().map(([name]) => name.trim())));
    call-sites.ts+222 −0
     
    1+import {Page} from '@playwright/test';
    2+import * as fs from 'fs';
    3+import * as path from 'path';
    4+import {SourceMapConsumer} from 'source-map';
    5+import {CallSite, idsOfTraceparent, parseStack, replayableHeaders, requestFrames} from '../genseq/call-site';
    6+import {TestWindow} from '../genseq/generate';
    7+import {windowFileName} from './trace-window-store';
    8+
    9+/**
    10+ * One request as it left the page: the W3C header it carried, then the JS stacks that explain
    11+ * it. Its own stack first, then the stack that scheduled each task it ran in, nearest first.
    12+ */
    13+export interface RawCallSite {
    14+ traceparent: string;
    15+ stacks: string[];
    16+ /** Every header the page set on the request, the trace context included. */
    17+ headers?: Record<string, string>;
    18+}
    19+
    20+const BINDING = '__genseqCallSite';
    21+
    22+/** Enough for a router hop and a timer or two; a timer rearming itself must not chain for ever. */
    23+const MAX_CAUSES = 16;
    24+
    25+/**
    26+ * Runs in the page before any of its scripts. Serialized by Playwright, so it may only touch
    27+ * what it is handed and the browser's own globals.
    28+ *
    29+ * Installed first means wrapped last: zone.js and the OTel instrumentation both patch these
    30+ * same methods on top of this one, so by the time a request reaches `send` here OTel has
    31+ * already called `setRequestHeader('traceparent', …)` — and the stack still has everybody
    32+ * above it, down to the line of application code that started the call.
    33+ *
    34+ * Unless that code has already returned. `router.navigate` hops over a `Promise.resolve` in
    35+ * the router, so the request of a sort or a page change leaves from a stack of 226 frames with
    36+ * no application frame on it. V8's async stacks cannot help: zone.js drains its own microtask
    37+ * queue at the end of the click, so to V8 it is one synchronous task (measured with
    38+ * Debugger.setAsyncCallStackDepth: no async parent at all). The hop is only visible to Zone,
    39+ * which is why the stack is also taken wherever Zone schedules a task. An Error is cheap until
    40+ * its `.stack` is read, which happens only for a task a request ran in.
    41+ *
    42+ * Every header the page sets is taken on the way too: nothing else records them, and the
    43+ * request's detail panel shows them and replays the call with them (Victor, 10 Nov 2026).
    44+ * Taken here whole and filtered in Node (`replayableHeaders`), which a unit test can reach.
    45+ */
    46+function wrapRequestSenders({binding, maxCauses}: {binding: string; maxCauses: number}): void {
    47+ const page = globalThis as any;
    48+ // The default of 10 frames ends inside rxjs: a subscribe chain alone is deeper than that.
    49+ page.Error.stackTraceLimit = Infinity;
    50+
    51+ interface Cause {error: Error; up: Cause | null; depth: number}
    52+ const causes = new WeakMap<object, Cause>();
    53+ let running: Cause | null = null;
    54+
    55+ // A click is not caused by the code that registered its listener, so an event task has no
    56+ // cause and starts a chain of its own. Zone is trapped on its way in: it has to be hooked
    57+ // after polyfills.js defines it and before the app starts scheduling.
    58+ const followTasks = (Zone: any): void => {
    59+ const {scheduleTask, runTask} = Zone.prototype;
    60+ Zone.prototype.scheduleTask = function (this: object, task: any) {
    61+ if (task.type !== 'eventTask') {
    62+ const depth = (running?.depth ?? 0) + 1;
    63+ causes.set(task, {error: new page.Error(), up: depth < maxCauses ? running : null, depth});
    64+ }
    65+ return scheduleTask.call(this, task);
    66+ };
    67+ Zone.prototype.runTask = function (this: object, task: object, ...args: unknown[]) {
    68+ const outer = running;
    69+ running = causes.get(task) ?? null;
    70+ try {
    71+ return runTask.call(this, task, ...args);
    72+ } finally {
    73+ running = outer;
    74+ }
    75+ };
    76+ };
    77+ let zone: unknown;
    78+ Object.defineProperty(page, 'Zone', {
    79+ configurable: true,
    80+ get: () => zone,
    81+ set: (value) => {
    82+ zone = value;
    83+ followTasks(value);
    84+ },
    85+ });
    86+
    87+ // From the root zone, or the promise the binding returns would count as Angular work and
    88+ // the app would run an extra change detection for every request it made under test.
    89+ const report = (traceparent: string, headers: Record<string, string>): void => {
    90+ const stacks = [new page.Error().stack ?? ''];
    91+ for (let cause = running; cause; cause = cause.up) stacks.push(cause.error.stack ?? '');
    92+ const send = () => page[binding](traceparent, stacks, headers);
    93+ page.Zone?.root ? page.Zone.root.run(send) : send();
    94+ };
    95+ const traceparentOf = (headers: Record<string, string>): string | undefined =>
    96+ Object.entries(headers).find(([name]) => name.toLowerCase() === 'traceparent')?.[1];
    97+
    98+ const proto = page.XMLHttpRequest.prototype;
    99+ const sent = new WeakMap<object, Record<string, string>>();
    100+ // `open` starts a new request on the same object, and the spec drops its headers there too.
    101+ const open = proto.open;
    102+ proto.open = function (this: object, ...args: unknown[]) {
    103+ sent.delete(this);
    104+ return open.apply(this, args);
    105+ };
    106+ const setRequestHeader = proto.setRequestHeader;
    107+ proto.setRequestHeader = function (this: object, name: string, value: string) {
    108+ const headers = sent.get(this) ?? {};
    109+ // A header set twice goes out once, its values joined — the way the browser sends it.
    110+ headers[name] = Object.hasOwn(headers, name) ? `${headers[name]}, ${value}` : String(value);
    111+ sent.set(this, headers);
    112+ return setRequestHeader.call(this, name, value);
    113+ };
    114+ const send = proto.send;
    115+ proto.send = function (this: object, ...args: unknown[]) {
    116+ const headers = sent.get(this) ?? {};
    117+ const traceparent = traceparentOf(headers);
    118+ if (traceparent) report(traceparent, headers);
    119+ return send.apply(this, args);
    120+ };
    121+
    122+ const fetch = page.fetch;
    123+ page.fetch = function (this: unknown, input: any, init?: any) {
    124+ const headers: Record<string, string> = {};
    125+ new page.Headers(init?.headers ?? input?.headers ?? {})
    126+ .forEach((value: string, name: string) => {headers[name] = value;});
    127+ const traceparent = traceparentOf(headers);
    128+ if (traceparent) report(traceparent, headers);
    129+ return fetch.call(this, input, init);
    130+ };
    131+}
    132+
    133+/**
    134+ * From now on, every traced request this page sends is remembered with the stacks that sent
    135+ * it. A binding rather than an array on `window`: the array would die with the first full
    136+ * navigation, and a test may make several.
    137+ */
    138+export async function recordCallSites(page: Page): Promise<RawCallSite[]> {
    139+ const sites: RawCallSite[] = [];
    140+ await page.exposeFunction(BINDING, (traceparent: string, stacks: string[], headers?: Record<string, string>) => {
    141+ sites.push({traceparent, stacks, ...(headers ? {headers} : {})});
    142+ });
    143+ await page.addInitScript(wrapRequestSenders, {binding: BINDING, maxCauses: MAX_CAUSES});
    144+ return sites;
    145+}
    146+
    147+// Parsed once per process: one bundle's map is 7 MB and every test's stacks point into it.
    148+const maps = new Map<string, Promise<SourceMapConsumer | undefined>>();
    149+
    150+/**
    151+ * The map beside a script, over HTTP from the server that served it. The Docker build keeps
    152+ * its maps `hidden`, which only drops the `sourceMappingURL` comment: nginx serves
    153+ * `main.<hash>.js.map` next to the bundle, and `ng serve` does the same for `main.js`.
    154+ */
    155+function mapOf(script: string): Promise<SourceMapConsumer | undefined> {
    156+ let map = maps.get(script);
    157+ if (!map) {
    158+ map = fetch(`${script}.map`)
    159+ .then((response) => (response.ok ? response.json() : undefined))
    160+ .then((raw) => (raw ? new SourceMapConsumer(raw) : undefined))
    161+ .catch(() => undefined);
    162+ maps.set(script, map);
    163+ }
    164+ return map;
    165+}
    166+
    167+async function toCallSites(sites: RawCallSite[]): Promise<CallSite[]> {
    168+ const stacks = sites.flatMap((s) => s.stacks);
    169+ const scripts = [...new Set(stacks.flatMap((stack) => parseStack(stack).map((f) => f.url)))];
    170+ const loaded = new Map<string, SourceMapConsumer>();
    171+ for (const script of scripts) {
    172+ const map = await mapOf(script);
    173+ if (map) loaded.set(script, map);
    174+ }
    175+ return sites.flatMap((site) => {
    176+ const ids = idsOfTraceparent(site.traceparent);
    177+ const headers = replayableHeaders(site.headers);
    178+ return ids ? [{...ids, frames: requestFrames(site.stacks, loaded), ...(headers ? {headers} : {})}] : [];
    179+ });
    180+}
    181+
    182+/** A test's requests on disk. `source` is what `forgetWindowsOf` sorts files to their runner by. */
    183+type CallSiteFile = Pick<TestWindow, 'title' | 'source'> & {sites: CallSite[]};
    184+
    185+function readFileOf(file: string): CallSite[] {
    186+ try {
    187+ return (JSON.parse(fs.readFileSync(file, 'utf-8')) as CallSiteFile).sites ?? [];
    188+ } catch {
    189+ return []; // not there yet, or a run killed mid-write: that test's links, not every diagram's
    190+ }
    191+}
    192+
    193+/**
    194+ * One file per test, named as its trace window is, and added to: every row of a Scenario
    195+ * Outline runs under the same title. A runner clears its own at the start, with
    196+ * `forgetWindowsOf`, and only its own: the last diagram step of a review re-fetches both
    197+ * suites, and needs both suites' files.
    198+ *
    199+ * Translated here, while the stack that served the bundle is certainly still up. Best-effort,
    200+ * like the diagram it feeds: a request with no call site is an arrow with no link, never a
    201+ * red test.
    202+ */
    203+export async function saveCallSites(
    204+ dir: string, test: Pick<TestWindow, 'title' | 'source'>, sites: RawCallSite[],
    205+): Promise<void> {
    206+ try {
    207+ const translated = await toCallSites(sites);
    208+ fs.mkdirSync(dir, {recursive: true});
    209+ const file = path.join(dir, windowFileName(test));
    210+ const entry: CallSiteFile = {...test, sites: [...readFileOf(file), ...translated]};
    211+ fs.writeFileSync(file, JSON.stringify(entry, null, 2));
    212+ } catch (err) {
    213+ console.warn(`⚠️ Could not save the call sites of "${test.title}": ${(err as Error).message}`);
    214+ }
    215+}
    216+
    217+/** Every request on file. */
    218+export function readCallSites(dir: string): CallSite[] {
    219+ if (!fs.existsSync(dir)) return [];
    220+ return fs.readdirSync(dir).filter((name) => name.endsWith('.json'))
    221+ .flatMap((name) => readFileOf(path.join(dir, name)));
    222+}
     
    1+import axios from 'axios';
    2+
    3+// What the database holds when a test starts: the one thing a test must SAY rather than
    4+// inherit. A Playwright spec says it with `test.use({dbFixture: 'default'})` (or the name of a
    5+// db/fixtures/<name>.sql); a Cucumber feature or scenario with the tag `@fixture:default`.
    6+// Saying nothing means 'empty' — every table emptied, the reference rows (pet types,
    7+// specialties) written back — which is also what an undeclared Spring test starts on
    8+// (petclinic-backend/src/test/resources/config/application.properties).
    9+//
    10+// The reset itself is the review environment's sidecar (docker/reset), reached through the
    11+// frontend's nginx at /__reset — never the backend, which must not have such a route
    12+// (no-reset-endpoint.spec.ts). So it exists only on a docker stack: `ng serve` on :4200 has no
    13+// /__reset, and there a test runs on whatever the database holds, as every test did before.
    14+
    15+export type DbFixture = 'empty' | 'default' | string;
    16+
    17+/** Words of a tag or `dbFixture`, to the sidecar's route: Default is its root, the rest a name. */
    18+function route(fixture: DbFixture): string {
    19+ return fixture === 'default' || fixture === 'seed' ? '/__reset' : `/__reset/${fixture}`;
    20+}
    21+
    22+/**
    23+ * The fixture a Cucumber pickle declares with `@fixture:<name>`, else 'empty'. A pickle lists
    24+ * its Feature's tags before its own, so the last one wins: the scenario's tag beats the Feature's.
    25+ */
    26+export function fixtureOfTags(tags: readonly {name: string}[]): DbFixture {
    27+ const named = tags.map(t => /^@fixture[:=-]([a-z0-9][a-z0-9-]*)$/.exec(t.name)?.[1]).filter(Boolean);
    28+ return (named[named.length - 1] as string | undefined) ?? 'empty';
    29+}
    30+
    31+let warned = false;
    32+
    33+/**
    34+ * Puts the database on `fixture`. Returns false, once warned, when there is no sidecar to ask —
    35+ * the stack is not a docker one — and throws when there is one and the reset fails: a test that
    36+ * declared its data must not run on other data and pass for the wrong reason.
    37+ */
    38+export async function resetDatabaseTo(fixture: DbFixture): Promise<boolean> {
    39+ const base = (process.env.BASE_URL || 'http://127.0.0.1:4200').replace(/\/$/, '');
    40+ const res = await axios.post(base + route(fixture), {}, {
    41+ timeout: 30_000, validateStatus: () => true, responseType: 'text', transformResponse: r => r,
    42+ });
    43+ let body: any;
    44+ try { body = JSON.parse(res.data); } catch { body = undefined; }
    45+ if ((res.status === 404 && !body) || (res.status < 400 && body?.ok === undefined)) {
    46+ if (!warned) {
    47+ warned = true;
    48+ console.warn(`⚠️ no reset sidecar behind ${base}/__reset (not a docker stack): tests run on ` +
    49+ `whatever the database holds, not on the DB fixture they declare.`);
    50+ }
    51+ return false;
    52+ }
    53+ if (!body?.ok) {
    54+ throw new Error(`reset to DB fixture '${fixture}' failed (${res.status}): ${body?.error ?? res.data}`);
    55+ }
    56+ return true;
    57+}
    export default async function globalSetup(): Promise<void> {
    1212 path.join(__dirname, '..', '..', 'test-results', 'trace-windows'),
    1313 PLAYWRIGHT_SOURCES,
    1414 );
    15+ forgetWindowsOf(
    16+ path.join(__dirname, '..', '..', 'test-results', 'call-sites'),
    17+ PLAYWRIGHT_SOURCES,
    18+ );
    1519 // Per-test coverage for /human-review (support/coverage.ts): a clean folder per run.
    1620 startCoverageRun('playwright');
    1721 }
     
    11 import {execFileSync} from 'child_process';
    22 import * as path from 'path';
    33 import {runGenerate, PLAYWRIGHT_SOURCES} from '../genseq/generate';
    4+import {resetDatabaseTo} from './db-fixture';
    45
    56 // Pull the acceptance suite's coverage out of the backend JVM while it is STILL RUNNING.
    67 // That JVM is the one the browser exercised, and surefire's JaCoCo agent never sees it —
    function dumpAcceptanceCoverage(): void {
    5455 export default async function globalTeardown(): Promise<void> {
    5556 dumpAcceptanceCoverage();
    5657 await runGenerate(PLAYWRIGHT_SOURCES);
    58+ // Each test reset the database to the fixture it declares; leave the stack on Default, the
    59+ // dataset the app's demo comes up in, rather than on whatever the last test asked for.
    60+ await resetDatabaseTo('default').catch(e => console.warn(`could not put the stack back on Default: ${e}`));
    5761 }
    import {appendWindow} from './trace-window-store';
    44 import {flushBrowserSpans} from './otel-flush';
    55 import {isTagged, shouldGenerateSequence} from '../genseq/sequence-tag';
    66 import {startTestCoverage, stopTestCoverage} from './coverage';
    7+import {DbFixture, resetDatabaseTo} from './db-fixture';
    8+import {recordCallSites, saveCallSites} from './call-sites';
    79
    810 // The Playwright counterpart of src/glue/world.ts: it honours the very same
    911 // @generate_sequence opt-in, only read from Playwright's test tags instead of
    import {startTestCoverage, stopTestCoverage} from './coverage';
    1214 // window, so no .puml is produced for it.
    1315
    1416 const WINDOWS_DIR = path.join(__dirname, '..', '..', 'test-results', 'trace-windows');
    17+const CALL_SITES_DIR = path.join(__dirname, '..', '..', 'test-results', 'call-sites');
    1518
    1619 // Pads the recorded window so the BatchSpanProcessor's async export (and Tempo
    1720 // ingestion lag) still falls inside the search range.
    1821 const PRE_PAD_MS = 1_000;
    1922 const POST_PAD_MS = 5_000;
    2023
    21-export const test = base.extend({
    22- page: async ({page}, use, testInfo) => {
    24+export const test = base.extend<{dbFixture: DbFixture; database: void}>({
    25+ // The data the test starts on (support/db-fixture.ts): 'empty' unless the spec says
    26+ // `test.use({dbFixture: 'default'})` — or the name of a db/fixtures/<name>.sql.
    27+ dbFixture: ['empty', {option: true}],
    28+ // Reset before EVERY test, auto so a test with no page (no-reset-endpoint.spec.ts) gets it too.
    29+ // That is only correct with one test at a time: the reset empties the one database every
    30+ // worker shares, so playwright.config.ts runs the suite on a single worker.
    31+ database: [async ({dbFixture}, use) => {
    32+ await resetDatabaseTo(dbFixture);
    33+ await use();
    34+ }, {auto: true}],
    35+ // `database` first: the trace window and the coverage dump open after the reset, never on it.
    36+ page: async ({page, database}, use, testInfo) => {
    37+ void database;
    2338 // Per-test coverage for /human-review (support/coverage.ts). Inert without
    2439 // COVERAGE_DIR; around the body so a traced test and an untraced one are measured alike.
    2540 await startTestCoverage(page);
    export const test = base.extend({
    4459 (globalThis as any).__E2E_TEST_NAME__ = name;
    4560 }, testInfo.title);
    4661
    62+ // Which line of the frontend sent each request (support/call-sites.ts).
    63+ const callSites = await recordCallSites(page);
    64+ const source = path.relative(path.join(__dirname, '..', '..'), testInfo.file);
    65+
    4766 const startMs = Date.now() - PRE_PAD_MS;
    4867 await use(page);
    4968 await harvest();
    5069 await flushBrowserSpans(page);
    70+ await saveCallSites(CALL_SITES_DIR, {title: testInfo.title, source}, callSites);
    5171 appendWindow(WINDOWS_DIR, {
    5272 title: testInfo.title,
    53- source: path.relative(path.join(__dirname, '..', '..'), testInfo.file),
    73+ source,
    5474 startMs,
    5575 endMs: Date.now() + POST_PAD_MS,
    5676 // Named by GENSEQ_SELECT, untagged: the diagram's footer must not claim a tag.
    export const test = base.extend({
    6080 });
    6181
    6282 export {expect} from '@playwright/test';
    83+export type {Page} from '@playwright/test';
    world.ts+15 −0
    import {isTagged, shouldGenerateSequence} from '../genseq/sequence-tag';
    1111 import {runGenerate, CUCUMBER_SOURCES} from '../genseq/generate';
    1212 import {startCoverageRun, startTestCoverage, stopTestCoverage} from './coverage';
    1313 import {keepCaptionAcrossLoads, showCaption} from './captions';
    14+import {fixtureOfTags, resetDatabaseTo} from './db-fixture';
    15+import {RawCallSite, recordCallSites, saveCallSites} from './call-sites';
    1416
    1517 setDefaultTimeout(60_000);
    1618
    1719 const WINDOWS_DIR = path.join(__dirname, '..', '..', 'test-results', 'trace-windows');
    20+const CALL_SITES_DIR = path.join(__dirname, '..', '..', 'test-results', 'call-sites');
    1821
    1922 // Playwright's own recording of each scenario — screenshots, DOM snapshots, console,
    2023 // network — the same zip `trace: 'on'` leaves behind for a *.spec.ts. Cucumber runs
    export class PlaywrightWorld extends World {
    5659 // Named by GENSEQ_SELECT, untagged: the diagram's footer must not claim a tag.
    5760 traceSelected?: boolean;
    5861 traceStartMs?: number;
    62+ // Which line of the frontend sent each of those requests (support/call-sites.ts).
    63+ callSites?: RawCallSite[];
    5964 // Set for every scenario when PW_TRACE=on: where its recording lands, and when it began.
    6065 recordingZip?: string;
    6166 recordingStartMs?: number;
    setWorldConstructor(PlaywrightWorld);
    8590 // of its own source files, and the other suite's are none of its business.
    8691 BeforeAll(function () {
    8792 forgetWindowsOf(WINDOWS_DIR, CUCUMBER_SOURCES);
    93+ forgetWindowsOf(CALL_SITES_DIR, CUCUMBER_SOURCES);
    8894 // Per-test coverage for /human-review (support/coverage.ts): a clean folder per run.
    8995 startCoverageRun('cucumber');
    9096 // A clean slate per run, like the report the Playwright suite rewrites: a recording
    BeforeAll(function () {
    96102 });
    97103
    98104 Before(async function (this: PlaywrightWorld, {pickle}: ITestCaseHookParameter) {
    105+ // The data the scenario starts on (db-fixture.ts): `@fixture:<name>` on it or on its
    106+ // Feature, else 'empty'. Every scenario, before anything is recorded — the suite runs
    107+ // one scenario at a time, so resetting the shared database never pulls rows from under another.
    108+ await resetDatabaseTo(fixtureOfTags(pickle.tags));
    99109 this.browser = await chromium.launch({
    100110 headless: !process.env.HEADED,
    101111 slowMo: VIDEO_ON ? Number(process.env.PW_SLOWMO ?? 400) : undefined,
    Before(async function (this: PlaywrightWorld, {pickle}: ITestCaseHookParameter)
    125135 await this.page.addInitScript((name) => {
    126136 (globalThis as any).__E2E_TEST_NAME__ = name;
    127137 }, pickle.name);
    138+ this.callSites = await recordCallSites(this.page);
    128139 this.traceStartMs = Date.now() - PRE_PAD_MS;
    129140 }
    130141 });
    After(async function (this: PlaywrightWorld, {pickle, gherkinDocument, result}:
    166177 }
    167178 if (this.traceTitle && this.traceStartMs !== undefined) {
    168179 await flushBrowserSpans(this.page);
    180+ await saveCallSites(CALL_SITES_DIR,
    181+ {title: this.traceTitle, source: this.traceSource!}, this.callSites ?? []);
    169182 appendWindow(WINDOWS_DIR, {
    170183 title: this.traceTitle,
    171184 source: this.traceSource!,
    function recordingSlug(uri: string, name: string): string {
    214227 // runGenerate() never throws, so a telemetry hiccup can't fail the suite.
    215228 AfterAll(async function () {
    216229 await runGenerate(CUCUMBER_SOURCES);
    230+ // Back on Default, the dataset the app's demo comes up in, whatever the last scenario declared.
    231+ await resetDatabaseTo('default').catch(e => console.warn(`could not put the stack back on Default: ${e}`));
    217232 });
     
    1+# Books onto an owner's pet and picks among vets that are already there: the Default dataset's.
    2+@fixture:default
    3+Feature: Choose the vet who attends a visit
    4+ As a clinic user
    5+ I want to say which vet will attend a visit when I book it
    6+ So that the owner's page and the visits screen show who will see the pet
    7+
    8+ Background:
    9+ Given an owner with at least one pet
    10+
    11+ Scenario: Book a visit attended by a vet, and see the vet on the owner page and on all visits
    12+ When I open that owner's page
    13+ And I start a new visit for their first pet
    14+ And I date the visit "2026-05-12" and describe it uniquely
    15+ And I choose the first vet the form offers
    16+ And I submit the visit
    17+ Then I am back on that owner's page
    18+ And the visit on the owner's page reads the chosen vet
    19+ When I open the all visits screen
    20+ Then the visit on the all visits screen reads the chosen vet
    21+
    22+ Scenario: Book a visit with nobody yet as its vet, and see it read "No vet" everywhere
    23+ When I open that owner's page
    24+ And I start a new visit for their first pet
    25+ Then the form's vet field reads "No vet"
    26+ When I date the visit "2026-05-12" and describe it uniquely
    27+ And I submit the visit
    28+ Then I am back on that owner's page
    29+ And the visit on the owner's page reads "No vet"
    30+ When I open the all visits screen
    31+ Then the visit on the all visits screen reads "No vet"
     
    1+import {Given, When, Then} from '@cucumber/cucumber';
    2+import {PlaywrightWorld} from './support/world';
    3+import {
    4+ an_owner_with_at_least_one_pet_exists,
    5+ click_add_visit_for_first_pet,
    6+ expect_all_visits_screen_shows_vet,
    7+ expect_back_on_owner_detail_page,
    8+ expect_pet_visit_list_shows_vet,
    9+ expect_visit_form_offers_nobody_yet,
    10+ fill_visit_date_and_unique_description,
    11+ open_all_visits_screen,
    12+ open_owner_detail_page,
    13+ select_first_vet_in_visit_form,
    14+ submit_visit_form,
    15+} from './add-visit.dsl';
    16+
    17+// Gherkin over the sentences add-visit.dsl.ts already has: the browser work and every
    18+// selector stay there, shared with add-visit.spec.ts, and each step below only binds a
    19+// line of visit-vet.feature to one of them. The functions are imported raw, not through
    20+// narrate(): a scenario's steps are its Gherkin lines, so nothing here stamps a sentence.
    21+//
    22+// What a scenario learns on the way — the owner, the visit's date and description, the vet
    23+// it chose — is kept on the World, so a later Then can find that exact row again.
    24+
    25+type VisitWorld = PlaywrightWorld & {visitDate?: string; chosenVet?: string};
    26+
    27+function known<T>(value: T | undefined, what: string): T {
    28+ if (value === undefined) {
    29+ throw new Error(`Expected ${what} to have been set by an earlier step of the scenario`);
    30+ }
    31+ return value;
    32+}
    33+
    34+Given('an owner with at least one pet', async function (this: VisitWorld) {
    35+ const {ownerId, petId} = await an_owner_with_at_least_one_pet_exists();
    36+ this.ownerId = ownerId;
    37+ this.petId = petId;
    38+});
    39+
    40+When('I open that owner\'s page', async function (this: VisitWorld) {
    41+ await open_owner_detail_page(this.page, known(this.ownerId, 'the owner'));
    42+});
    43+
    44+When('I start a new visit for their first pet', async function (this: VisitWorld) {
    45+ await click_add_visit_for_first_pet(this.page, 'Add Visit');
    46+});
    47+
    48+When('I date the visit {string} and describe it uniquely', async function (this: VisitWorld, date: string) {
    49+ this.visitDate = date;
    50+ this.visitDescription = await fill_visit_date_and_unique_description(this.page, date);
    51+});
    52+
    53+When('I choose the first vet the form offers', async function (this: VisitWorld) {
    54+ this.chosenVet = await select_first_vet_in_visit_form(this.page);
    55+});
    56+
    57+When('I submit the visit', async function (this: VisitWorld) {
    58+ await submit_visit_form(this.page);
    59+});
    60+
    61+When('I open the all visits screen', async function (this: VisitWorld) {
    62+ await open_all_visits_screen(this.page);
    63+});
    64+
    65+Then('the form\'s vet field reads "No vet"', async function (this: VisitWorld) {
    66+ await expect_visit_form_offers_nobody_yet(this.page);
    67+});
    68+
    69+Then('I am back on that owner\'s page', async function (this: VisitWorld) {
    70+ await expect_back_on_owner_detail_page(this.page, known(this.ownerId, 'the owner'));
    71+});
    72+
    73+Then('the visit on the owner\'s page reads the chosen vet', async function (this: VisitWorld) {
    74+ await visitOnOwnerPageReads(this, known(this.chosenVet, 'the chosen vet'));
    75+});
    76+
    77+Then('the visit on the owner\'s page reads {string}', async function (this: VisitWorld, vet: string) {
    78+ await visitOnOwnerPageReads(this, vet);
    79+});
    80+
    81+Then('the visit on the all visits screen reads the chosen vet', async function (this: VisitWorld) {
    82+ await visitOnAllVisitsReads(this, known(this.chosenVet, 'the chosen vet'));
    83+});
    84+
    85+Then('the visit on the all visits screen reads {string}', async function (this: VisitWorld, vet: string) {
    86+ await visitOnAllVisitsReads(this, vet);
    87+});
    88+
    89+async function visitOnOwnerPageReads(world: VisitWorld, vet: string): Promise<void> {
    90+ await expect_pet_visit_list_shows_vet(world.page, known(world.visitDate, 'the visit date'),
    91+ known(world.visitDescription, 'the visit description'), vet);
    92+}
    93+
    94+async function visitOnAllVisitsReads(world: VisitWorld, vet: string): Promise<void> {
    95+ await expect_all_visits_screen_shows_vet(world.page, known(world.visitDescription, 'the visit description'), vet);
    96+}
    import * as path from 'path';
    44 import {VisitsPage} from './pages/VisitsPage';
    55 import {ApiClient, VisitDto} from './support/api-client';
    66
    7+// Reads the seed's visits: the page must have rows to list, sort and link from.
    8+test.use({dbFixture: 'default'});
    9+
    710 test.describe('Visits Page', () => {
    811 let apiClient: ApiClient;
    912 let screenshotDir: string;
     
    1+{
    2+ "name": "petclinic-local",
    3+ "owner": {
    4+ "name": "Victor Rentea"
    5+ },
    6+ "metadata": {
    7+ "description": "Local marketplace demo: one folder, no git, shared by every petclinic service"
    8+ },
    9+ "plugins": [
    10+ {
    11+ "name": "petclinic-tools",
    12+ "description": "Skills shared by every petclinic service, installed instead of copied",
    13+ "source": "./plugins/petclinic-tools"
    14+ }
    15+ ]
    16+}
    README.md+17 −0
     
    1+# Demo: a plugin marketplace from a plain folder
    2+
    3+No git, no hosting: this folder is a marketplace (`.claude-plugin/marketplace.json`) listing one
    4+plugin, `petclinic-tools`, which carries one skill, `grill-me`. The same folder installs in all three
    5+harnesses. Run from the repo root:
    6+
    7+| Harness | Install | Use |
    8+| --- | --- | --- |
    9+| Claude Code | `claude plugin marketplace add ./plugin-marketplace-demo`<br>`claude plugin install petclinic-tools@petclinic-local` | `/petclinic-tools:grill-me <plan>` |
    10+| Copilot CLI | `copilot plugin marketplace add ./plugin-marketplace-demo`<br>`copilot plugin install petclinic-tools@petclinic-local` | `/petclinic-tools:grill-me <plan>` |
    11+| Codex | `codex plugin marketplace add ./plugin-marketplace-demo`<br>`codex plugin add petclinic-tools@petclinic-local` | `$petclinic-tools:grill-me <plan>` |
    12+
    13+Then start the agent in `petclinic-backend/` and in `notification-service/`: the skill is there in
    14+both, although no `.claude/skills/` holds it any more.
    15+
    16+Claude Code and Copilot read the plugin live from this folder; Codex copies it into its cache, so
    17+after an edit run `codex plugin add` again.
     
    1+{
    2+ "name": "petclinic-tools",
    3+ "description": "Skills shared by every petclinic service, installed instead of copied",
    4+ "author": { "name": "Victor Rentea" }
    5+}
    SKILL.md+12 −0
     
    1+---
    2+name: grill-me
    3+description: Interview the user relentlessly about a plan, one question at a time, until there is a shared understanding.
    4+disable-model-invocation: true
    5+---
    6+
    7+Interview me relentlessly about every aspect of this plan until we reach a shared understanding.
    8+Walk down each branch of the design tree, resolving dependencies between decisions one-by-one.
    9+For each question, provide your recommended answer.
    10+Ask the questions one at a time, waiting for feedback on each question before continuing. Asking multiple questions at once is bewildering😵‍💫.
    11+If a question can be answered by exploring the environment (filesystem, tools, etc), look it up rather than asking me.
    12+The decisions, though, are mine — ask one to me and wait for my answer.
    README.md+102 −0
     
    1+# refactoring-tools
    2+
    3+A **self-contained** [OpenRewrite](https://docs.openrewrite.org/) module. It does **not** touch the
    4+backend build in any way — `petclinic-backend/pom.xml` has no OpenRewrite config. You run the recipes
    5+against the backend from the command line, passing this module as a recipe artifact (see below).
    6+
    7+It ships two recipes, deliberately of the two different kinds:
    8+
    9+| Recipe | Kind | What it does |
    10+|---|---|---|
    11+| `InlineSingleUseLocalVariable` | **Imperative** (`Recipe` + visitor) | Inlines a local variable used exactly once |
    12+| `ListGetFirst` → `ListGetFirstRecipe` | **Refaster template** (before/after) | Rewrites `list.get(0)` to `list.getFirst()` |
    13+
    14+## Why two kinds — and why the inline one can't be Refaster
    15+
    16+A **Refaster template** is the simplest kind to read: you write a `@BeforeTemplate` snippet and an
    17+`@AfterTemplate` snippet, and the `rewrite-templating` annotation processor turns them into a recipe
    18+at build time. It is a **typed, whitespace-agnostic, one-expression-in / one-expression-out**
    19+replacement. `ListGetFirst.java` is the whole recipe:
    20+
    21+```java
    22+@RecipeDescriptor(name = "...", description = "...")
    23+public class ListGetFirst<T> {
    24+ @BeforeTemplate T getZero(List<T> list) { return list.get(0); }
    25+ @AfterTemplate T getFirst(List<T> list) { return list.getFirst(); }
    26+}
    27+```
    28+
    29+The **inline-single-use** refactoring **cannot** be expressed that way. Refaster matches one
    30+expression and rewrites it in place; it can't:
    31+- **count** how many times a variable is used (the whole safety rule is "exactly one use"),
    32+- **delete** the declaration statement while editing a *different* statement,
    33+- reason about statement **order**, loops, lambdas or conditionals.
    34+
    35+That needs data-flow + multi-statement surgery, which is exactly what an **imperative** recipe (a
    36+`Recipe` with a `JavaIsoVisitor`) is for. So it stays imperative — see
    37+`InlineSingleUseLocalVariable.java`.
    38+
    39+## `InlineSingleUseLocalVariable` — the safety rules
    40+
    41+Inlines `T v = expr;` into its use **only** when all hold, so behaviour can never change:
    42+1. Single-variable declaration with an initializer.
    43+2. The initializer is a high-precedence expression (method call, `new`, field/array access, literal,
    44+ identifier, parenthesized) — safe to drop in without parentheses.
    45+3. The variable is **read exactly once**. Two+ reads (e.g. `save(type); … type.getId()`) are never
    46+ inlined — that would evaluate the initializer twice. Zero reads are left alone (dead code).
    47+4. That single read is in the **immediately following statement**, reached only through
    48+ side-effect-transparent nodes — so no reordering, and never inside a loop/lambda/conditional.
    49+
    50+## `ListGetFirst` — note
    51+
    52+On an empty list `get(0)` throws `IndexOutOfBoundsException` while `getFirst()` throws
    53+`NoSuchElementException`; both mean "no first element". `getFirst()` comes from Java 21's
    54+`SequencedCollection`.
    55+
    56+## Build & test
    57+
    58+```sh
    59+cd refactoring-tools
    60+mvn test # 17 tests: 13 for the imperative recipe, 4 for the Refaster one
    61+mvn install # publishes victor.training.agentic:refactoring-tools:1.0 to your ~/.m2
    62+```
    63+
    64+The Refaster recipe class `ListGetFirstRecipe` is **generated** at build time by the
    65+`rewrite-templating` annotation processor from `ListGetFirst` (look in
    66+`target/generated-sources/annotations/…` after a build).
    67+
    68+## Run against the backend — no backend pom changes
    69+
    70+Because nothing is wired into `petclinic-backend/pom.xml`, invoke the plugin ad-hoc and hand it this
    71+module as a recipe artifact. Run from the backend directory:
    72+
    73+```sh
    74+cd petclinic-backend
    75+
    76+# Preview only — writes target/rewrite/rewrite.patch, changes no sources:
    77+mvn org.openrewrite.maven:rewrite-maven-plugin:6.44.0:dryRun \
    78+ -Drewrite.recipeArtifactCoordinates=victor.training.agentic:refactoring-tools:1.0 \
    79+ -Drewrite.activeRecipes=victor.training.petclinic.rewrite.ListGetFirstRecipe,victor.training.petclinic.rewrite.InlineSingleUseLocalVariable
    80+
    81+# Apply in place (review with `git diff`, undo with `git checkout .`):
    82+mvn org.openrewrite.maven:rewrite-maven-plugin:6.44.0:run \
    83+ -Drewrite.recipeArtifactCoordinates=victor.training.agentic:refactoring-tools:1.0 \
    84+ -Drewrite.activeRecipes=victor.training.petclinic.rewrite.ListGetFirstRecipe,victor.training.petclinic.rewrite.InlineSingleUseLocalVariable
    85+```
    86+
    87+Drop either name from `-Drewrite.activeRecipes` to run just one recipe.
    88+
    89+### Watch the imperative recipe reproduce the exact hand-refactoring
    90+
    91+`getPetType` in `PetTypeRestController` is already inlined by hand, so put the variable back first and
    92+let the recipe redo it:
    93+
    94+```sh
    95+cd petclinic-clone
    96+git checkout petclinic-backend/src/main/java/victor/training/petclinic/rest/PetTypeRestController.java
    97+cd petclinic-backend
    98+mvn org.openrewrite.maven:rewrite-maven-plugin:6.44.0:run \
    99+ -Drewrite.recipeArtifactCoordinates=victor.training.agentic:refactoring-tools:1.0 \
    100+ -Drewrite.activeRecipes=victor.training.petclinic.rewrite.InlineSingleUseLocalVariable
    101+git diff # getPetType inlined again — by the recipe; `type` (2 uses) left untouched
    102+```
    pom.xml+133 −0
     
    1+<?xml version="1.0" encoding="UTF-8"?>
    2+<project xmlns="http://maven.apache.org/POM/4.0.0"
    3+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
    4+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    5+ <modelVersion>4.0.0</modelVersion>
    6+
    7+ <groupId>victor.training.agentic</groupId>
    8+ <artifactId>refactoring-tools</artifactId>
    9+ <version>1.0</version>
    10+
    11+ <name>Refactoring Legacy</name>
    12+ <description>
    13+ OpenRewrite recipes that reproduce, programmatically, refactorings we do by hand in this repo.
    14+ Two flavours:
    15+ - InlineSingleUseLocalVariable : a full imperative recipe (needs data-flow, so it MUST be imperative)
    16+ - ListGetFirst : a Refaster template recipe (a simple before/after expression swap)
    17+ </description>
    18+
    19+ <properties>
    20+ <maven.compiler.release>21</maven.compiler.release>
    21+ <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
    22+
    23+ <rewrite-recipe-bom.version>3.35.0</rewrite-recipe-bom.version>
    24+ <rewrite-templating.version>1.42.0</rewrite-templating.version>
    25+ <error-prone.version>2.36.0</error-prone.version>
    26+ <junit.version>5.11.4</junit.version>
    27+ </properties>
    28+
    29+ <dependencyManagement>
    30+ <dependencies>
    31+ <!-- Aligns the versions of every org.openrewrite.* artifact we use. -->
    32+ <dependency>
    33+ <groupId>org.openrewrite.recipe</groupId>
    34+ <artifactId>rewrite-recipe-bom</artifactId>
    35+ <version>${rewrite-recipe-bom.version}</version>
    36+ <type>pom</type>
    37+ <scope>import</scope>
    38+ </dependency>
    39+ <!-- Forces a single JUnit 5 platform/jupiter version across the whole test classpath
    40+ (rewrite-test and junit-jupiter otherwise drag in mismatched platform jars). -->
    41+ <dependency>
    42+ <groupId>org.junit</groupId>
    43+ <artifactId>junit-bom</artifactId>
    44+ <version>${junit.version}</version>
    45+ <type>pom</type>
    46+ <scope>import</scope>
    47+ </dependency>
    48+ </dependencies>
    49+ </dependencyManagement>
    50+
    51+ <dependencies>
    52+ <!-- The Java LST model + visitor API we write the recipe against. -->
    53+ <dependency>
    54+ <groupId>org.openrewrite</groupId>
    55+ <artifactId>rewrite-java</artifactId>
    56+ </dependency>
    57+
    58+ <!-- ===== Refaster template support (the "refactor recipe" flavour) ===== -->
    59+ <!-- The annotation processor that turns @BeforeTemplate/@AfterTemplate classes into recipes. -->
    60+ <dependency>
    61+ <groupId>org.openrewrite</groupId>
    62+ <artifactId>rewrite-templating</artifactId>
    63+ </dependency>
    64+ <!-- Supplies the @BeforeTemplate / @AfterTemplate annotations only (compile-time). -->
    65+ <dependency>
    66+ <groupId>com.google.errorprone</groupId>
    67+ <artifactId>error_prone_core</artifactId>
    68+ <version>${error-prone.version}</version>
    69+ <scope>provided</scope>
    70+ <exclusions>
    71+ <exclusion>
    72+ <groupId>com.google.auto.service</groupId>
    73+ <artifactId>auto-service-annotations</artifactId>
    74+ </exclusion>
    75+ </exclusions>
    76+ </dependency>
    77+ <!-- Provides jakarta.annotation.Generated (javax.annotation.Generated was removed in Java 9+). -->
    78+ <dependency>
    79+ <groupId>jakarta.annotation</groupId>
    80+ <artifactId>jakarta.annotation-api</artifactId>
    81+ <version>3.0.0</version>
    82+ </dependency>
    83+
    84+ <!-- RewriteTest harness: before/after fixtures with full type attribution. -->
    85+ <dependency>
    86+ <groupId>org.openrewrite</groupId>
    87+ <artifactId>rewrite-test</artifactId>
    88+ <scope>test</scope>
    89+ </dependency>
    90+ <!-- Java 21 parser backend used by the test harness to build typed LSTs. -->
    91+ <dependency>
    92+ <groupId>org.openrewrite</groupId>
    93+ <artifactId>rewrite-java-21</artifactId>
    94+ <scope>test</scope>
    95+ </dependency>
    96+ <dependency>
    97+ <groupId>org.junit.jupiter</groupId>
    98+ <artifactId>junit-jupiter</artifactId>
    99+ <scope>test</scope>
    100+ </dependency>
    101+ </dependencies>
    102+
    103+ <build>
    104+ <plugins>
    105+ <plugin>
    106+ <groupId>org.apache.maven.plugins</groupId>
    107+ <artifactId>maven-compiler-plugin</artifactId>
    108+ <version>3.15.0</version>
    109+ <configuration>
    110+ <!-- keep parameter names so Refaster templates read naturally -->
    111+ <compilerArgs>
    112+ <arg>-parameters</arg>
    113+ <!-- generated recipe classes use jakarta.annotation.Generated on Java 9+ -->
    114+ <arg>-Arewrite.generatedAnnotation=jakarta.annotation.Generated</arg>
    115+ </compilerArgs>
    116+ <annotationProcessorPaths>
    117+ <!-- generates <TemplateClass>Recipe from @BeforeTemplate/@AfterTemplate -->
    118+ <path>
    119+ <groupId>org.openrewrite</groupId>
    120+ <artifactId>rewrite-templating</artifactId>
    121+ <version>${rewrite-templating.version}</version>
    122+ </path>
    123+ </annotationProcessorPaths>
    124+ </configuration>
    125+ </plugin>
    126+ <plugin>
    127+ <groupId>org.apache.maven.plugins</groupId>
    128+ <artifactId>maven-surefire-plugin</artifactId>
    129+ <version>3.5.2</version>
    130+ </plugin>
    131+ </plugins>
    132+ </build>
    133+</project>
     
    1+package victor.training.petclinic.rewrite;
    2+
    3+import org.openrewrite.Cursor;
    4+import org.openrewrite.ExecutionContext;
    5+import org.openrewrite.Recipe;
    6+import org.openrewrite.TreeVisitor;
    7+import org.openrewrite.java.JavaIsoVisitor;
    8+import org.openrewrite.java.JavaVisitor;
    9+import org.openrewrite.java.tree.Expression;
    10+import org.openrewrite.java.tree.J;
    11+import org.openrewrite.java.tree.JavaType;
    12+import org.openrewrite.java.tree.Statement;
    13+
    14+import java.util.ArrayList;
    15+import java.util.List;
    16+
    17+/**
    18+ * Inlines a local variable into its single use, e.g.
    19+ *
    20+ * <pre>{@code
    21+ * PetType petType = petTypeRepository.findById(petTypeId).orElseThrow();
    22+ * return petTypeMapper.toPetTypeDto(petType);
    23+ * }</pre>
    24+ *
    25+ * becomes
    26+ *
    27+ * <pre>{@code
    28+ * return petTypeMapper.toPetTypeDto(petTypeRepository.findById(petTypeId).orElseThrow());
    29+ * }</pre>
    30+ *
    31+ * <h2>Why the guards matter</h2>
    32+ * The whole point of the recipe is to <em>never</em> evaluate the initializer more than once.
    33+ * A variable therefore qualifies only when ALL of the following hold:
    34+ * <ol>
    35+ * <li>It is a single-variable declaration with an initializer.</li>
    36+ * <li>The initializer is a high-precedence expression (method call, {@code new}, field/array
    37+ * access, literal, identifier or a parenthesized expression) so it can be dropped verbatim
    38+ * into another expression without needing parentheses and without changing operator precedence.</li>
    39+ * <li>The variable is <strong>read exactly once</strong> in its scope. Two or more uses (as in
    40+ * {@code save(type); ...; type.getId()}) are left untouched, because inlining would re-evaluate
    41+ * the initializer. Zero uses are left untouched too (that is a different concern: dead code).</li>
    42+ * <li>That single read lives inside the <strong>immediately following statement</strong> and is reached
    43+ * only through side-effect-transparent expression nodes. This guarantees the initializer runs
    44+ * exactly once, unconditionally, in the same order as before — no reordering across intervening
    45+ * statements, and never inside a loop, lambda, conditional or short-circuit that could repeat,
    46+ * defer or skip it.</li>
    47+ * </ol>
    48+ */
    49+public class InlineSingleUseLocalVariable extends Recipe {
    50+
    51+ @Override
    52+ public String getDisplayName() {
    53+ return "Inline a local variable used exactly once";
    54+ }
    55+
    56+ @Override
    57+ public String getDescription() {
    58+ return "Replaces the single read of a local variable with its initializer and removes the " +
    59+ "declaration, but only when the variable is used exactly once so the initializer is " +
    60+ "never evaluated more than once. Variables used two or more times are left as-is.";
    61+ }
    62+
    63+ @Override
    64+ public TreeVisitor<?, ExecutionContext> getVisitor() {
    65+ return new JavaIsoVisitor<ExecutionContext>() {
    66+ @Override
    67+ public J.Block visitBlock(J.Block block, ExecutionContext ctx) {
    68+ J.Block b = super.visitBlock(block, ctx);
    69+ List<Statement> statements = b.getStatements();
    70+
    71+ // Inline at most one variable per pass; the recipe re-runs to convergence, which keeps
    72+ // chained/interdependent inlines from corrupting each other within a single pass.
    73+ for (int i = 0; i + 1 < statements.size(); i++) {
    74+ if (!(statements.get(i) instanceof J.VariableDeclarations vd)) {
    75+ continue;
    76+ }
    77+ if (vd.getVariables().size() != 1) {
    78+ continue; // keep it simple: only single-variable declarations
    79+ }
    80+ J.VariableDeclarations.NamedVariable declared = vd.getVariables().get(0);
    81+ Expression initializer = declared.getInitializer();
    82+ JavaType.Variable declaredType = declared.getVariableType();
    83+ if (initializer == null || declaredType == null || !isSafeToInline(initializer)) {
    84+ continue;
    85+ }
    86+
    87+ List<Cursor> reads = findReferences(b, declaredType, declared.getName());
    88+ if (reads.size() != 1) {
    89+ continue; // 0 uses (dead code) or 2+ uses (would re-evaluate) => leave alone
    90+ }
    91+ Cursor use = reads.get(0);
    92+ if (isWriteTarget(use)) {
    93+ continue; // the single mention is an assignment/increment target, not a read
    94+ }
    95+
    96+ Statement nextStatement = statements.get(i + 1);
    97+ if (!executesExactlyOnceWithin(use, nextStatement)) {
    98+ continue;
    99+ }
    100+
    101+ Statement inlinedNext = (Statement) new ReplaceIdentifier(use.getValue(), initializer)
    102+ .visitNonNull(nextStatement, ctx);
    103+ List<Statement> rewritten = new ArrayList<>(statements);
    104+ rewritten.set(i + 1, inlinedNext);
    105+ rewritten.remove(i);
    106+ return b.withStatements(rewritten);
    107+ }
    108+ return b;
    109+ }
    110+ };
    111+ }
    112+
    113+ /**
    114+ * Only inline initializers that are "primary"/postfix expressions: they carry the highest
    115+ * precedence in Java, so substituting them into any surrounding expression cannot change how that
    116+ * expression parses and never needs extra parentheses.
    117+ */
    118+ private static boolean isSafeToInline(Expression e) {
    119+ return e instanceof J.MethodInvocation
    120+ || e instanceof J.NewClass
    121+ || e instanceof J.NewArray
    122+ || e instanceof J.FieldAccess
    123+ || e instanceof J.ArrayAccess
    124+ || e instanceof J.Identifier
    125+ || e instanceof J.Literal
    126+ || e instanceof J.Parentheses;
    127+ }
    128+
    129+ /** Collect every read/write mention of {@code target} inside {@code scope}, excluding the declaration name. */
    130+ private static List<Cursor> findReferences(J scope, JavaType.Variable target, J.Identifier declarationName) {
    131+ List<Cursor> found = new ArrayList<>();
    132+ new JavaIsoVisitor<List<Cursor>>() {
    133+ @Override
    134+ public J.Identifier visitIdentifier(J.Identifier identifier, List<Cursor> out) {
    135+ if (identifier != declarationName && identifier.getFieldType() == target) {
    136+ out.add(getCursor());
    137+ }
    138+ return identifier;
    139+ }
    140+ }.visit(scope, found);
    141+ return found;
    142+ }
    143+
    144+ /** True when the single mention is the target of an assignment or an increment/decrement (a write, not a read). */
    145+ private static boolean isWriteTarget(Cursor identifierCursor) {
    146+ J.Identifier id = identifierCursor.getValue();
    147+ Object parent = identifierCursor.getParentTreeCursor().getValue();
    148+ if (parent instanceof J.Assignment assignment) {
    149+ return assignment.getVariable() == id;
    150+ }
    151+ if (parent instanceof J.AssignmentOperation assignmentOperation) {
    152+ return assignmentOperation.getVariable() == id;
    153+ }
    154+ if (parent instanceof J.Unary unary) {
    155+ return switch (unary.getOperator()) {
    156+ case PreIncrement, PreDecrement, PostIncrement, PostDecrement -> unary.getExpression() == id;
    157+ default -> false;
    158+ };
    159+ }
    160+ return false;
    161+ }
    162+
    163+ /**
    164+ * True when {@code use} sits inside {@code statement} and is reached only through
    165+ * side-effect-transparent expression nodes — i.e. it is guaranteed to run exactly once,
    166+ * unconditionally, whenever {@code statement} runs. Any loop, lambda, nested class, conditional,
    167+ * ternary or short-circuit operator on the path makes this return false.
    168+ */
    169+ private static boolean executesExactlyOnceWithin(Cursor use, Statement statement) {
    170+ Cursor c = use.getParentTreeCursor();
    171+ while (true) {
    172+ Object node = c.getValue();
    173+ if (node == statement) {
    174+ return isUnconditionalStatement(statement);
    175+ }
    176+ if (!isTransparentExpression(node)) {
    177+ return false;
    178+ }
    179+ c = c.getParentTreeCursor();
    180+ if (c.getValue() == Cursor.ROOT_VALUE) {
    181+ return false; // walked past the statement without meeting it
    182+ }
    183+ }
    184+ }
    185+
    186+ /** Statements whose (relevant) sub-expressions run unconditionally exactly once when the statement runs. */
    187+ private static boolean isUnconditionalStatement(Statement statement) {
    188+ return statement instanceof J.Return
    189+ || statement instanceof J.Throw
    190+ || statement instanceof J.MethodInvocation
    191+ || statement instanceof J.NewClass
    192+ || statement instanceof J.Assignment
    193+ || statement instanceof J.AssignmentOperation
    194+ || statement instanceof J.Unary
    195+ || statement instanceof J.VariableDeclarations;
    196+ }
    197+
    198+ /**
    199+ * Expression nodes we are willing to walk <em>through</em> from the statement down to the use:
    200+ * each evaluates its children unconditionally and exactly once. Deliberately excludes
    201+ * {@link J.Ternary}, short-circuit {@link J.Binary} (&amp;&amp;, ||), lambdas, and anonymous classes.
    202+ */
    203+ private static boolean isTransparentExpression(Object node) {
    204+ if (node instanceof J.MethodInvocation
    205+ || node instanceof J.NewClass
    206+ || node instanceof J.NewArray
    207+ || node instanceof J.FieldAccess
    208+ || node instanceof J.ArrayAccess
    209+ || node instanceof J.Parentheses
    210+ || node instanceof J.ControlParentheses
    211+ || node instanceof J.TypeCast
    212+ || node instanceof J.InstanceOf) {
    213+ return true;
    214+ }
    215+ if (node instanceof J.Unary unary) {
    216+ return switch (unary.getOperator()) {
    217+ case PreIncrement, PreDecrement, PostIncrement, PostDecrement -> false;
    218+ default -> true;
    219+ };
    220+ }
    221+ if (node instanceof J.Binary binary) {
    222+ return switch (binary.getOperator()) {
    223+ case And, Or -> false; // short-circuit: right operand is conditional
    224+ default -> true;
    225+ };
    226+ }
    227+ return false;
    228+ }
    229+
    230+ /** Swaps a specific identifier instance for an expression, preserving the identifier's whitespace. */
    231+ private static final class ReplaceIdentifier extends JavaVisitor<ExecutionContext> {
    232+ private final J.Identifier target;
    233+ private final Expression replacement;
    234+
    235+ private ReplaceIdentifier(J.Identifier target, Expression replacement) {
    236+ this.target = target;
    237+ this.replacement = replacement;
    238+ }
    239+
    240+ @Override
    241+ public J visitIdentifier(J.Identifier identifier, ExecutionContext ctx) {
    242+ if (identifier == target) {
    243+ return replacement.withPrefix(identifier.getPrefix());
    244+ }
    245+ return identifier;
    246+ }
    247+ }
    248+}
     
    1+package victor.training.petclinic.rewrite;
    2+
    3+import com.google.errorprone.refaster.annotation.AfterTemplate;
    4+import com.google.errorprone.refaster.annotation.BeforeTemplate;
    5+import org.openrewrite.java.template.RecipeDescriptor;
    6+
    7+import java.util.List;
    8+
    9+/**
    10+ * A <strong>Refaster template</strong> recipe: the whole recipe is just a before/after pair of code
    11+ * snippets. The {@code rewrite-templating} annotation processor reads this class at build time and
    12+ * generates the actual recipe class {@code ListGetFirstRecipe} (class name + {@code Recipe} because
    13+ * there is a single template).
    14+ *
    15+ * <p>It replaces {@code list.get(0)} with the Java 21 {@code list.getFirst()} (from
    16+ * {@link java.util.SequencedCollection}). Because Refaster works on a typed template, it only matches
    17+ * when the receiver is really a {@link List} — {@code someArray[0]} or a {@code get(0)} on an
    18+ * unrelated type is left alone. Compare with {@code InlineSingleUseLocalVariable}: that one cannot be
    19+ * a Refaster template because it must count references and delete a statement — analysis a fixed
    20+ * before/after snippet cannot express.</p>
    21+ */
    22+@RecipeDescriptor(
    23+ name = "Use `List#getFirst()` instead of `list.get(0)`",
    24+ description = "Replaces `list.get(0)` with `list.getFirst()` (Java 21 SequencedCollection), which reads " +
    25+ "more clearly. Note: on an empty list `get(0)` throws IndexOutOfBoundsException while " +
    26+ "`getFirst()` throws NoSuchElementException — both signal \"no first element\"."
    27+)
    28+public class ListGetFirst<T> {
    29+
    30+ @BeforeTemplate
    31+ T getZero(List<T> list) {
    32+ return list.get(0);
    33+ }
    34+
    35+ @AfterTemplate
    36+ T getFirst(List<T> list) {
    37+ return list.getFirst();
    38+ }
    39+}
     
    1+package victor.training.petclinic.rewrite;
    2+
    3+import org.junit.jupiter.api.Test;
    4+import org.openrewrite.test.RecipeSpec;
    5+import org.openrewrite.test.RewriteTest;
    6+
    7+import static org.openrewrite.java.Assertions.java;
    8+
    9+/**
    10+ * Corner cases mirror the real methods in {@code PetTypeRestController}:
    11+ * <ul>
    12+ * <li>{@code getPetType}/{@code listPetTypes} -> single use, inline (the manual refactoring)</li>
    13+ * <li>{@code deletePetType} -> single use in a following call statement, inline</li>
    14+ * <li>{@code addPetType} (save + getId) -> two uses, MUST NOT inline</li>
    15+ * <li>{@code updatePetType} (setName + save) -> two uses, MUST NOT inline</li>
    16+ * </ul>
    17+ * plus the tricky ones a naive recipe would get wrong.
    18+ */
    19+class InlineSingleUseLocalVariableTest implements RewriteTest {
    20+
    21+ @Override
    22+ public void defaults(RecipeSpec spec) {
    23+ spec.recipe(new InlineSingleUseLocalVariable());
    24+ }
    25+
    26+ // ---------- cases that SHOULD inline ----------
    27+
    28+ @Test
    29+ void inlinesSingleUseIntoReturn() { // == getPetType / listPetTypes
    30+ rewriteRun(java(
    31+ """
    32+ class A {
    33+ String find(int id) { return "" + id; }
    34+ String toDto(String s) { return s; }
    35+ String get(int id) {
    36+ String found = find(id);
    37+ return toDto(found);
    38+ }
    39+ }
    40+ """,
    41+ """
    42+ class A {
    43+ String find(int id) { return "" + id; }
    44+ String toDto(String s) { return s; }
    45+ String get(int id) {
    46+ return toDto(find(id));
    47+ }
    48+ }
    49+ """
    50+ ));
    51+ }
    52+
    53+ @Test
    54+ void inlinesNewClassInitializer() { // == listPetTypes: new ArrayList<>(...)
    55+ rewriteRun(java(
    56+ """
    57+ import java.util.ArrayList;
    58+ import java.util.List;
    59+ class A {
    60+ List<String> all() { return new ArrayList<>(); }
    61+ List<String> toDtos(List<String> in) { return in; }
    62+ List<String> list() {
    63+ List<String> items = new ArrayList<>(all());
    64+ return toDtos(items);
    65+ }
    66+ }
    67+ """,
    68+ """
    69+ import java.util.ArrayList;
    70+ import java.util.List;
    71+ class A {
    72+ List<String> all() { return new ArrayList<>(); }
    73+ List<String> toDtos(List<String> in) { return in; }
    74+ List<String> list() {
    75+ return toDtos(new ArrayList<>(all()));
    76+ }
    77+ }
    78+ """
    79+ ));
    80+ }
    81+
    82+ @Test
    83+ void inlinesIntoFollowingCallStatement() { // == deletePetType (use is a plain call, more stmts follow)
    84+ rewriteRun(java(
    85+ """
    86+ class A {
    87+ String find(int id) { return "" + id; }
    88+ void delete(String s) {}
    89+ void remove(int id) {
    90+ String found = find(id);
    91+ delete(found);
    92+ }
    93+ }
    94+ """,
    95+ """
    96+ class A {
    97+ String find(int id) { return "" + id; }
    98+ void delete(String s) {}
    99+ void remove(int id) {
    100+ delete(find(id));
    101+ }
    102+ }
    103+ """
    104+ ));
    105+ }
    106+
    107+ @Test
    108+ void inlinesTwoIndependentVariablesAcrossCycles() {
    109+ rewriteRun(
    110+ spec -> spec.cycles(2).expectedCyclesThatMakeChanges(2),
    111+ java(
    112+ """
    113+ class A {
    114+ String find(int id) { return "" + id; }
    115+ String toDto(String s) { return s; }
    116+ void save(String s) {}
    117+ void two(int id) {
    118+ String a = find(id);
    119+ save(toDto(a));
    120+ String b = find(id);
    121+ save(toDto(b));
    122+ }
    123+ }
    124+ """,
    125+ """
    126+ class A {
    127+ String find(int id) { return "" + id; }
    128+ String toDto(String s) { return s; }
    129+ void save(String s) {}
    130+ void two(int id) {
    131+ save(toDto(find(id)));
    132+ save(toDto(find(id)));
    133+ }
    134+ }
    135+ """
    136+ )
    137+ );
    138+ }
    139+
    140+ // ---------- cases that MUST NOT inline (the whole point) ----------
    141+
    142+ @Test
    143+ void keepsVariableUsedTwice() { // == addPetType: save(type) + type.getId()
    144+ rewriteRun(java(
    145+ """
    146+ class A {
    147+ String make() { return "x"; }
    148+ void save(String s) {}
    149+ void link(String s) {}
    150+ void add() {
    151+ String type = make();
    152+ save(type);
    153+ link(type);
    154+ }
    155+ }
    156+ """
    157+ ));
    158+ }
    159+
    160+ @Test
    161+ void keepsVariableUsedAsReceiverThenArgument() { // == updatePetType: setName + save
    162+ rewriteRun(java(
    163+ """
    164+ class A {
    165+ A find(int id) { return this; }
    166+ void setName(String n) {}
    167+ void save(A a) {}
    168+ void update(int id, String name) {
    169+ A current = find(id);
    170+ current.setName(name);
    171+ save(current);
    172+ }
    173+ }
    174+ """
    175+ ));
    176+ }
    177+
    178+ @Test
    179+ void keepsUnusedVariable() { // 0 uses -> dead code, not our concern
    180+ rewriteRun(java(
    181+ """
    182+ class A {
    183+ String make() { return "x"; }
    184+ void doThing() {
    185+ String unused = make();
    186+ System.out.println("hi");
    187+ }
    188+ }
    189+ """
    190+ ));
    191+ }
    192+
    193+ @Test
    194+ void keepsVariableWhoseOnlyMentionIsAReassignment() { // single mention, but it is a write
    195+ rewriteRun(java(
    196+ """
    197+ class A {
    198+ String make() { return "x"; }
    199+ String other() { return "y"; }
    200+ void doThing() {
    201+ String s = make();
    202+ s = other();
    203+ }
    204+ }
    205+ """
    206+ ));
    207+ }
    208+
    209+ @Test
    210+ void keepsWhenUseIsNotTheAdjacentStatement() { // reordering across an intervening statement is unsafe
    211+ rewriteRun(java(
    212+ """
    213+ class A {
    214+ String find(int id) { return "" + id; }
    215+ void log(String m) {}
    216+ String toDto(String s) { return s; }
    217+ String get(int id) {
    218+ String found = find(id);
    219+ log("getting");
    220+ return toDto(found);
    221+ }
    222+ }
    223+ """
    224+ ));
    225+ }
    226+
    227+ @Test
    228+ void keepsWhenSingleUseIsInsideALoop() { // would evaluate the initializer many times
    229+ rewriteRun(java(
    230+ """
    231+ class A {
    232+ String compute() { return "p"; }
    233+ void use(String s) {}
    234+ void go() {
    235+ String prefix = compute();
    236+ for (int i = 0; i < 3; i++) {
    237+ use(prefix);
    238+ }
    239+ }
    240+ }
    241+ """
    242+ ));
    243+ }
    244+
    245+ @Test
    246+ void keepsWhenSingleUseIsInsideALambda() { // would defer / repeat evaluation
    247+ rewriteRun(java(
    248+ """
    249+ class A {
    250+ String compute() { return "p"; }
    251+ void use(String s) {}
    252+ Runnable make() {
    253+ String p = compute();
    254+ return () -> use(p);
    255+ }
    256+ }
    257+ """
    258+ ));
    259+ }
    260+
    261+ @Test
    262+ void keepsPrecedenceUnsafeBinaryInitializer() { // x + 1 inlined into r * 2 would misparse
    263+ rewriteRun(java(
    264+ """
    265+ class A {
    266+ int compute(int x) {
    267+ int sum = x + 1;
    268+ return sum * 2;
    269+ }
    270+ }
    271+ """
    272+ ));
    273+ }
    274+
    275+ @Test
    276+ void keepsTernaryInitializer() {
    277+ rewriteRun(java(
    278+ """
    279+ class A {
    280+ int pick(boolean b, int x, int y) {
    281+ int r = b ? x : y;
    282+ return r + 1;
    283+ }
    284+ }
    285+ """
    286+ ));
    287+ }
    288+}
     
    1+package victor.training.petclinic.rewrite;
    2+
    3+import org.junit.jupiter.api.Test;
    4+import org.openrewrite.test.RecipeSpec;
    5+import org.openrewrite.test.RewriteTest;
    6+
    7+import static org.openrewrite.java.Assertions.java;
    8+
    9+/**
    10+ * Tests the recipe that the {@code rewrite-templating} processor generates from {@link ListGetFirst}.
    11+ * The generated class is {@code ListGetFirstRecipe} (single template -> "...Recipe").
    12+ */
    13+class ListGetFirstTest implements RewriteTest {
    14+
    15+ @Override
    16+ public void defaults(RecipeSpec spec) {
    17+ spec.recipe(new ListGetFirstRecipe());
    18+ }
    19+
    20+ @Test
    21+ void rewritesGetZeroToGetFirst() {
    22+ rewriteRun(java(
    23+ """
    24+ import java.util.List;
    25+ class A {
    26+ String first(List<String> items) {
    27+ return items.get(0);
    28+ }
    29+ }
    30+ """,
    31+ """
    32+ import java.util.List;
    33+ class A {
    34+ String first(List<String> items) {
    35+ return items.getFirst();
    36+ }
    37+ }
    38+ """
    39+ ));
    40+ }
    41+
    42+ @Test
    43+ void rewritesGetZeroOnAChainedReceiver() {
    44+ rewriteRun(java(
    45+ """
    46+ import java.util.List;
    47+ class Owner { List<String> getPets() { return List.of(); } }
    48+ class A {
    49+ String firstPet(Owner owner) {
    50+ return owner.getPets().get(0);
    51+ }
    52+ }
    53+ """,
    54+ """
    55+ import java.util.List;
    56+ class Owner { List<String> getPets() { return List.of(); } }
    57+ class A {
    58+ String firstPet(Owner owner) {
    59+ return owner.getPets().getFirst();
    60+ }
    61+ }
    62+ """
    63+ ));
    64+ }
    65+
    66+ @Test
    67+ void leavesOtherIndexesAlone() {
    68+ rewriteRun(java(
    69+ """
    70+ import java.util.List;
    71+ class A {
    72+ String second(List<String> items) {
    73+ return items.get(1);
    74+ }
    75+ }
    76+ """
    77+ ));
    78+ }
    79+
    80+ @Test
    81+ void leavesNonListGetAlone() {
    82+ rewriteRun(java(
    83+ """
    84+ import java.util.Map;
    85+ class A {
    86+ String lookup(Map<Integer, String> m) {
    87+ return m.get(0);
    88+ }
    89+ }
    90+ """
    91+ ));
    92+ }
    93+}
    if [[ -f "$AGENT_JAR" ]]; then
    4747 export OTEL_METRICS_EXPORTER=otlp
    4848 export OTEL_TRACES_EXPORTER=otlp
    4949 export OTEL_RESOURCE_ATTRIBUTES=deployment.environment=local
    50+ # Behind :4318 is either Grafana LGTM (./start-grafana.sh), which takes everything, or a
    51+ # bare Tempo (./start-tempo.sh), which only takes traces and would answer every logs and
    52+ # metrics export with a 404 logged by the agent.
    53+ if ! curl -fsS -o /dev/null --max-time 2 http://localhost:3300/api/health 2>/dev/null; then
    54+ export OTEL_LOGS_EXPORTER=none OTEL_METRICS_EXPORTER=none
    55+ echo "ℹ️ No Grafana on :3300 — exporting traces only (Tempo alone)."
    56+ fi
    5057 # Capture the *most* detail the agent can give; what a sequence diagram
    5158 # actually shows is decided at render time (petclinic-test SEQ_SQL=…), so a
    5259 # different level of detail never costs another test run.
    if [[ -f "$AGENT_JAR" ]]; then
    6067 export OTEL_SEMCONV_STABILITY_OPT_IN=database/dup
    6168 OTEL_JVM_ARGS="-javaagent:$AGENT_JAR"
    6269 else
    63- echo "ℹ️ OTel collector not running on :4318 — telemetry disabled. Run ./start-grafana.sh to enable."
    70+ echo "ℹ️ OTel collector not running on :4318 — telemetry disabled. Run ./start-grafana.sh (or ./start-tempo.sh, no Docker) to enable."
    6471 fi
    6572 fi
    6673
     
    11 #!/bin/bash
    22 # The whole PetClinic in containers, one isolated instance per branch under review.
    33 #
    4-# Inside the network every service keeps its default port (5432 / 8080 / 4200). Only the
    5-# frontend is published, and the host picks the port, so instances never collide — and
    6-# neither does an instance with the plain ./start-*.sh stack on :4200 and :5432.
    4+# Inside the network every service keeps its default port (5432 / 8080 / 4200). What is
    5+# published (frontend, backend, database) goes to a port the host picks, on loopback, so
    6+# instances never collide — and neither does one with the plain ./start-*.sh stack on
    7+# :4200 and :5432.
    78 #
    89 # ./start-docker.sh up [--ref SHA] [--name N] [--ttl SECS] [--fresh] [--jacoco] [--otel]
    910 # ./start-docker.sh url [name] # the URL again
    1011 # ./start-docker.sh ports [name] # every published endpoint, as KEY=value lines
    12+# ./start-docker.sh db [name] # the database's JDBC URL and login, as KEY=value
    1113 # ./start-docker.sh reset [name] # database back to the seed
    1214 # ./start-docker.sh ls
    1315 # ./start-docker.sh down [name|--all]
    cmd_ports() {
    269271 return 0
    270272 }
    271273
    274+# Where DataGrip / DBeaver / psql on the host reach this instance's database, as KEY=value
    275+# lines (the review page's "Running Database" row reads them). The login is read off the
    276+# container's own environment, so it cannot drift from the compose file.
    277+cmd_db() {
    278+ local name; name="$(resolve "${1:-}")"
    279+ local p; p="$(published "$name" db 5432)"
    280+ [ -n "$p" ] || die "$name publishes no database port (started before it was published?)"
    281+ local env user pass db
    282+ env="$(docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$name-db-1")"
    283+ user="$(printf '%s\n' "$env" | sed -n 's/^POSTGRES_USER=//p')"
    284+ pass="$(printf '%s\n' "$env" | sed -n 's/^POSTGRES_PASSWORD=//p')"
    285+ db="$(printf '%s\n' "$env" | sed -n 's/^POSTGRES_DB=//p')"
    286+ echo "JDBC_URL=jdbc:postgresql://localhost:$p/${db:-${user:-postgres}}"
    287+ echo "DB_HOST_PORT=$p"
    288+ echo "DB_CONTAINER_PORT=5432"
    289+ echo "DB_USER=${user:-postgres}"
    290+ echo "DB_PASSWORD=$pass"
    291+}
    292+
    272293 # Same starting point every time, so repeated deep links cannot pile up duplicate rows or
    273294 # trip a unique constraint.
    274295 # One implementation, reachable two ways. The button on the review page and this command
    case "${1:-up}" in
    309330 up) shift || true; cmd_up "$@" ;;
    310331 url) shift || true; cmd_url "$@" ;;
    311332 ports) shift || true; cmd_ports "$@" ;;
    333+ db) shift || true; cmd_db "$@" ;;
    312334 reset) shift || true; cmd_reset "$@" ;;
    313335 ls) cmd_ls ;;
    314336 down) shift || true; cmd_down "$@" ;;
    315- -h|--help|help) sed -n '2,28p' "${BASH_SOURCE[0]}" | sed -e 's/^#//' -e 's/^ //' ;;
    316- *) die "unknown command: $1 (up | url | ports | reset | ls | down)" ;;
    337+ -h|--help|help) sed -n '2,30p' "${BASH_SOURCE[0]}" | sed -e 's/^#//' -e 's/^ //' ;;
    338+ *) die "unknown command: $1 (up | url | ports | db | reset | ls | down)" ;;
    317339 esac
     
    1+#!/bin/bash
    2+# Tempo on its own — no Docker, no Grafana: all the sequence diagrams need.
    3+# OTLP in on :4318 (where start-backend.sh and the frontend's proxy already send), the
    4+# search/trace API on :3200 (TEMPO_URL, read by petclinic-test/src/genseq/tempo-client.ts).
    5+# One binary, downloaded once into petclinic-observability/.tools; traces are wiped on
    6+# every start. ./start-grafana.sh is the full stack (dashboards, logs, metrics) instead —
    7+# the two share :4318, so run one or the other.
    8+set -euo pipefail
    9+printf '\033]0;Tempo\007' # set terminal/tab title
    10+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
    11+OBS_DIR="$SCRIPT_DIR/petclinic-observability"
    12+
    13+source "$SCRIPT_DIR/scripts/preflight.sh"
    14+require_ports_free tempo 3200 4318
    15+
    16+TEMPO_VERSION="3.1.1"
    17+case "$(uname -s)" in
    18+ Darwin) os=darwin ;;
    19+ Linux) os=linux ;;
    20+ MINGW*|MSYS*|CYGWIN*) os=windows ;;
    21+ *) echo "❌ No Tempo build for $(uname -s)" >&2; exit 1 ;;
    22+esac
    23+case "$(uname -m)" in
    24+ arm64|aarch64) arch=arm64 ;;
    25+ *) arch=amd64 ;;
    26+esac
    27+TOOL_DIR="$OBS_DIR/.tools/tempo-$TEMPO_VERSION-$os-$arch"
    28+TEMPO_BIN="$TOOL_DIR/tempo"
    29+[[ $os == windows ]] && TEMPO_BIN="$TEMPO_BIN.exe"
    30+
    31+if [[ ! -x "$TEMPO_BIN" ]]; then
    32+ echo "⬇️ Downloading Tempo v$TEMPO_VERSION ($os/$arch, ~65 MB)..."
    33+ mkdir -p "$TOOL_DIR"
    34+ curl -fSL --progress-bar \
    35+ "https://github.com/grafana/tempo/releases/download/v$TEMPO_VERSION/tempo_${TEMPO_VERSION}_${os}_${arch}.tar.gz" \
    36+ | tar xz -C "$TOOL_DIR"
    37+fi
    38+
    39+# tempo-local.yaml points every path at ./.tempo-data, relative to here.
    40+cd "$OBS_DIR"
    41+rm -rf .tempo-data
    42+
    43+echo "🚀 Starting Tempo..."
    44+# Without the grep, a single binary's background compaction worker logs "nil job received"
    45+# errors forever: harmless, and the only thing anyone would read in this window.
    46+"$TEMPO_BIN" -config.file tempo-local.yaml \
    47+ > >(grep --line-buffered -vE 'caller=backend|unowned file entry') 2>&1 &
    48+TEMPO_PID=$!
    49+trap 'echo ""; echo "🛑 Stopping Tempo..."; kill $TEMPO_PID 2>/dev/null; wait $TEMPO_PID 2>/dev/null; echo "✅ Stopped"' EXIT
    50+
    51+for _ in {1..30}; do
    52+ curl -fsS http://127.0.0.1:3200/ready >/dev/null 2>&1 && break
    53+ kill -0 $TEMPO_PID 2>/dev/null || { echo "❌ Tempo exited — see the log above" >&2; exit 1; }
    54+ sleep 1
    55+done
    56+echo "✅ Tempo ready: OTLP on :4318, API on http://localhost:3200"
    57+echo " the tracing scripts pick it up by themselves; by hand: export TEMPO_URL=http://localhost:3200"
    58+
    59+# Same trap as with Grafana: the agent is only attached if :4318 answered when the backend booted.
    60+backend_pid="$(lsof -nP -iTCP:8080 -sTCP:LISTEN -t 2>/dev/null | head -1 || true)"
    61+if [[ -z "$backend_pid" ]]; then
    62+ echo "ℹ️ Backend not up yet — start it now (./start-backend.sh) so it picks up the OTel agent."
    63+elif ps -o command= -p "$backend_pid" 2>/dev/null | grep -q 'opentelemetry-javaagent'; then
    64+ echo "✅ Backend on :8080 already has the OTel agent attached — traces will be collected."
    65+else
    66+ echo "⚠️ Backend on :8080 is running WITHOUT the OpenTelemetry agent."
    67+ echo " Restart it (./start-backend.sh) or e2e runs will produce no sequence diagrams."
    68+fi
    69+
    70+echo "📜 Press Ctrl+C to stop."
    71+wait $TEMPO_PID

    51 generated files re-recorded in the fix commits (1bcf74d4..088f2f28) — regenerated output, not a fix, so not drawn.

    Demo

    Running appchecking…
    DB Fixture

    ELI5: Now every pet visit can say which vet took care of it.

    Watch: we pick a vet, leave it empty, then change our mind.

    🎬 Intro

    1. 0:00This visit finally says who treated the animal.
    2. 0:02Yesterday, nobody could tell. So how did that name get there?
    3. 0:08You book a visit, and this Vet field was not here yesterday.
    4. 0:12Pick a vet, save, and the name lands on the visit.
    5. 0:17It is optional. Leave it empty...
    6. 0:20...and the visit reads No vet, never blank.
    7. 0:23Wrong vet? Edit the visit and change it.
    8. 0:28Or clear it: choose none, and it is back to No vet.
    9. 0:32And the all-visits screen tells the same story, name by name.

    Tests

    Issue #37: Link Visit with Vet

    victorrenteaon Jun 13, 2026

    The Visit should be linked to the vet that attended that consultation. Visit should display its vet everywhere throughout the app.


    What it has to do

    1. Booking a visit lets you choose the vet, and lets you not choose one. The vet is optional: half the time the appointment is booked before anyone knows who is taking it, and forcing a choice there produces bad data rather than information.
    2. Editing a visit can change the vet, and can remove it. Clearing the field must persist as empty. We had this with pet types: the old value kept coming back.
    3. Wherever a visit is shown with its details, the vet is shown too. Today that means the owner's page and the all-visits screen.
    4. A visit with no vet reads as having none. Not "Unknown", not blank-because-broken, and never an error. Visits created before this change have no vet and will not get one.

    Out of scope

    Searching or filtering visits by vet.

    fully coveredpartiallyranmissingN/A

    +22

    Sequence

    Sequence diagrams generated from traced tests

    These diagrams were captured from OpenTelemetry traces of 7 tests.
    1. The tests ran against the real app, with OpenTelemetry tracing switched on.
    2. Each HTTP call and database query was recorded as a span: who called whom, and for how long. The spans that one action set off form a trace.
    3. Each test's traces were drawn as its diagram below.

    The trace of AddVisitApiTest: adds a visit to an existing pet, in Grafana Tempo. Compare it with that test's diagram below. Recorded while the e2e tests ran, 100% sampled: every request is in it.

    A trace in Grafana Tempo: AddVisitApiTest: adds a visit to an existing pet
    E2E@Add a visit to an existing pet from the owner detail pageadd-visit.spec.ts⇒.puml
    Test: Add a visit to an existing pet from the owner detail pageBrowserBackendDBNotificationService.module.SMS gatewayBrowserBackendDBNotificationService«module»CommonsSMS gatewayOwnerService.getOwnerById ↗Get an owner by IDGET /api/owners/{ownerId} ⊕OwnerRepository.findById ↗tx[committed]select owners ⊕select pets ⊕select visits ⊕200 ⊕VetService.getVets ↗listVetsGET /api/vets ⊕VetRepository.findAll ↗tx[committed]SELECT DISTINCT v FROM Vet v LEFT JOIN FETCH v.specialties ⊕200 ⊕PetService.getPetById ↗getPetGET /api/pets/{petId} ⊕PetRepository.findById ↗tx[committed]select pets ⊕select visits ⊕200 ⊕OwnerService.getOwnerById ↗Get an owner by IDGET /api/owners/{ownerId} ⊕OwnerRepository.findById ↗tx[committed]select owners ⊕select pets ⊕select visits ⊕200 ⊕VisitService.addVisit ↗Add a visit for an owner's petPOST /api/owners/{ownerId}/pets/{petId}/visits ⊕book-visit ↗VetRepository.findByIdOrNull ↗VisitRepository.save ↗tx[committed]insert for victor.training.petclinic.domain.Visit ⊕OwnerRepository.findById ↗tx[committed]select owners ⊕select pets ⊕PhoneNumbers.normalize ↗NotificationServiceClient.homeDialCode ↗POST /api/notifications/visit-booked ⊕PhoneNumbers.normalize ↗send-sms ↗202201OwnerService.getOwnerById ↗Get an owner by IDGET /api/owners/{ownerId} ⊕OwnerRepository.findById ↗tx[committed]select owners ⊕select pets ⊕select visits ⊕200 ⊕@generate_sequence in src/add-visit.spec.ts — generated from real traces of end-to-end test runs, do not edit ❗
    E2E@Searching with an empty last name lists every ownerowner-search.feature⇒.puml
    Test: Searching with an empty last name lists every ownerBrowserBackendDBBrowserBackendDBOwnerService.getOwners ↗List ownersGET /api/owners ⊕OwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕200 ⊕OwnerService.getOwners ↗List ownersGET /api/owners ⊕OwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕200 ⊕@generate_sequence in src/owner-search.feature — generated from real traces of end-to-end test runs, do not edit ❗
    E2E+Book a visit attended by a vet, and see the vet on the owner page and on all visitsvisit-vet.feature⇒.puml
    E2E+Book a visit with nobody yet as its vet, and see it read "No vet" everywherevisit-vet.feature⇒.puml
    API@adds a visit to an existing petAddVisitApiTest.java⇒.puml
    Test: AddVisitApiTest: adds a visit to an existing petTestBackendDBNotificationService.module.SMS gatewayTestBackendDBNotificationService«module»CommonsSMS gatewaygiven an owner with at least one pet exists ↗List ownersGET /api/ownersOwnerRepository.findByLastNameStartingWith ↗OwnerRepository.findByLastNameStartingWith ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕select pets ⊕select visits ⊕200 ⊕when the owner detail page is opened ↗Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗tx[committed]select owners ⊕select pets ⊕select visits ⊕200 ⊕and a visit is added for the first pet ↗Add a visit for an owner's petPOST /api/owners/{ownerId}/pets/{petId}/visits ⊕book-visit ↗VisitRepository.save ↗tx[committed]insert for victor.training.petclinic.domain.Visit ⊕OwnerRepository.findById ↗tx[committed]select owners ⊕select pets ⊕PhoneNumbers.normalize ↗NotificationServiceClient.homeDialCode ↗POST /api/notifications/visit-booked ⊕PhoneNumbers.normalize ↗send-sms ↗202201then the visit is listed under the pet ↗Get an owner by IDGET /api/owners/{ownerId}OwnerRepository.findById ↗tx[committed]select owners ⊕select pets ⊕select visits ⊕200 ⊕@GenerateSequence in petclinic-backend/src/test/java/victor/training/petclinic/rest/AddVisitApiTest.java — generated from real traces of end-to-end test runs, do not edit ❗
    API+create with unknown vet not foundVisitTest.java⇒.puml
    Test: VisitTest: create with unknown vet not foundTestBackendDBTestBackendDBaddVisitPOST /api/visits ⊕book-visit ↗VetRepository.findByIdOrNull ↗VetRepository.findById ↗SELECT v FROM Vet v LEFT JOIN FETCH v.specialties WHERE v.id = :id ⊕404traced because this branch wrote it in petclinic-backend/src/test/java/victor/training/petclinic/rest/VisitTest.java — generated from real traces of end-to-end test runs, do not edit ❗
    API+create without vetVisitTest.java⇒.puml
    Test: VisitTest: create without vetTestBackendDBTestBackendDBaddVisitPOST /api/visits ⊕book-visit ↗VetRepository.findByIdOrNull ↗VisitRepository.save ↗insert for victor.training.petclinic.domain.Visit ⊕201getVisitGET /api/visits/{visitId} ⊕VisitRepository.findById ↗200traced because this branch wrote it in petclinic-backend/src/test/java/victor/training/petclinic/rest/VisitTest.java — generated from real traces of end-to-end test runs, do not edit ❗

    API

    Backwards compatible · 11 endpoints changed · checked by oasdiff (report ↗)v1.0

    Data

    Diff + extra neighbours:
    Domain Model - DiffVetid : IntegerfirstName : StringlastName : StringVisitid : Integerdate : LocalDatetime : LocalTimedescription : String*vetaddedorremoved— the impacted elements only (2 of 8 shown)domain/*.java -> DomainModel.puml — Diagram generated from code using Java reflection
    Diff + extra neighbours:
    Database Schema (ERD) - Diffvetsid : int «PK»first_name : textlast_name : textvisitsid : int «PK»pet_id : int «FK»visit_date : datedescription : textvisit_time : timevet_id : int «FK»vet_idaddedorremoved— the impacted elements only (2 of 9 shown)db/migration/*.sql -> DB -> dump to DB.sql -> converted to DB.puml — Diagram generated from DB incremental scripts
    Conceptual Model
    Owner
    PetType
    User
    Vet
    Pet
    Role
    Specialty
    Visit
    *
    *
    *
    *
    *
    *
    *

    added by this PR

    Structure

    Structure diagrams

    Backend Java packagesunchangedArchUnit-tested vs codepackages.puml
    = the box's share of the lines this PR added
    = the box's share of the lines this PR added
    Frontend TypeScript packageschangedTested vs codefrontend-packages.puml
    Diff + extra neighbours:
    Frontend Logical Architecture - Diffvetsvisitsdesign-systemaddedorremoved— the impacted elements only (3 of 12 shown)
    = the box's share of the lines this PR added
    PetClinic — containers
    Runtime containers and the calls between them. Every edge marked traced="yes" must appear in the sequence diagrams generated from real traces — DeploymentDiagramTest fails if it does not.
    Pet Owner
    Veterinarian
    Frontend
    Backend
    Database
    Notification Service
    Chatbot
    OpenAI
    SMS gateway

    walked by a testno test walks it

    C2-ContainerschangedGenerated from tracesC2-Containers.new.puml
    C2 ContainersPetClinic[system]Backend[Java 21 / Spring Boot 3.5]Browser[Angular 16]DB[PostgreSQL]NotificationService[Java 21 / Spring Boot 3.5]SMS gateway SMS provider (simulated)SQLHTTP ⊕[1 ops]HTTP ⊕[6 ops (was 5)]calls ⊕[1 ops]Diagram generated fromsequence diagramsof the test traces

    Op counts compare only the 3 tests traced on both sides.

    C2-ContainersStructurizr viewunchangedHand-maintainedc4model.c1+c2.dsl
    C2-Containers — drawn by StructurizrC2-Containers — drawn by Structurizr
    C2-BackendOutboundStructurizr viewunchangedHand-maintainedc4model.c1+c2.dsl
    C2-BackendOutbound — drawn by StructurizrC2-BackendOutbound — drawn by Structurizr
    C3-Components-AllStructurizr viewunchangedArchUnit-tested vs codec4model.c1+c2.dsl
    C3-Components-All — drawn by StructurizrC3-Components-All — drawn by Structurizr
    C3-RepositoryStructurizr viewunchangedArchUnit-tested vs codec4model.c1+c2.dsl
    C3-Repository — drawn by StructurizrC3-Repository — drawn by Structurizr
    C3-MapperStructurizr viewunchangedArchUnit-tested vs codec4model.c1+c2.dsl
    C3-Mapper — drawn by StructurizrC3-Mapper — drawn by Structurizr
    C1-ContextStructurizr viewunchangedHand-maintainedc4model.c1+c2.dsl
    C1-Context — drawn by StructurizrC1-Context — drawn by Structurizr

    Code City

    Impact on code size, complexity, coupling, …

    Code City with the branch change set highlighted

    Complexity

    Cognitive Complexity per Entry Point

    Computed by endpoint-complexity.py with JavaParser and its symbol solver: a syntax tree of the Java sources, every call bound by type.

    REST APIs 45addedtotal
    GET/api/owners+112
    POST/api/users11
    GET/assistant11
    GET/api/owners/{ownerId}+110
    GET/api/pets+110
    GET/api/owners/{ownerId}/pets/{petId}+18
    GET/api/pets/{petId}+18
    POST/api/owners/{ownerId}/pets/{petId}/visits+26
    GET/api/visits+16
    GET/api/vets4
    PUT/api/vets/{vetId}4
    GET/api/visits/{visitId}+14
    POST/api/vets3
    GET/jev3
    GET/api/pettypes2
    GET/api/specialties2
    GET/api/vets/{vetId}2
    GET/history2
    POST/api/notifications/visit-booked1
    POST/api/owners/{ownerId}/pets1
    PUT/api/pets/{petId}1
    DELETE/api/pettypes/{petTypeId}1
    GET/api/specialties/feed1
    POST/api/visits+11
    PUT/api/visits/{visitId}+11
    ANY/0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    POST/api/owners0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/api/owners/count0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/owners/{ownerId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    PUT/api/owners/{ownerId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    PUT/api/owners/{ownerId}/pets/{petId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/pets/{petId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    POST/api/pettypes0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/api/pettypes/{petTypeId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    PUT/api/pettypes/{petTypeId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    POST/api/specialties0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/specialties/{specialtyId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/api/specialties/{specialtyId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    PUT/api/specialties/{specialtyId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/vets/{vetId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/api/visits/{visitId}0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/firefighter0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    DELETE/history0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/model0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    GET/triage0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    MCP tools 7addedtotal
    MCPcall_vet_ambulance6
    MCPcreate_visit6
    MCPlist_visits+14
    MCPLocalTools.sendEmail2
    MCPcancel_visit2
    MCPget_owner_profile1
    MCPLocalTools.currentDateTime0

    Nothing counted: every method behind this entry point is straight-line code. Cognitive complexity charges for branching, loops and boolean runs, and there are none here.

    UX

    UX design system

    · ⚠ +1 gap · +1 design-system component

    ✅ Book A Visit (/pets/11/visits/add) · +1 component
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    ✓ combo · added
    ✓ combo · added

    ✓ combo — addedchanged — nothing here to judge

    sideelementrolewhydeltachurn
    ok788c63dcVet
    app-combo[name="vetId"]
    design-system component comboadded—
    markup and template

    The design system’s combo component (<app-combo>) — the control this role should be.

    <app-combo inputid="vet" name="vetId" valuekey="id" data-ds="combo">
      <select class="form-control pc-combo__control" id="vet">
        <option value="">No vet</option>
        <option value="0">Rafael Ortega</option>
        <option value="1">James Carter</option>
        <option value="2">Helen Leary</option>
        <!-- 3 more <option> -->
      </select>
      <span aria-hidden="true" class="pc-combo__chevron"></span>
    </app-combo>
    2 controls considered and deliberately not judged
    • Date input[name="date"] — role input[type=text], not covered
    • Description #description — role input[type=text], not covered
    ⚠️ Edit A Visit (/visits/1/edit) · +1 gap
    design-system componentnative or outside control where one belongswhere this branch changed the screen
    ✗ new plain <select> — should be <app-combo>
    ✗ new plain <select> — should be <app-combo>

    ✗ <select> — added

    sideelementrolewhydeltachurn
    gap788c63dcVet
    #vet
    selectnot the design-system component — a plain <select> where combo belongs
    this branch shipped it bare, never migrated
    added—
    why A native <select> where the design system offers <app-combo> (combo, used on 4 other screens): use the component so styling, keyboard behaviour and validation stay consistent.
    <select id="vet" name="vetId" class="form-control">
      <option value="0: null">-- none --</option>
      <option value="1: 4">Rafael Ortega</option>
      <option value="2: 1">James Carter</option>
      <option value="3: 2">Helen Leary</option>
      <!-- 3 more <option> -->
    </select>
    2 controls considered and deliberately not judged
    • Date input[name="date"] — role input[type=text], not covered
    • Description #description — role input[type=text], not covered
    ⚪ Owner Details (/owners/8) · nothing to judge
    design-system componentnative or outside control where one belongswhere this branch changed the screen

    changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    ⚪ Pets (/pets) · nothing to judge
    design-system componentnative or outside control where one belongswhere this branch changed the screen

    changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.
    ⚪ Visits (/visits) · nothing to judge
    design-system componentnative or outside control where one belongswhere this branch changed the screen

    changed — nothing here to judge

    sideelementrolewhydeltachurn
    No control on this screen is a design-system component or fills a role one covers — nothing to judge here; what was considered is listed below.

    Logging

    Uses of common Java logging libraries

    Found by syntax-aware search over Java sources: ast-grep (open source, built on tree-sitter) parses each file, and this skill's own rules (logextract.py) first find which fields are loggers, then every log-level call made on one.

    +201log.info("Booked visit {} for pet {}", visit.getId(), petId);
    +203log.debug("Attending vet: {}", vet.getLastName());

    CODEOWNERS

    Needs approval as per .github/CODEOWNERS

    Cost

    Total Cost

    steptimecost
    ImplementationClaude Code 82329d0a11 min$4.78Opus 5.5 (high)
    ReviewClaude Code 82329d0a · the reviewers and their brief1 min$1.53Sonnet 5.5 (medium) · Opus 5.5 (high)
    Auto-fixesClaude Code 82329d0a · deciding and fixing12 min$2.61Opus 5.5 (high)
    plus 59 min of later refreshes, no model3 min$0.41Sonnet 5.5
    claude -p on Sonnet 5.5 · requirements↔tests mapping1 min$0.12
    claude -p on Sonnet 5.5 · film script (rerun-film.py)2 min$0.27
    claude -p on Sonnet 5.5 · verdict (write-content.py)5 s$0.02
    VoicesFish Audio, 2 voices< $0.01s2.1-pro-free
    Total27 min$9.33

    Building human-review itself — the tool, not this change

    $3,986token cost (subsidized)34 h (4 days)Victor’s time, estimated221 h (28 days)agent time

    227 sessions, 27 Aug 2026 → 20 Oct 2026