🤖 Review
9 open, worst first · 2 auto-applied · 4 coder assumptions to check
DELETE /api/vets/{id} returns 500vet_id gets no ON DELETE clause and nothing on the JPA side nulls it out, so delete(vet) raises a foreign-key violation the advice turns into a 500. Verified live this run: DELETE /api/vets/4 → HTTP 500, violates foreign key constraint "visits_vet_id_fkey". The backfill gives every seeded vet a visit, so on a fresh database none of the six can be deleted.
Three defensible answers: ON DELETE SET NULL, a guard in deleteVet answering 409, or “a vet who has attended is not deletable”. Returning 500 is not one of them.
3ALTER TABLE visits ADD COLUMN vet_id INT REFERENCES vets (id);
4CREATE INDEX ON visits (vet_id);
96@DeleteMapping("{vetId}")
97public void deleteVet(@PathVariable int vetId) {
98 Vet vet = vetRepository.findById(vetId).orElseThrow();
99 vetRepository.delete(vet);
100}
The UPDATE assumes the six V3-seeded vets still occupy ids 1–6. DELETE /api/vets/{id} is exposed, so in any long-lived database where a seeded vet was removed this violates visits_vet_id_fkey: Flyway aborts and the app does not start.
Also false wherever a visit was booked through the UI. Selecting from the vets that actually exist costs one subquery and removes both problems.
6-- Backfill: every visit in the DB at this point comes from the V3 sample data,
7-- spread round-robin over the 6 seeded vets so the UI has something to show.
8UPDATE visits SET vet_id = 1 + (id % 6);
VET_ADMIN endpoint from two OWNER_ADMIN screens, and the 403 is swallowedThe 403 is swallowed into an empty array, so the select renders with nothing but -- not assigned -- and no error.
Masked today: petclinic.security.enable is false by default and the one seeded user holds all three roles, so no test catches it. PetTypeRestController sets the precedent — hasAnyRole(@roles.OWNER_ADMIN, @roles.VET_ADMIN) — but widening an authorization rule is a contract decision.
27@RestController
28@RequestMapping("/api/vets")
29@PreAuthorize("hasRole(@roles.VET_ADMIN)")
30public class VetRestController {
31
32 private final VetMapper vetMapper;
33 private final SpecialtyMapper specialtyMapper;
34 private final VetRepository vetRepository;
35 private final SpecialtyRepository specialtyRepository;
36
37 public VetRestController(
38 VetMapper vetMapper,
39 SpecialtyMapper specialtyMapper,
40 VetRepository vetRepository,
41 SpecialtyRepository specialtyRepository) {
42 this.vetMapper = vetMapper;
43 this.specialtyMapper = specialtyMapper;
44 this.vetRepository = vetRepository;
45 this.specialtyRepository = specialtyRepository;
46 }
47
48 @GetMapping
49 @ApiResponse(responseCode = "200", description = "OK",
50 content = @Content(mediaType = "application/json",
51 array = @ArraySchema(schema = @Schema(implementation = VetDto.class)),
52 examples = @ExampleObject(name = "sample", value = ApiExamples.VETS)))
53 public List<VetDto> listVets() {
54 List<Vet> allVets = vetRepository.findAll();
55 return vetMapper.toVetDtos(allVets);
56 }
57
58 @GetMapping("{vetId}")
59 public VetDto getVet(@PathVariable int vetId) {
60 Vet vet = vetRepository.findById(vetId).orElseThrow();
61 return vetMapper.toVetDto(vet);
62 }
63
64 @PostMapping
65 public ResponseEntity<Void> addVet(@RequestBody @Validated VetDto vetDto) {
66 Vet vet = vetMapper.toVet(vetDto);
67 updateSpecialties(vet);
68 URI createdVetUri = UriComponentsBuilder.fromPath("/api/vets/{id}")
69 .buildAndExpand(vet.getId()).toUri();
70 return ResponseEntity.created(createdVetUri).build();
21getVets(): Observable<Vet[]> {
22 return this.http.get<Vet[]>(this.entityUrl)
23 .pipe(
24 catchError(this.handlerError('getVets', []))
25 );
26}
@ManyToOne Vet is EAGER, and only one of the four read paths join-fetches itThe other three — GET /api/owners/{ownerId} (which the new e2e exercises), GET /api/visits/{visitId} and PetClinicMcp.listVisits — issue one select … from vets where id=? per visit. On the MCP tool that is N+1 across the whole clinic.
LAZY trades a query count for a LazyInitializationException on any path that maps outside the transaction. That trade is yours.
+31@ManyToOne
+32@JoinColumn(name = "vet_id")
+33private Vet vet;
+18@Query("SELECT v FROM Visit v JOIN FETCH v.pet p JOIN FETCH p.owner LEFT JOIN FETCH v.vet")
19List<Visit> findAllWithPetAndOwner();
vetId answers 404, for a resource the client is trying to createorElseThrow() throws NoSuchElementException, which the advice maps to 404 with a bare "Not found!" string body rather than the ProblemDetail every other handler returns. POST /api/visits {"vetId": 9999} answers 404 and the client cannot tell a bad field from a bad URL.
Its Javadoc says “an unknown one is rejected”; a 400 naming the field would match that intent. Left alone because a status code is a contract.
+28@Nullable
+29default Vet getByIdOrNull(@Nullable Integer vetId) {
+30 if (vetId == null) {
+31 return null;
+32 }
+33 return findByIdWithoutSpecialties(vetId).orElseThrow();
+34}
DomainModelExtractor reads the model by plain reflection and ignores JPA annotations on purpose, so it can only mark a * end — an unmarked end reads as exactly one.
Optionality is the central design decision of this change and the one thing the living diagrams cannot show; no test will complain either — the extractor that feeds them cannot see it.
+31@ManyToOne
+32@JoinColumn(name = "vet_id")
+33private Vet vet;
openapi.yaml covers only one toolEdit(./openapi.yaml) blocks Edit and not Write, MultiEdit or NotebookEdit; the shell-write denies added earlier were dropped again. CLAUDE.md says hand-editing the file is denied, so the rule and the documentation disagree.
Raised, not fixed, on purpose: an agent should not widen its own permission configuration because a review suggested it — and the ./ prefix in that glob is worth confirming actually matches first.
10"deny": [
11 "Bash(git commit --no-verify*)",
12 "Bash(git push --no-verify*)",
13 "Edit(./openapi.yaml)"
14],
15"ask": [
16 "Edit(.claude/settings.json)"
17]
This branch moved the diagram generator itself — trace-to-puml.ts alone changed 102 lines — so the committed base diagrams were rendered by the old generator and arrows that never changed still read as removed-and-re-added: 45 of 54 arrows red in add-visit, 34 of 64 in owner-search, a scenario this feature does not touch at all.
Red means “rendered differently”, not “changed”. In add-visit, the listVets / GET /api/vets arrows are genuinely new. Re-recording the base side with the base generator needs the stack running on the base commit, and its ports are fixed.
The existing test pins the opposite rule on purpose, at line 3 of its fixture: a comment naming the test is still where a reviewer wants to land. The applied fix was reverted; the file is untouched by this branch.
Which rule you actually want is a call for whoever reads these diagrams.
31test('takes the first line that names the test, comment or declaration', () => {
32 expect(lineOfTest(SPEC, 'Add a visit to an existing pet from the owner detail page')).toBe(3);
33});
Issue #37 asks that a visit “be linked to the vet that attended that consultation” and never says whether that link is mandatory. Six minutes in, the coder settled it as nullable everywhere — column, entity, both DTOs, no validation — and made -- not assigned -- the picker's first option. Nothing in the stack can now refuse a vet-less visit. “one design decision matters: vet should be optional/nullable since legacy rows and MCP-booked visits won't have one yet” — its own thinking, 13 Aug 23:00.
Read the other way: Required on every new visit — @NotNull on VisitFieldsDto, NOT NULL after the backfill — with vet-less tolerated only for rows that predate the column.
Whether a receptionist may save a visit naming no vet, and whether that is a legitimate resting state or a to-do something should chase.
description carries @NotNull; vetId, right beneath it, carries only @Min(0). 17@NotNull
18@Size(min = 1, max = 255)
19@Schema(example = "rabies shot", description = "The description for the visit.")
20private String description;
21
+22@Min(0)
+23@Schema(example = "1", description = "The ID of the vet that attended the visit.")
+24private @Nullable Integer vetId;
+54<div class="form-group">
+55 <label for="vetId" class="col-sm-2 control-label">Vet</label>
+56 <div class="col-sm-10">
+57 <select id="vetId" name="vetId" class="form-control" [(ngModel)]="visit.vetId">
+58 <option [ngValue]="null">-- not assigned --</option>
+59 <option *ngFor="let vet of vets" [ngValue]="vet.id">{{ vet.firstName }} {{ vet.lastName }}</option>
+60 </select>
+61 </div>
+62</div>
PUT that omits vetId silently unassigns the attending vetupdateVisit assigns resolveVet(visitDto.getVetId()) unconditionally, and vetId is a plain Integer — so “field absent” and “explicitly null” are the same wire value. Any client still sending the pre-feature body wipes the vet on every save. “I'm adding a helper method that resolves a vet by id, returning null if the id is null and throwing otherwise, then using it in both bookVisit and updateVisit” — its own thinking, 13 Aug 23:02. The two paths were never separated after that.
Read the other way: Leave the recorded vet untouched when vetId is absent, and require an explicit null to unassign.
Whether an older integration, or a partial form post, is allowed to erase attribution as a side effect. No test clears a vet, so nothing pins today's answer either.
setVet runs on every update, with whatever the body did or did not carry.+89@Transactional
90@PutMapping("{visitId}")
91public void updateVisit(@PathVariable int visitId, @RequestBody @Validated VisitFieldsDto visitDto) {
92 Visit currentVisit = visitRepository.findById(visitId).orElseThrow();
93 currentVisit.setDate(visitDto.getDate());
94 currentVisit.setDescription(visitDto.getDescription());
+95 currentVisit.setVet(resolveVet(visitDto.getVetId()));
96 visitRepository.save(currentVisit);
97}
The form loads getVets() and lists the whole clinic, unfiltered and unordered. The domain already models vet_specialties, and a visit already carries a date and an exact time, so narrowing by specialty or by who is free at that slot was available and was never weighed. Nothing prevents booking one vet for two visits at the same minute. “adding a vets array to visit-add, loading it in ngOnInit, and binding a select dropdown to vetId using ngValue” — its own thinking, 13 Aug 23:03, straight from “add a dropdown” to binding it.
Read the other way: Offer only vets whose specialty fits, or only those with no clashing visit at that date and time — and reject a clashing vetId server-side.
Whether picking a vet is scheduling, with the constraints that implies, or only record-keeping about who was there.
getVets(), no filter, no awareness of the date being booked. 43ngOnInit() {
+44 this.vetService.getVets().subscribe(vets => this.vets = vets);
45 console.log(this.route.parent);
46 const petId = this.route.snapshot.params.id;
47 this.petService.getPetById(petId).subscribe(
48 pet => {
49 this.currentPet = pet;
50 this.visit.pet = this.currentPet;
51 this.currentPetType = this.currentPet.type;
52 this.ownerService.getOwnerById(pet.ownerId).subscribe(
53 owner => {
54 this.currentOwner = owner;
55 }
56 )
57 },
58 error => this.errorMessage = error as any);
59}
“Display its vet everywhere throughout the app” was read as display-only for the MCP surface: list_visits gained a vet name, create_visit gained no vet parameter. Every visit an owner books through the assistant is therefore vet-less until somebody opens the web form. “This keeps things least disruptive and avoids breaking the MCP create_visit path, so I'll move on to checking the remaining files rather than asking for clarification” — its own thinking, 13 Aug 23:00. That constraint is also what forced the column to stay nullable, so this call and the first one are the same call.
Read the other way: Give create_visit an optional vetId, so the chat booking path carries what the web form does.
Whether owners booking by chat are meant to choose a vet at all, and who is expected to fill the gap on the visits they create.
create_visit's four required parameters — pet, date, time, description; no vet. 119@McpTool(
120 name = "create_visit",
121 description = "Create a new vet visit for one of the authenticated owner's pets "
122 + "(date/time, pet, description). Books the visit directly — no confirmation prompt.",
123 annotations = @McpAnnotations(destructiveHint = true))
124@Transactional
125public String createVisit(
126 @McpToolParam(description = "Pet ID (must belong to the authenticated owner)", required = true) int petId,
127 @McpToolParam(description = "Visit date (yyyy-MM-dd); must be today or in the future",
128 required = true) LocalDate visitDate,
129 @McpToolParam(description = "Exact local time of the appointment (HH:mm), e.g. 08:00",
130 required = true) LocalTime visitTime,
131 @McpToolParam(description = "Visit description (reason, diagnosis, notes...)",
132 required = true) String description) {
133 int ownerId = McpSecurity.currentOwnerId();
134 Pet pet = petRepository.findById(petId)
135 .orElseThrow(() -> new IllegalArgumentException("Pet not found: " + petId));
136 if (pet.getOwner() == null || pet.getOwner().getId() != ownerId) {
137 throw new IllegalArgumentException("Pet " + petId + " does not belong to owner " + ownerId);
138 }
139 requireFutureDate(visitDate);
140 if (LocalDateTime.of(visitDate, visitTime).isBefore(LocalDateTime.now())) {
141 throw new IllegalArgumentException("Visit time must be in the future: " + visitDate + " " + visitTime);
142 }
143 requireUnderUpcomingVisitCap(pet);
144
145 Visit v = new Visit();
146 v.setDate(visitDate);
147 v.setTime(visitTime);
148 v.setDescription(description);
149 pet.addVisit(v); // maintain both sides of the Pet<->Visit association
150 Visit saved = visitRepository.save(v);
151 return "Created visit id=" + saved.getId() + " for pet '" + pet.getName() + "' on " + visitDate
152 + " at " + visitTime;
153}
Diffs cut from bf26a0de alone. A third was applied and then reverted — it inverted a rule the tests pin deliberately; that is item 9 above.
@Transactional on the private, self-invoked method is a no-op — Spring AOP proxies the public entry point, which is where it now sits.
| import io.swagger.v3.oas.annotations.media.Schema; | ||
| 8 | 8 | import io.swagger.v3.oas.annotations.responses.ApiResponse; |
| 9 | 9 | import jakarta.transaction.Transactional; |
| 10 | 10 | import lombok.RequiredArgsConstructor; |
| 11 | +import lombok.extern.slf4j.Slf4j; | |
| 11 | 12 | import org.springframework.http.ResponseEntity; |
| 13 | +import victor.training.petclinic.domain.Vet; | |
| 12 | 14 | import victor.training.petclinic.mapper.VisitMapper; |
| 13 | 15 | import victor.training.petclinic.domain.Visit; |
| 14 | 16 | import victor.training.petclinic.repository.VetRepository; |
| import org.springframework.security.access.prepost.PreAuthorize; | ||
| 21 | 23 | import org.springframework.web.util.UriComponentsBuilder; |
| 22 | 24 | |
| 23 | 25 | import java.util.List; |
| 26 | +import java.util.NoSuchElementException; | |
| 24 | 27 | |
| 28 | +@Slf4j | |
| 25 | 29 | @RestController |
| 26 | 30 | @RequestMapping("/api/visits") |
| 27 | 31 | @RequiredArgsConstructor |
| public class VisitRestController { | ||
| 47 | 51 | return visitMapper.toVisitDto(visit); |
| 48 | 52 | } |
| 49 | 53 | |
| 54 | + // On the public entry point, not on bookVisit: that one is private and self-invoked, | |
| 55 | + // so a @Transactional there would be silently ignored by Spring AOP — the same reason | |
| 56 | + // the span below has to come from the bytecode agent. | |
| 57 | + @Transactional | |
| 50 | 58 | @PostMapping |
| 51 | 59 | public ResponseEntity<Void> addVisit(@RequestBody @Validated VisitDto visitDto) { |
| 52 | 60 | int id = bookVisit(visitDto); |
| public class VisitRestController { | ||
| 63 | 71 | @WithSpan("book-visit") |
| 64 | 72 | private int bookVisit(VisitDto visitDto) { |
| 65 | 73 | Visit visit = visitMapper.toVisit(visitDto); |
| 66 | - visit.setVet(vetRepository.getByIdOrNull(visitDto.getVetId())); | |
| 74 | + visit.setVet(resolveVet(visitDto.getVetId())); | |
| 67 | 75 | visitRepository.save(visit); |
| 68 | 76 | return visit.getId(); |
| 69 | 77 | } |
| 70 | 78 | |
| 79 | + @Transactional | |
| 71 | 80 | @PutMapping("{visitId}") |
| 72 | 81 | public void updateVisit(@PathVariable int visitId, @RequestBody @Validated VisitFieldsDto visitDto) { |
| 73 | 82 | Visit currentVisit = visitRepository.findById(visitId).orElseThrow(); |
| 74 | 83 | currentVisit.setDate(visitDto.getDate()); |
| 75 | 84 | currentVisit.setDescription(visitDto.getDescription()); |
| 76 | - currentVisit.setVet(vetRepository.getByIdOrNull(visitDto.getVetId())); | |
| 85 | + currentVisit.setVet(resolveVet(visitDto.getVetId())); | |
| 77 | 86 | visitRepository.save(currentVisit); |
| 78 | 87 | } |
| 79 | 88 | |
| 89 | + private Vet resolveVet(Integer vetId) { | |
| 90 | + try { | |
| 91 | + return vetRepository.getByIdOrNull(vetId); | |
| 92 | + } catch (NoSuchElementException e) { | |
| 93 | + log.warn("Rejecting visit: attending vet id {} does not exist", vetId); | |
| 94 | + throw e; | |
| 95 | + } | |
| 96 | + } | |
| 97 | + | |
| 80 | 98 | @Transactional |
| 81 | 99 | @DeleteMapping("{visitId}") |
| 82 | 100 | public void deleteVisit(@PathVariable int visitId) { |
The vet-detail query was fetching specialties that three write endpoints throw away; the existing one is untouched for the paths that need them.
| public interface VetRepository extends Repository<Vet, Integer> { | ||
| 15 | 15 | @Query("SELECT v FROM Vet v LEFT JOIN FETCH v.specialties WHERE v.id = :id") |
| 16 | 16 | Optional<Vet> findById(int id); |
| 17 | 17 | |
| 18 | + /** The row alone — attaching a vet to a visit needs its identity, not its specialties. */ | |
| 19 | + @Query("SELECT v FROM Vet v WHERE v.id = :id") | |
| 20 | + Optional<Vet> findByIdWithoutSpecialties(int id); | |
| 21 | + | |
| 18 | 22 | /** |
| 19 | 23 | * The attending-vet rule, in one place: a null vetId means "no vet attended (yet)", |
| 20 | 24 | * an unknown one is rejected. Both write paths that accept a vetId from a DTO |
| public interface VetRepository extends Repository<Vet, Integer> { | ||
| 26 | 30 | if (vetId == null) { |
| 27 | 31 | return null; |
| 28 | 32 | } |
| 29 | - return findById(vetId).orElseThrow(); | |
| 33 | + return findByIdWithoutSpecialties(vetId).orElseThrow(); | |
| 30 | 34 | } |
| 31 | 35 | |
| 32 | 36 | void save(Vet vet); |
Demo
Run this in a terminal to start it:cd ~/workspace/petclinic-pr && ./start-docker.sh up --ref 2a03c604
API
Data
Hand-drawn in draw.io, but checked to match the code by ConceptualModelDiagramTest.
added by this PR — new against the base branch
Edit this diagram in draw.io App ↗ or draw.io Web ↗, then (or ) to update the report.To start over, (or ).
cd /Users/victorrentea/workspace/petclinic-pr \
&& /Users/victorrentea/workspace/human-review/skills/human-review/scripts/drawio-diff.py --base origin/main --diagram petclinic-backend/docs/ConceptualModel.drawio.png --concepts petclinic-backend/docs/generated/DomainModel.puml --redraw 'python3 petclinic-backend/docs/scripts/conceptual-model-patch.py' --out-dir .human-review/assets --name conceptual \
&& uv run --with pygments python /Users/victorrentea/workspace/human-review/skills/human-review/scripts/build-review-html.py .human-review/content.json --out .human-review/review.htmlcd /Users/victorrentea/workspace/petclinic-pr \
&& git checkout origin/main -- petclinic-backend/docs/ConceptualModel.drawio.png && python3 petclinic-backend/docs/scripts/conceptual-model-patch.py \
&& /Users/victorrentea/workspace/human-review/skills/human-review/scripts/drawio-diff.py --base origin/main --diagram petclinic-backend/docs/ConceptualModel.drawio.png --concepts petclinic-backend/docs/generated/DomainModel.puml --redraw 'python3 petclinic-backend/docs/scripts/conceptual-model-patch.py' --out-dir .human-review/assets --name conceptual \
&& uv run --with pygments python /Users/victorrentea/workspace/human-review/skills/human-review/scripts/build-review-html.py .human-review/content.json --out .human-review/review.htmlTests
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
Out of scope
Searching or filtering visits by vet.
UIclicks the screenAPIREST/MCPunitone isolated component
Sequence
60@GenerateSequence
61class AddVisitSequenceTest {
⋯8 lines not shown
70 @Test
+71 @Order(1)
72 void addsAVisitToAnExistingPet() throws Exception {
73 given("an owner with at least one pet exists");
74 JsonNode owner = anOwnerWithAPet();
75 int ownerId = owner.path("id").asInt();
76 int petId = owner.path("pets").get(0).path("id").asInt();
77
78 when("the owner detail page is opened");
79 call(mockMvc, get("/api/owners/{ownerId}", ownerId)).andExpect(status().isOk());
80
81 and("a visit is added for the first pet");
82 String description = "Annual check-up " + System.currentTimeMillis();
83 call(mockMvc, post("/api/owners/{ownerId}/pets/{petId}/visits", ownerId, petId)
84 .contentType(MediaType.APPLICATION_JSON)
85 .content(mapper.writeValueAsString(Map.of("date", VISIT_DATE, "description", description))))
86 .andExpect(status().isCreated());
87
88 then("the visit is listed under the pet");
89 JsonNode reloaded = json(call(mockMvc, get("/api/owners/{ownerId}", ownerId))
90 .andExpect(status().isOk()));
91 assertThat(reloaded.path("pets").get(0).path("visits").toString())
92 .contains(description)
93 .contains(VISIT_DATE);
94 }
⋯29 lines not shown
+124 @Test
+125 @Order(2)
+126 @WithMockUser(roles = {"OWNER_ADMIN", "VET_ADMIN"})
+127 void remembersTheVetWhoAttendedIt() throws Exception {
+128 given("an owner with at least one pet exists");
+129 JsonNode owner = anOwnerWithAPet();
+130 int ownerId = owner.path("id").asInt();
+131 int petId = owner.path("pets").get(0).path("id").asInt();
+132
+133 and("the clinic has a vet who can attend it");
+134 JsonNode vet = theFirstVet();
+135 int vetId = vet.path("id").asInt();
+136 String vetName = vet.path("firstName").asText() + " " + vet.path("lastName").asText();
+137
+138 when("a visit is booked for that pet with that vet attending");
+139 String description = "Annual check-up " + System.currentTimeMillis();
+140 call(mockMvc, post("/api/owners/{ownerId}/pets/{petId}/visits", ownerId, petId)
+141 .contentType(MediaType.APPLICATION_JSON)
+142 .content(mapper.writeValueAsString(
+143 Map.of("date", VISIT_DATE, "description", description, "vetId", vetId))))
+144 .andExpect(status().isCreated());
+145
+146 then("that pet's history shows the visit was attended by that vet");
+147 JsonNode reloaded = json(call(mockMvc, get("/api/owners/{ownerId}", ownerId))
+148 .andExpect(status().isOk()));
+149 JsonNode visit = visitDescribed(reloaded, description);
+150 // The name, not only the id: the id proves the column was written, the two names
+151 // prove the read path joins the vet back in — which is the half the UI depends on.
+152 assertThat(visit.path("vetId").asInt()).isEqualTo(vetId);
+153 assertThat(visit.path("vetFirstName").asText() + " " + visit.path("vetLastName").asText())
+154 .isEqualTo(vetName);
+155 }
15@generate_sequence
16Scenario: A visit remembers the vet who attended it
17 When I book a visit for that pet with "Helen Leary" attending
18 Then that pet's history shows the visit was attended by "Helen Leary"
35test('Add a visit to an existing pet from the owner detail page',
36 {tag: [GENERATE_SEQUENCE_TAG]},
37 async ({page}) => {
38 const {ownerId} = await an_owner_with_at_least_one_pet_exists();
39
40 await open_owner_detail_page(page, ownerId);
41 await click_add_visit_for_first_pet(page, 'Add Visit');
42 const description = await fill_visit_date_and_unique_description(page, VISIT_DATE);
43 await submit_visit_form(page);
44
45 await expect_back_on_owner_detail_page(page, ownerId);
46 await expect_pet_visit_list_contains(page, VISIT_DATE, description);
+47 await expect_pet_visit_list_shows_no_vet(page, VISIT_DATE, description);
+48 });
⋯3 lines not shown
+52test('Add a visit attended by a vet',
+53 {tag: [GENERATE_SEQUENCE_TAG]},
+54 async ({page}) => {
+55 const {ownerId} = await an_owner_with_at_least_one_pet_exists();
+56
+57 await open_owner_detail_page(page, ownerId);
+58 await click_add_visit_for_first_pet(page, 'Add Visit');
+59 const description = await fill_visit_date_and_unique_description(page, VISIT_DATE);
+60 const vetName = await select_first_vet_in_visit_form(page);
+61 await submit_visit_form(page);
+62
+63 await expect_back_on_owner_detail_page(page, ownerId);
+64 await expect_pet_visit_list_shows_vet(page, VISIT_DATE, description, vetName);
65 });
25@generate_sequence
26Scenario: Searching with an empty last name lists every owner
27 When I open the owners page
28 And I search owners for ""
29 Then every owner in the clinic is listed
Structure
Packages — the dependencies allowed between Java packages, written by hand as the rule and enforced on every build by PackagesArchTest (ArchUnit): the test fails when the code disagrees with it. This branch did briefly break it — the [Mapper] → [DTO] arrow had been commented out during a demo. Restoring it is in the fixed list on the Review tab.
Maven modules — from mvn dependency:tree per project, filtered to this repo's own groupId:artifactId pairs: four standalone builds with nothing between them. There is no aggregator pom.xml at the root, and petclinic-frontend and petclinic-test have no pom.xml at all.
Code City
11 classes lit — a clean vertical slice: domain, mapper, repository, controller, and the tests beside them.
UX
main introduced app-combo — one standardised single-select that marks its host data-ds="combo" — and moved every existing single-select onto it. This branch adds a vet picker to two screens and puts only one of them on the component.
Flagged: a native control filling a role the design system covers, with no [data-ds] above it. The roles are derived from the components themselves — the attribute is the registry — so controls the design system has no component for, like the multi-select on Edit a vet, are listed as considered and deliberately not judged.
Nothing else in the repository catches this. Not ESLint, not the guardrails, not Cucumber: app-combo renders an inner <select> carrying the same id, so page.locator('select#vetId') in add-visit.spec.ts matches either implementation identically.
1 gap across 7 screens, 4 design-system components in place · 1 regression
data-ds="combo" covers select — runtime:Edit a visit:new: <select> inside app-combo[name="vetId"]1 gap · 0 design-system components in place

[data-ds]new or changed on this brancheverything else is deliberately unmarked
[data-ds]new or changed on this brancheverything else is deliberately unmarked
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| gap | test-pr | Vet#vetId | select | native <select> in a role the design system covers (combo), and it is not inside any [data-ds] hostnew on this branch: it shipped bare, it was never migrated | added | — |
input[name="date"] Date — role input[type=text], not covered#description Description — role input[type=text], not covered0 gaps · 1 design-system component in place

[data-ds]new or changed on this brancheverything else is deliberately unmarked
[data-ds]new or changed on this brancheverything else is deliberately unmarked
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| ok | test-pr | Vetapp-combo[name="vetId"] | design-system component combo | added | — |
input[name="date"] Date — role input[type=text], not covered#description Description — role input[type=text], not covered0 gaps · 1 design-system component in place

[data-ds]new or changed on this brancheverything else is deliberately unmarked
[data-ds]new or changed on this brancheverything else is deliberately unmarked
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| ok | test-pr | Typeapp-combo[name="type"] | design-system component combo | same | 0% | |
| ok | main | Typeapp-combo[name="type"] | design-system component combo | same | 0% |
#owner_name Owner — role input[type=text], not covered#name Name — role input[type=text], not coveredinput[name="birthDate"] Birth Date — role input[type=text], not covered0 gaps · 1 design-system component in place

[data-ds]new or changed on this brancheverything else is deliberately unmarked
[data-ds]new or changed on this brancheverything else is deliberately unmarked
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| ok | test-pr | Typeapp-combo[name="pettype"] | design-system component combo | same | 0% | |
| ok | main | Typeapp-combo[name="pettype"] | design-system component combo | same | 0% |
#owner_name Owner — role input[type=text], not covered#name Name — role input[type=text], not coveredinput[name="birthDate"] Birth Date — role input[type=text], not covered#type1 Type — role input[type=text], not covered0 gaps · 1 design-system component in place

[data-ds]new or changed on this brancheverything else is deliberately unmarked
[data-ds]new or changed on this brancheverything else is deliberately unmarked
| side | element | role | why | delta | churn | |
|---|---|---|---|---|---|---|
| ok | test-pr | Typeapp-combo[name="specialties"] | design-system component combo | same | 0% | |
| ok | main | Typeapp-combo[name="specialties"] | design-system component combo | same | 0% |
#firstName First Name — role input[type=text], not covered#lastName Last Name — role input[type=text], not covered0 gaps · 0 design-system components in place

[data-ds]new or changed on this brancheverything else is deliberately unmarked
[data-ds]new or changed on this brancheverything else is deliberately unmarked
Nothing on this screen is a design-system component or a gap where one belongs.
#firstName First Name — role input[type=text], not covered#lastName Last Name — role input[type=text], not covered#spec Specialties — role role=combobox, not covered0 gaps · 0 design-system components in place

[data-ds]new or changed on this brancheverything else is deliberately unmarked
[data-ds]new or changed on this brancheverything else is deliberately unmarked
Nothing on this screen is a design-system component or a gap where one belongs.
#lastName Last name — role input[type=text], not coveredComplexity
Cognitive complexity of the whole flow behind each entry point.
Logging
Found structurally searching for common logging libraries.
81private int bookVisit(VisitDto visitDto) {
+82 log.info("Booking visit for pet {}: {}", visitDto.getPetId(), visitDto.getDescription());
visitDto.getPetId() — numeric pet database identifiervisitDto.getDescription() — free-text visit description whose content is unconstrained and could include personal details+99private Vet resolveVet(Integer vetId) {
⋯3 lines not shown
+103 log.warn("Rejecting visit: attending vet id {} does not exist", vetId);
vetId — just a numeric vet database identifier, no name or contact info+203private Vet resolveVet(Integer vetId) {
⋯3 lines not shown
+207 log.warn("Rejecting visit: attending vet id {} does not exist", vetId);
vetId — numeric vet database id parameter, not personal data itself🤖 AI Evaluation:
The code block is the evidence: alongside each statement it quotes the lines its logged values came from, walked back structurally by ast-grep and cut from the working tree with their real line numbers.
CODEOWNERS