Passe C — Findings post-refactor (sans /ultrareview cloud)¶
Contexte¶
/ultrareviewrenvoie 404/502 de manière transitoire depuis 24 h → pivot Plan C : relecture disciplinée locale.- Scope : 7 commits sur
feature/refactor-architect(ec8b860..958baf3). - Relecture axée sur 8 axes (error handling, race conditions, memory leaks, typing, tests faibles, cohérence cross-file, Pinia/Vue 3 anti-patterns, règle "pas d'accès direct
useStationsStore"). - 102 tests passent. Gate
pnpm typecheckdit « vert » — voir C1 : ce gate ne vérifie rien.
Findings¶
Critical (1)¶
C1 — pnpm typecheck ne typecheck rien (gate faussement vert)¶
- Fichier :
apps/web/package.json:10 - Sévérité : Critical
- Description : Le script
"typecheck": "vue-tsc --noEmit"exécutevue-tscsans-p/--projectni-b. Aveccomposite: truedanstsconfig.app.jsonetfiles: []danstsconfig.jsonracine,vue-tscn'entre dans aucun projet et sort enexit 0silencieux sans produire d'output. - Preuve :
pnpm vue-tsc --noEmit --project tsconfig.app.jsonfait surgir 39 erreurs (dontM1,M2,vite.config.ts, plusieurs Storybook stories).git checkout main+ même commande → 39 erreurs aussi. Le gate est cassé depuis au moins J15 (pas introduit par le refactor), mais la claim « gates all green » repose dessus. - Impact user-facing : aucun immédiat. Impact projet : la CI GitHub Actions,
pre-commit, et toute revue future reposent sur un check qui ne check pas. Toute régression de typage passe inaperçue. - Fix proposé : remplacer le script par
vue-tsc -b(build mode, exit code correct, walk les composite projects) ouvue-tsc --noEmit --project tsconfig.app.json. Vérifier le comportement dans la CI (workflow GitHub Actions doit bien échouer sur une erreur). Corriger avant merge main : le patch de script est trivial, mais les 39 erreurs qu'il fera remonter doivent être triées (voir M1, M2, et pré-existants).
Major (4)¶
M1 — Factories de test makeStation omettent le champ requis sourcingStatus¶
- Fichiers :
apps/web/src/components/organisms/OStationDrawer.test.ts:16-31apps/web/src/components/organisms/OKeyMetricsSection.test.ts:16-31apps/web/src/lib/map/station-map-mapping.test.ts:6-22- Sévérité : Major
- Description :
StationDTOrequiertsourcingStatus: SourcingStatus(non optionnel, depuis ADR-008 session Option A). Les 3 factories ci-dessus retournentStationDTOsans ce champ. Masqué par C1. Deux autres factories (stations.test.ts:16-31,useStationSelection.test.ts:9-25,useStationsList.test.ts:10-26,useStationMeasurements.test.tsvia$patch,useStationDrawer.test.ts:12-28,hydrodaten.test.ts:6-22,OStationMap.test.ts:94-125) incluent bien le champ — d'où l'incohérence. - Impact user-facing : nul (tests passent, logique sourcing pas exercée par ces suites). Impact dev : typecheck correctement gating fera échouer CI ; les tests OStationMap re-render / OKeyMetricsSection ne vérifient jamais une station research avec un
sourcingStatusnon-trivial (CONFIRMED/ILLUSTRATIVE). - Fix proposé : ajouter
sourcingStatus: partial.sourcingStatus ?? 'CONFIRMED'(même défaut questations.test.ts:28). 3 lignes à ajouter, cohérence des fixtures restaurée.
M2 — chart-model.ts incompatible noUncheckedIndexedAccess¶
- Fichier :
apps/web/src/lib/charts/chart-model.ts:24, 27, 33 - Sévérité : Major
- Description :
points[0]retourneMeasurementPoint | undefined(base tsconfig activenoUncheckedIndexedAccess). La valeur est passée àproject: (p: MeasurementPoint) => number→ TS2345. La fonctionfindNearestPointByPxdéclare retournerMeasurementPoint | nullmais sonreturn bestretourneMeasurementPoint | undefined. Masqué par C1. Pré-existant au refactor (R4 n'a fait que déplacer le fichier). - Impact user-facing : en pratique,
points.length === 0est géré en amont, donc les accèspoints[0]etpoints[i]sont toujours définis à runtime. Aucun crash constaté. Mais un call-site futur qui appellefindNearestPointByPx([], …)sans passer par la garde d'amont crasherait surproject(undefined). - Fix proposé : restructurer
bestvialet best: MeasurementPoint = points[0]!+ mêmes non-null assertions surpoints[i], OU mieux :for (const p of points) { if (best === undefined) { best = p; bestDist = …; continue; } … }en typantbest: MeasurementPoint | undefinedet en guardant le return.
M3 — useStatusStore bypass le pattern façade — asymétrie non documentée¶
- Fichiers :
apps/web/src/components/organisms/OHeroSection.vue:10apps/web/src/components/organisms/OKeyMetricsSection.vue:9- Sévérité : Major
- Description : R3 a enforced la règle « aucun consumer prod n'importe
useStationsStorehors façades » (vérifié clean par grep : seuls tests + Storybook touchent le store directement). MaisuseStatusStoren'a PAS de façade et reste importé directement par 2 organismes prod. L'audit (section §2.2) justifiait cette asymétrie (« useStatusStore plus simple, 1 contexte »), mais ce choix n'est nulle part verrouillé en ADR ni commentaire — un lecteur arrivant après merge va naturellement se demander si c'est une omission ou une décision. - Impact user-facing : aucun. Impact dev : cognitive overhead ; prochaine feature status (ex : ajout d'un polling côté keyMetrics) va soit re-traverser le store directement soit inventer une façade ad-hoc — drift architectural.
- Fix proposé : option A (léger) — commentaire d'ouverture dans
stores/status.tsexpliquant pourquoi pas de façade + mention dans ADR-010. Option B (cohérent) — créercomposables/status/useIngestionStatus.tsqui miroiteuseStationsList(storeToRefs + méthoderefresh). Préférer A sauf si une deuxième façade status émerge.
M4 — OStationDrawer.test.ts teste encore via mount(.vue) au lieu de tester le composable¶
- Fichier :
apps/web/src/components/organisms/OStationDrawer.test.ts(149 lignes) - Sévérité : Major
- Description : R2 a extrait
useStationDrawerjustement pour rendre le drawer testable sans monter le composant.useStationDrawer.test.ts(177 lignes) exerce bien le composable via un probe. MaisOStationDrawer.test.tscontinue de mockerfetch, monter le composant, trigger les clicks sur.o-station-drawer__closeetc. — il teste à la fois la template et la logique. Duplication d'intent : si je changeclose()dans le composable, les deux suites cassent. Si je change le template, seule la deuxième casse. - Impact user-facing : aucun. Impact dev : budget tests double-jobbed sur la même surface ; les tests lents (Puppeteer-like mount + fetch mock) éclipsent les tests rapides (composable pur).
- Fix proposé : ne pas supprimer
OStationDrawer.test.ts— il reste utile comme smoke test de rendu. Mais alléger : garder uniquement « monte, render le nom de station, close button dispatche l'event » (3 tests de rendu) et laisser les assertions sur escape, scroll-lock, retry, hydrodaten-nullity aux suites composable/shared. Budget M4 : à trancher — peut attendre post-merge si pas le temps.
Minor (5)¶
m1 — src/services/.gitkeep dead directory¶
- Fichier :
apps/web/src/services/.gitkeep - Audit M5 proposait de renommer
services/→lib/et supprimer.gitkeep. R4 a bien créélib/mais n'a pas suppriméservices/. Deux dirs qui racontent deux fois la même histoire.trash src/services.
m2 — Storybook decorators seedent Error brut dans error: ApiError | null¶
- Fichier :
apps/web/src/components/organisms/OMapSection.stories.ts:67-69 - Depuis R1,
useStationsStore.errorest typéApiError | null. La story seednew Error('Simulated network failure'). TypeScript gueule (cf. C1), mais le rendu visuel fonctionne (OMapSection fait justev-else-if="error"truthy-check). Réparer en passant{ kind: 'network', cause: new Error('Simulated network failure') }— aligne la story avec la surface post-R1.
m3 — useScrollLock non-composable-safe (1 seul consumer tolérable)¶
- Fichier :
apps/web/src/composables/shared/useScrollLock.ts:17-40 - Si deux consumers instancient
useScrollLocksimultanément, le second snapshotterait'hidden'(posé par le premier) comme valeur à restaurer — l'ordre de fermeture détermine si l'état final est correct. Pratiquement N/A : il n'y a qu'un seul drawer dans l'app. Mais le composable est présenté comme shared/réutilisable → violation silencieuse de son propre contrat. - Fix : soit ref-counter module-scoped (
let locks = 0; if (locks === 0) snapshot ; locks++;), soit commenter explicitement « single-consumer only » en tête de fichier.
m4 — useStationDrawer.now vs store.fetchMeasurements window — skew de ~1s¶
- Fichier :
apps/web/src/composables/stations/useStationDrawer.ts:59-67+apps/web/src/stores/stations.ts:82-83 - Le composable snapshot
now = new Date()pour l'axe X du chart. Le store snapshot SON proprenowau moment du fetch (quelques ms plus tard en pratique, jusqu'à 1s si la sélection est bloquée par un fetch concurrent). Le chart affichera donc[drawerNow - 24h, drawerNow]mais les données reçues couvrent[fetchNow - 24h, fetchNow]. Invisible à l'œil. - Fix : passer
from/todepuis le composable vers le store (fetchMeasurements(id, { from, to })), OU accepter le skew et documenter. Pas prioritaire.
m5 — VITE_API_BASE_URL undefined silencieux¶
- Fichier :
apps/web/src/lib/api-client.ts:19 const API_BASE_URL = import.meta.env.VITE_API_BASE_URL;— si la variable manque,fetch('undefined/stations')→TypeError: Failed to fetch→ surface comme{ kind: 'network' }. L'UI affichera « something went wrong » sans distinguer « env mal configuré en build » de « backend down ». POC-acceptable ; noter pour prod.- Fix :
const API_BASE_URL = import.meta.env.VITE_API_BASE_URL ?? ''n'améliore pas (idem, 404). Meilleur : throw at module load si undefined, ou fallback explicite sur/api/v1+ warn.
Axes propres (RAS)¶
- Axis 2 — Race conditions : RAS. Les 3 façades fan out proprement (
selectedStationIdunique, mesures cached per-station key).usePollingest stateless par design — sifetchStatus > 60s, 2 fetchs concurrent sont attendus et non-destructifs (le dernier assignement gagne, même ID). Clic rapide A → B → A n'entraîne pas corruption cache (keys disjoints). - Axis 3 — Memory leaks : RAS. Tous les
setInterval(usePolling),ResizeObserver(OHydroChart,OStationMap),addEventListener(useEscapeClosevia@vueuse/core),watch(auto-cleanup) bind leur teardown àonScopeDisposeou au lifecycle Vue.map.remove()etresizeObserver.disconnect()appelés sur unmount. - Axis 7 — Pinia/Vue 3 anti-patterns : RAS.
storeToRefsutilisé correctement pour les refs à clé fixe (useStationsList,useStationSelection).useStationMeasurementsutilisecomputed(passtoreToRefs) pour l'accès dynamique parstationId— c'est le bon pattern car le key change. Réactivité OK (le store réassigne toujours l'objet entier, donccomputedre-run sur mutation). - Axis 8 — Règle façade stations : ENFORCED.
grep -rn "useStationsStore" --include="*.vue" src/retourne 0 résultat. Les seuls imports de@/stores/stationshors façades/tests sontOMapSection.stories.ts(exception Storybook) +OStationMap.test.ts/OKeyMetricsSection.test.ts/OStationDrawer.test.ts(exceptions tests via dynamic import pour le mock Leaflet, ou$patchpour seed controlled state). Aucune violation prod.
Synthèse¶
- Total findings : 10 (1 Critical / 4 Major / 5 Minor)
- Corrections recommandées AVANT merge main :
- C1 — fix script
typecheck(1 ligne), re-run, trier les erreurs révélées. - M1 — ajouter
sourcingStatusdans les 3 factories de test (3 lignes). - M2 — corriger
chart-model.tspournoUncheckedIndexedAccess(restructuration mineure). - m1 — supprimer
src/services/.gitkeep(dead dir). - m2 — aligner story
OMapSectionsurApiErrortypé. - Corrections à tracer dans ADR-010 comme dette assumée :
- M3 — asymétrie façade stations / direct useStatusStore (si option A choisie) ; ou redirection vers future US pour créer façade status.
- M4 — overlap tests .vue vs composable pour OStationDrawer ; décision "garder les deux, budget doubled" vs "slim le .vue test" à trancher post-candidature.
- m3 —
useScrollLocksingle-consumer only (commenter ou ref-compter). - m4 — skew
drawerNowvsfetchNow, acceptable tant que pas d'auto-refresh. - Corrections à skipper (pas de fix dans le scope candidature) :
- m5 — env var undefined ; comportement POC-acceptable, flagger prod.
- Les 39 erreurs Storybook (
OHeroSection.stories.ts,ABadge.stories.ts,AButton.stories.ts,OMapSection.stories.ts) pré-existantes du merge J15 → tracer séparément en tâche « pré-candidature si budget ».
Estimation totale pré-merge : ~90 min (C1 + M1 + M2 + m1 + m2).
Note sur le scope refactor vs dette héritée : aucun des findings critique ou majeur n'est attribuable exclusivement aux 7 commits refactor. C1 et M2 pré-existaient ; M1 est apparu quand le champ sourcingStatus est devenu requis (session Option A, 2026-04-22), les tests ajoutés à R2/R3 ont hérité du problème sans le voir car C1 masquait tout. M3 est une conséquence de R3 (façade pour stations) qui n'a pas été étendue à status — par design selon audit §2.2, mais non-documentée.