eqs openapi export lit le dist/ sans vérifier qu'il est à jour : on peut committer un contrat périmé, et --check ne le voit pas #33

Closed
opened 2026-09-09 16:45:06 +00:00 by nicolas-fryder · 2 comments

Constaté le 2026-09-09 sur le premier satellite réel monté depuis eqs-satellite-template, en ajoutant une deuxième ressource à l'API. @eqs/* 0.7.0, pnpm 11.8.0.

eqs openapi export lit le point d'entrée déclaré dans package.json ("eqs": { "openapi": "./apps/api/dist/openapi.js" }) — c'est-à-dire le build compilé, pas les sources. Rien ne vérifie que ce dist/ correspond au code actuel, et rien ne le signale.

Ce qui s'est passé

Après avoir écrit un nouveau contrôleur, pnpm openapi a régénéré un openapi.json sans les nouvelles routes — et même sans certaines routes plus anciennes, le dist/ datant d'un état antérieur. La commande sort en 0 : aucun avertissement, aucun indice que le contrat produit décrit un code qui n'existe plus.

Il a fallu jouer pnpm --filter api build avant pnpm openapi pour obtenir le bon contrat. Cette étape n'est mentionnée ni dans AGENTS.md, ni dans le README du gabarit, ni par la commande elle-même.

Reproduction

Dans un satellite, à partir d'un état où dist/ est à jour :

# ajouter un contrôleur, ou une route à un contrôleur existant
pnpm openapi
git diff openapi.json     # vide : la nouvelle route est absente
pnpm --filter api build
pnpm openapi
git diff openapi.json     # la route apparaît

Pourquoi c'est plus grave qu'un simple oubli d'étape

Le garde-fou censé rattraper ça ne peut pas le faire. eqs openapi export --check compare le contrat committé à ce que produit le même dist/. Si dist/ est périmé, les deux sont cohérents entre eux et faux tous les deux par rapport aux sources : le contrôle passe au vert.

Autrement dit, la chaîne peut committer et déployer un contrat qui ne décrit pas le service, sans qu'aucune porte ne s'en aperçoive — ni le pre-push, ni la CI. Or le contrat n'est pas un document : c'est ce dont les clients sont générés, et ce que le Dockerfile copie dans l'image.

Le symptôme visible pour la personne, en attendant, est déroutant : elle vient d'écrire une route, pnpm openapi ne produit rien, et il n'y a aucune raison apparente. On cherche l'erreur dans le décorateur.

Ce que ça coûte

Le geste manquant coûte quelques minutes. Le trou dans le contrôle, lui, peut coûter un client généré faux et un contrat déployé qui ment sur le service. C'est ce second point qui motive le ticket.

Ce que je proposerais

Trois pistes, de la plus simple à la plus sûre :

  1. eqs openapi export construit d'abord, ou refuse de tourner si le dist/ est plus ancien que les sources (comparaison de dates la plus récente sous src/ contre celle du point d'entrée). Le message dirait quoi faire.
  2. Le script du gabarit enchaîne les deux : "openapi": "pnpm --filter api build && eqs openapi export". Corrige l'usage courant, mais laisse le trou de --check ouvert si la CI appelle la CLI directement.
  3. --check construit systématiquement avant de comparer. C'est ce qui ferme réellement la faille : le contrôle ne peut plus se comparer à lui-même.

La 1 et la 3 me semblent les bonnes ; je n'ai pas la vue d'ensemble de la CLI pour trancher.

Contournement en place

pnpm --filter api build joué avant chaque pnpm openapi dans le satellite. Rien d'écrit dans le dépôt pour l'instant — c'est une consigne portée à la main, donc exactement le genre de chose qui se perdra.

Constaté le 2026-09-09 sur le premier satellite réel monté depuis `eqs-satellite-template`, en ajoutant une deuxième ressource à l'API. `@eqs/*` 0.7.0, pnpm 11.8.0. `eqs openapi export` lit le point d'entrée déclaré dans `package.json` (`"eqs": { "openapi": "./apps/api/dist/openapi.js" }`) — c'est-à-dire le **build compilé**, pas les sources. Rien ne vérifie que ce `dist/` correspond au code actuel, et rien ne le signale. ## Ce qui s'est passé Après avoir écrit un nouveau contrôleur, `pnpm openapi` a régénéré un `openapi.json` **sans les nouvelles routes** — et même sans certaines routes plus anciennes, le `dist/` datant d'un état antérieur. La commande sort en 0 : aucun avertissement, aucun indice que le contrat produit décrit un code qui n'existe plus. Il a fallu jouer `pnpm --filter api build` avant `pnpm openapi` pour obtenir le bon contrat. Cette étape n'est mentionnée ni dans `AGENTS.md`, ni dans le `README` du gabarit, ni par la commande elle-même. ## Reproduction Dans un satellite, à partir d'un état où `dist/` est à jour : ```bash # ajouter un contrôleur, ou une route à un contrôleur existant pnpm openapi git diff openapi.json # vide : la nouvelle route est absente pnpm --filter api build pnpm openapi git diff openapi.json # la route apparaît ``` ## Pourquoi c'est plus grave qu'un simple oubli d'étape Le garde-fou censé rattraper ça ne peut pas le faire. `eqs openapi export --check` compare le contrat committé à ce que produit **le même `dist/`**. Si `dist/` est périmé, les deux sont cohérents *entre eux* et faux tous les deux par rapport aux sources : le contrôle passe au vert. Autrement dit, la chaîne peut committer et déployer un contrat qui ne décrit pas le service, sans qu'aucune porte ne s'en aperçoive — ni le `pre-push`, ni la CI. Or le contrat n'est pas un document : c'est ce dont les clients sont générés, et ce que le `Dockerfile` copie dans l'image. Le symptôme visible pour la personne, en attendant, est déroutant : elle vient d'écrire une route, `pnpm openapi` ne produit rien, et il n'y a aucune raison apparente. On cherche l'erreur dans le décorateur. ## Ce que ça coûte Le geste manquant coûte quelques minutes. Le **trou dans le contrôle**, lui, peut coûter un client généré faux et un contrat déployé qui ment sur le service. C'est ce second point qui motive le ticket. ## Ce que je proposerais Trois pistes, de la plus simple à la plus sûre : 1. **`eqs openapi export` construit d'abord**, ou refuse de tourner si le `dist/` est plus ancien que les sources (comparaison de dates la plus récente sous `src/` contre celle du point d'entrée). Le message dirait quoi faire. 2. **Le script du gabarit enchaîne les deux** : `"openapi": "pnpm --filter api build && eqs openapi export"`. Corrige l'usage courant, mais laisse le trou de `--check` ouvert si la CI appelle la CLI directement. 3. **`--check` construit systématiquement avant de comparer.** C'est ce qui ferme réellement la faille : le contrôle ne peut plus se comparer à lui-même. La 1 et la 3 me semblent les bonnes ; je n'ai pas la vue d'ensemble de la CLI pour trancher. ## Contournement en place `pnpm --filter api build` joué avant chaque `pnpm openapi` dans le satellite. Rien d'écrit dans le dépôt pour l'instant — c'est une consigne portée à la main, donc exactement le genre de chose qui se perdra.
Author
Owner

Vrai, reproduit, corrigé — avec une réserve sur un point du ticket, qui est faux et qui compte.

La reproduction, dans un clone du gabarit, dist fraîchement construit :

pnpm --filter api build && pnpm openapi   # contrat à jour, aucun diff
# on ajoute une route à ping.controller.ts, SANS reconstruire
pnpm openapi
git diff --stat openapi.json              # vide
pnpm exec eqs openapi export --check
# → « Le contrat committé est à jour. »

Exactement ce que vous décrivez : la commande sort en 0, le contrat ignore la route, et --check confirme un contrat qui ment. Votre analyse du trou est juste — --check se compare au même dist, donc les deux sont cohérents entre eux et faux tous les deux.

Ce qui est faux : « ni le pre-push, ni la CI ». La CI l'attrape. Le workflow contract (eqs-workflows/.forgejo/workflows/contract.yml, job contrat-a-jour) fait, sur un checkout neuf :

- run: pnpm install --frozen-lockfile
- run: pnpm run build
- name: le contrat committé est celui du code
  run: pnpm exec eqs openapi export --check

Le dist y est reconstruit depuis les sources : le contrôle ne peut pas se comparer à un build périmé. Un contrat en retard rougit en CI.

La nuance n'est pas de la coquetterie, parce qu'elle déplace le vrai reproche. Ce n'est pas « personne ne le voit » — c'est « on le voit trop tard » : la CI ne bloque rien par conception (D-013), le webhook Forgejo → Coolify a déjà déployé quand le pipeline démarre. Le contrat faux part en production, et la CI le constate après coup. C'est déjà bien assez grave pour justifier le correctif, mais il fallait le dire juste.

Le correctif, votre piste 1 étendue à --check (votre piste 3) :

eqs openapi export refuse quand le point d'entrée est plus ancien que les sources, et nomme le fichier fautif :

eqs openapi export : ./apps/api/dist/openapi.js est plus ancien que les sources.
  src/ping/ping.controller.ts a changé depuis la dernière construction.
  Le contrat serait celui du code d’avant, sans que rien ne le dise.
  Construisez d’abord :  pnpm --filter <app> build

Refuser plutôt qu'avertir, parce qu'un contrat n'est pas un document : c'est ce dont les clients sont générés et ce que l'image embarque. C'est D-042.

Deux bornes, pour qu'il ne crie pas à tort :

  • la barrière ne s'arme que si le point d'entrée porte un segment dist ou build. Le profil next-fullstack déclare une source (./apps/web/src/openapi.ts) que Node lit telle quelle : rien n'y est compilé, donc rien ne peut être en retard, et une source plus récente qu'une autre y est la normale ;
  • elle ne compare que l'application à qui ce dist appartient (.../apps/api/dist/… → .../apps/api). Remonter à tout le dépôt ferait refuser l'export de l'API parce qu'on vient de toucher au front.

Trois tests couvrent ces trois cas, dont celui de --check.

Ce que la barrière ne voit pas, et je préfère l'écrire que vous laisser le découvrir : une mise à jour de @eqs/* ou d'une autre dépendance qui change la forme du contrat sans qu'aucune source du dépôt ne bouge. Les dates ne peuvent rien y faire. La CI, elle, construit sur un checkout neuf et ne dépend d'aucune date — c'est ce qui la garde utile même une fois cette barrière posée.

Ce que je n'ai pas fait, votre piste 2 : enchaîner build && eqs openapi export dans le script du gabarit. Ça masquerait la barrière au lieu de l'exercer, et un build complet à chaque export coûte plus que le message d'erreur. Si l'enchaînement vous manque à l'usage, dites-le.

Votre consigne portée à la main peut disparaître à la prochaine version de @eqs/* : pnpm openapi vous dira lui-même quand construire. Branche fix/tickets-socle-du-premier-satellite.

Vrai, reproduit, corrigé — **avec une réserve sur un point du ticket, qui est faux et qui compte.** **La reproduction**, dans un clone du gabarit, `dist` fraîchement construit : ```bash pnpm --filter api build && pnpm openapi # contrat à jour, aucun diff # on ajoute une route à ping.controller.ts, SANS reconstruire pnpm openapi git diff --stat openapi.json # vide pnpm exec eqs openapi export --check # → « Le contrat committé est à jour. » ``` Exactement ce que vous décrivez : la commande sort en 0, le contrat ignore la route, et `--check` confirme un contrat qui ment. Votre analyse du trou est juste — `--check` se compare au même `dist`, donc les deux sont cohérents entre eux et faux tous les deux. **Ce qui est faux : « ni le `pre-push`, ni la CI ».** La CI l'attrape. Le workflow `contract` (`eqs-workflows/.forgejo/workflows/contract.yml`, job `contrat-a-jour`) fait, sur un checkout neuf : ```yaml - run: pnpm install --frozen-lockfile - run: pnpm run build - name: le contrat committé est celui du code run: pnpm exec eqs openapi export --check ``` Le `dist` y est reconstruit depuis les sources : le contrôle ne peut pas se comparer à un build périmé. Un contrat en retard **rougit** en CI. La nuance n'est pas de la coquetterie, parce qu'elle déplace le vrai reproche. Ce n'est pas « personne ne le voit » — c'est **« on le voit trop tard »** : la CI ne bloque rien par conception (D-013), le webhook Forgejo → Coolify a déjà déployé quand le pipeline démarre. Le contrat faux part en production, et la CI le constate après coup. C'est déjà bien assez grave pour justifier le correctif, mais il fallait le dire juste. **Le correctif**, votre piste 1 étendue à `--check` (votre piste 3) : `eqs openapi export` **refuse** quand le point d'entrée est plus ancien que les sources, et nomme le fichier fautif : ``` eqs openapi export : ./apps/api/dist/openapi.js est plus ancien que les sources. src/ping/ping.controller.ts a changé depuis la dernière construction. Le contrat serait celui du code d’avant, sans que rien ne le dise. Construisez d’abord : pnpm --filter <app> build ``` Refuser plutôt qu'avertir, parce qu'un contrat n'est pas un document : c'est ce dont les clients sont générés et ce que l'image embarque. C'est **D-042**. Deux bornes, pour qu'il ne crie pas à tort : - la barrière ne s'arme que si le point d'entrée porte un segment `dist` ou `build`. Le profil `next-fullstack` déclare une source (`./apps/web/src/openapi.ts`) que Node lit telle quelle : rien n'y est compilé, donc rien ne peut être en retard, et une source plus récente qu'une autre y est la normale ; - elle ne compare que **l'application à qui ce `dist` appartient** (`.../apps/api/dist/…` → `.../apps/api`). Remonter à tout le dépôt ferait refuser l'export de l'API parce qu'on vient de toucher au front. Trois tests couvrent ces trois cas, dont celui de `--check`. **Ce que la barrière ne voit pas**, et je préfère l'écrire que vous laisser le découvrir : une mise à jour de `@eqs/*` ou d'une autre dépendance qui change la forme du contrat sans qu'aucune source du dépôt ne bouge. Les dates ne peuvent rien y faire. La CI, elle, construit sur un checkout neuf et ne dépend d'aucune date — c'est ce qui la garde utile même une fois cette barrière posée. **Ce que je n'ai pas fait**, votre piste 2 : enchaîner `build && eqs openapi export` dans le script du gabarit. Ça masquerait la barrière au lieu de l'exercer, et un `build` complet à chaque export coûte plus que le message d'erreur. Si l'enchaînement vous manque à l'usage, dites-le. **Votre consigne portée à la main peut disparaître** à la prochaine version de `@eqs/*` : `pnpm openapi` vous dira lui-même quand construire. Branche `fix/tickets-socle-du-premier-satellite`.
nicolas-fryder referenced this issue from a commit 2026-09-09 20:28:21 +00:00
nicolas-fryder referenced this issue from a commit 2026-09-09 21:51:55 +00:00
Author
Owner

Vérifié en 0.8.0 : corrigé, contournement retiré

Repris chez nous le 2026-09-10, sur @eqs/cli 0.8.0. La correction fonctionne, et le contournement est parti.

La mesure

dist construit, puis une route ajoutée aux sources sans reconstruire :

$ pnpm exec eqs openapi export --check

eqs openapi export : ./apps/api/dist/openapi.js est plus ancien que les sources.
  src\graphe\noeud.controller.ts a changé depuis la dernière construction.
  Le contrat serait celui du code d'avant, sans que rien ne le dise.
  Construisez d'abord :  pnpm --filter <app> build

Le message nomme le fichier changé et dit quoi faire — c'est mieux que ce que je demandais.

Et le code de sortie est 1, vérifié séparément :

$ pnpm exec eqs openapi export --check >/dev/null 2>&1; echo $?
1

Je le signale parce que c'est le point qui décidait de tout. Le trou du ticket n'était pas l'absence de message : c'était que --check se comparait au même dist périmé et laissait passer. Un refus affiché sur un code 0 aurait été un faux correctif — la personne aurait vu l'avertissement, la CI non. Là, la porte se ferme réellement.

Après retrait de la route d'essai et reconstruction : « Le contrat committé est à jour », aucun écart sur openapi.json.

Ce que ça change chez nous

Le script racine redevient celui du gabarit :

"openapi": "eqs openapi export"

Notre pnpm --filter api build && a disparu, et la décision locale qui le portait devient une ligne d'histoire datée plutôt qu'une décision active. Une divergence de moins avec le gabarit.

Un point qui reste, et qui vous appartient

eqs-satellite-template#16 décrit la même cause sous une autre forme : pnpm dev fait node --watch dist/main.js sans que rien ne recompile, donc le serveur sert un dist périmé en silence. Ça nous a encore coûté du temps hier : une vérification de bout en bout échouait sur un défaut corrigé le matin même.

Vous aviez noté que la vraie réponse serait, partout où le socle lit dist/, de comparer sa date à celle des sources et de le dire. C'est exactement ce que fait openapi export maintenant — et ça marche très bien. Le même geste appliqué au script dev fermerait l'autre moitié.

De notre côté, ce ticket est refermé.

## Vérifié en 0.8.0 : corrigé, contournement retiré Repris chez nous le 2026-09-10, sur `@eqs/cli` 0.8.0. **La correction fonctionne**, et le contournement est parti. ### La mesure `dist` construit, puis une route ajoutée aux **sources** sans reconstruire : ``` $ pnpm exec eqs openapi export --check eqs openapi export : ./apps/api/dist/openapi.js est plus ancien que les sources. src\graphe\noeud.controller.ts a changé depuis la dernière construction. Le contrat serait celui du code d'avant, sans que rien ne le dise. Construisez d'abord : pnpm --filter <app> build ``` Le message nomme le fichier changé et dit quoi faire — c'est mieux que ce que je demandais. **Et le code de sortie est 1**, vérifié séparément : ``` $ pnpm exec eqs openapi export --check >/dev/null 2>&1; echo $? 1 ``` Je le signale parce que c'est le point qui décidait de tout. Le trou du ticket n'était pas l'absence de message : c'était que `--check` se comparait au même `dist` périmé et laissait passer. Un refus affiché sur un code 0 aurait été un faux correctif — la personne aurait vu l'avertissement, la CI non. Là, la porte se ferme réellement. Après retrait de la route d'essai et reconstruction : « Le contrat committé est à jour », aucun écart sur `openapi.json`. ### Ce que ça change chez nous Le script racine redevient celui du gabarit : ```json "openapi": "eqs openapi export" ``` Notre `pnpm --filter api build &&` a disparu, et la décision locale qui le portait devient une ligne d'histoire datée plutôt qu'une décision active. **Une divergence de moins avec le gabarit.** ### Un point qui reste, et qui vous appartient `eqs-satellite-template#16` décrit la même cause sous une autre forme : `pnpm dev` fait `node --watch dist/main.js` sans que rien ne recompile, donc le serveur sert un `dist` périmé en silence. Ça nous a encore coûté du temps hier : une vérification de bout en bout échouait sur un défaut corrigé le matin même. Vous aviez noté que la vraie réponse serait, partout où le socle lit `dist/`, de comparer sa date à celle des sources et de le dire. **C'est exactement ce que fait `openapi export` maintenant** — et ça marche très bien. Le même geste appliqué au script `dev` fermerait l'autre moitié. De notre côté, ce ticket est refermé.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Infrastructure/eqs-platform#33
No description provided.