eqs openapi export refuse d'exporter parce qu'un fichier de test a bougé — or test/ n'est jamais compilé dans dist/ #52

Closed
opened 2026-09-10 15:15:58 +00:00 by nicolas-fryder · 2 comments

Le contrôle de fraîcheur de eqs openapi export compare la date de dist/openapi.js à tous les fichiers source de l'application, test/ compris. Or tsconfig.build.json n'inclut que src/ : un fichier de test ne peut pas rendre dist/ périmé, et le refus est un faux positif.

Reproduction

Dans un satellite, dist/ à jour :

touch apps/api/test/integration/planche.test.ts
pnpm openapi

Sortie :

$ eqs openapi export
eqs openapi export : ./apps/api/dist/openapi.js est plus ancien que les sources.
  test\integration\planche.test.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

pnpm --filter api build fait passer — mais il n'a rien recompilé d'utile, puisque le fichier incriminé n'entre dans aucun dist/.

Où le maillon manque

node_modules/@eqs/cli/dist/outils.js :

const IGNORES = new Set(['node_modules', 'dist', 'build', '.next', '.git', 'coverage', '.turbo']);

/** La première source trouvée plus récente que `quand`, ou `null`. */
function sourcePlusRecente(racine, quand) { … }

sourcePlusRecente descend récursivement depuis la racine de l'app en écartant seulement ces sept noms. test n'y est pas.

Et le tsconfig.build.json du gabarit, celui-là même que la commande suppose :

{
  "extends": "./tsconfig.json",
  "compilerOptions": { "rootDir": "src", "outDir": "dist", "incremental": false },
  "include": ["src"]
}

Le contrôle compare donc dist/ à des fichiers qu'il ne produit jamais.

Ce que ça coûte

Non bloquant : un build de plus et ça passe. Mais c'est un cycle inutile à chaque fois qu'on touche un test avant de régénérer le contrat — c'est-à-dire tout le temps, puisqu'ajouter une route s'accompagne d'ajouter ses tests.

Le coût réel est ailleurs : le message nomme un fichier de test comme cause d'un contrat périmé. C'est faux, et c'est exactement le genre d'affirmation qui apprend à ne plus lire les messages. Un contrôle qui crie au loup sur un cas impossible perd son crédit pour le cas réel — lequel est excellent par ailleurs (le ticket #33 l'a introduit pour de bonnes raisons, et il nous a déjà servi).

Aucun contournement posé de notre côté : on rejoue build.

Ce que je proposerais

Par ordre de préférence :

  1. Lire ce que le tsconfig de construction inclut, et n'inspecter que ça. C'est la seule réponse qui reste juste si un satellite range ses sources autrement — apps/mcp chez nous a une arborescence différente d'apps/api.
  2. À défaut, ajouter test, tests, __tests__, e2e et .superpowers à IGNORES. Plus simple, mais ce n'est qu'une liste de plus à tenir : le prochain dossier non compilé repassera par la même case.
  3. Un repli utile dans les deux cas : ne considérer que les extensions réellement compilées, et exclure les fichiers en .test.ts / .spec.ts où qu'ils soient. Cela couvre le cas d'un test posé à côté de son module dans src/, que ni (1) ni (2) n'attrapent — include: ["src"] les compile, alors que tsconfig.build du gabarit ne les exclut pas explicitement.

Versions

  • @eqs/cli : 0.8.1
  • eqs --version : 0.8.1
  • packageManager : pnpm@11.8.0, Node v24.15.0, Windows 11 Pro 26200

Trouvé en développant le premier satellite réel (Pilotage), relevé par une relecture puis reproduit à l'identique.

Le contrôle de fraîcheur de `eqs openapi export` compare la date de `dist/openapi.js` à **tous** les fichiers source de l'application, `test/` compris. Or `tsconfig.build.json` n'inclut que `src/` : un fichier de test ne peut pas rendre `dist/` périmé, et le refus est un faux positif. ## Reproduction Dans un satellite, `dist/` à jour : ``` touch apps/api/test/integration/planche.test.ts pnpm openapi ``` Sortie : ``` $ eqs openapi export eqs openapi export : ./apps/api/dist/openapi.js est plus ancien que les sources. test\integration\planche.test.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 ``` `pnpm --filter api build` fait passer — mais il n'a rien recompilé d'utile, puisque le fichier incriminé n'entre dans aucun `dist/`. ## Où le maillon manque `node_modules/@eqs/cli/dist/outils.js` : ```js const IGNORES = new Set(['node_modules', 'dist', 'build', '.next', '.git', 'coverage', '.turbo']); /** La première source trouvée plus récente que `quand`, ou `null`. */ function sourcePlusRecente(racine, quand) { … } ``` `sourcePlusRecente` descend récursivement depuis la racine de l'app en écartant seulement ces sept noms. **`test` n'y est pas.** Et le `tsconfig.build.json` du gabarit, celui-là même que la commande suppose : ```json { "extends": "./tsconfig.json", "compilerOptions": { "rootDir": "src", "outDir": "dist", "incremental": false }, "include": ["src"] } ``` Le contrôle compare donc `dist/` à des fichiers **qu'il ne produit jamais**. ## Ce que ça coûte Non bloquant : un `build` de plus et ça passe. Mais c'est un cycle inutile à chaque fois qu'on touche un test avant de régénérer le contrat — c'est-à-dire tout le temps, puisqu'ajouter une route s'accompagne d'ajouter ses tests. Le coût réel est ailleurs : le message **nomme un fichier de test** comme cause d'un contrat périmé. C'est faux, et c'est exactement le genre d'affirmation qui apprend à ne plus lire les messages. Un contrôle qui crie au loup sur un cas impossible perd son crédit pour le cas réel — lequel est excellent par ailleurs (le ticket [#33](https://git.equilibre-structures.fr/Infrastructure/eqs-platform/issues/33) l'a introduit pour de bonnes raisons, et il nous a déjà servi). Aucun contournement posé de notre côté : on rejoue `build`. ## Ce que je proposerais Par ordre de préférence : 1. **Lire ce que le tsconfig de construction inclut**, et n'inspecter que ça. C'est la seule réponse qui reste juste si un satellite range ses sources autrement — `apps/mcp` chez nous a une arborescence différente d'`apps/api`. 2. À défaut, ajouter `test`, `tests`, `__tests__`, `e2e` et `.superpowers` à `IGNORES`. Plus simple, mais ce n'est qu'une liste de plus à tenir : le prochain dossier non compilé repassera par la même case. 3. Un repli utile dans les deux cas : ne considérer que les extensions réellement compilées, et **exclure les fichiers en `.test.ts` / `.spec.ts`** où qu'ils soient. Cela couvre le cas d'un test posé à côté de son module dans `src/`, que ni (1) ni (2) n'attrapent — `include: ["src"]` les compile, alors que `tsconfig.build` du gabarit ne les exclut pas explicitement. ## Versions - `@eqs/cli` : 0.8.1 - `eqs --version` : 0.8.1 - `packageManager` : `pnpm@11.8.0`, Node v24.15.0, Windows 11 Pro 26200 Trouvé en développant le premier satellite réel (Pilotage), relevé par une relecture puis reproduit à l'identique.
Author
Owner

Vrai, reproduit à l'identique sur le gabarit, corrigé — et en cherchant la classe ailleurs, j'ai trouvé la même faute dans l'autre sens, en pire.

Reproduction

Il a fallu construire d'abord : le dist/ du gabarit était déjà en retard, donc le premier essai accusait une source réelle. Une fois apps/api construit :

$ touch apps/api/test/ping.test.ts && pnpm openapi
eqs openapi export : ./apps/api/dist/openapi.js est plus ancien que les sources.
  test\ping.test.ts a changé depuis la dernière construction.

Exactement votre trace.

La correction : votre option 1

Le périmètre est lu dans le tsconfig que le script build de l'application nomme — tsc -p <fichier>, ou --project — avec repli sur tsconfig.build.json puis tsconfig.json. On ne devine pas : comme vous le dites, apps/mcp chez vous n'a pas l'arborescence d'apps/api, et c'est le script qui dit la vérité.

include et exclude sont convertis en expressions régulières selon les règles de TypeScript : * ne traverse pas les séparateurs, ? vaut un caractère, **/ descend, et un motif sans extension ni joker — "src" — désigne le dossier.

Quand le tsconfig est illisible, on retombe sur « tout le dossier ». Ça sur-signale, mais ça ne rate jamais un dist en retard : entre les deux erreurs possibles, c'est celle-là qu'on veut.

Vérifié sur votre gabarit, les deux sens :

geste avant après
touch apps/api/test/ping.test.ts refus export réussi
touch apps/api/src/ping/ping.controller.ts refus refus

Sur votre option 3

Je ne l'ai pas prise, et voici pourquoi — dites-moi si vous n'êtes pas d'accord.

Un .test.ts posé dans src/ est réellement compilé par include: ["src"] : il atterrit dans dist/, donc dist/ est vraiment en retard, et le contrôle a raison de le dire. L'exclure d'office ferait mentir le contrôle dans l'autre sens.

Le bon endroit pour trancher est le tsconfig.build.json du satellite : s'il ne veut pas de ses tests dans dist/, il les exclut là — et le contrôle suivra tout seul, sans rien savoir de la convention. L'option 1 subsume donc l'option 3, à condition que le tsconfig soit honnête. C'est un endroit de plus où la vérité est écrite une fois.

La chasse : la même faute, muette

J'ai regardé les autres comparaisons de dates du socle. cli.baseline-presente datait le code ainsi :

git log -1 --format=%cI -- src

Un satellite range son code dans apps/*/src et n'a pas de src/ à la racine. Vérifié sur le gabarit : la commande rend une sortie vide. Donc dernierCommit valait null, et le contrôle sortait [].

Une baseline périmée n'était signalée dans aucun satellite. Vert, en silence, sans même se déclarer non exécutable.

instance périmètre effet
eqs openapi export (le vôtre) trop large refus sur un cas impossible — ça se voit, vous l'avez signalé le jour même
cli.baseline-presente trop étroit vert permanent — ça ne se voit pas, ça vivait là depuis le début

C'est la règle qui revient à chaque chasse : un faux positif se voit, un faux négatif non, et c'est le second qui coûte.

Corrigé : les dossiers de sources sont ceux qui existent (src, apps, packages), et les deux impasses — aucun dossier reconnu, git muet — se déclarent non exécutables au lieu de rendre []. Un contrôle qui ne peut pas s'exécuter le dit ; c'est écrit dans docs/conformite.md et ce n'était pas respecté ici.

Ce qui est ancré

Sept cas ajoutés, chacun dans les deux sens :

  • un fichier hors du tsconfig de build qui bouge → l'export passe ;
  • une source du tsconfig de build qui bouge → l'export refuse ;
  • un satellite en apps/*/src avec une baseline de l'an 2000 → rougit ;
  • aucun dossier de sources, ou pas de dépôt git → non exécutable, pas [].

Le premier de ces cas a demandé de poser explicitement la date de dist/ après celle des sources dans la fixture : sans ça, tout est écrit dans la même seconde et le cas ne prouve rien. Une porte qui n'a jamais rougi ne garde rien.

Ce que ça ne couvre pas

  • motifVersRegex traite *, ? et **, c'est-à-dire l'essentiel des include réels. Les formes rares de TypeScript — extensions implicites, champ files — ne sont pas gérées : on retombe alors sur le périmètre large, donc sur l'ancien comportement.
  • rien ne vérifie toujours qu'un client généré committé est à jour de son contrat. Le geste existe (pnpm client && git diff --exit-code), il n'est dans aucune porte, faute d'avoir tranché où il tourne — le pre-push, qui doit rester court, ou la CI, qui constate après le déploiement. C'est une décision qui vous revient, pas un oubli ; elle est écrite dans reference/chasses.md depuis deux jours.

État

Branche chasse/knip-et-code-mort sur eqs-platform. Ticket laissé ouvert jusqu'à la publication : chez vous, le refus ne cessera qu'à ce moment-là.

Vrai, reproduit à l'identique sur le gabarit, corrigé — et en cherchant la classe ailleurs, j'ai trouvé **la même faute dans l'autre sens**, en pire. ## Reproduction Il a fallu construire d'abord : le `dist/` du gabarit était déjà en retard, donc le premier essai accusait une source réelle. Une fois `apps/api` construit : ``` $ touch apps/api/test/ping.test.ts && pnpm openapi eqs openapi export : ./apps/api/dist/openapi.js est plus ancien que les sources. test\ping.test.ts a changé depuis la dernière construction. ``` Exactement votre trace. ## La correction : votre option 1 Le périmètre est lu dans le tsconfig que le script `build` de l'application **nomme** — `tsc -p <fichier>`, ou `--project` — avec repli sur `tsconfig.build.json` puis `tsconfig.json`. On ne devine pas : comme vous le dites, `apps/mcp` chez vous n'a pas l'arborescence d'`apps/api`, et c'est le script qui dit la vérité. `include` et `exclude` sont convertis en expressions régulières selon les règles de TypeScript : `*` ne traverse pas les séparateurs, `?` vaut un caractère, `**/` descend, et un motif sans extension ni joker — `"src"` — désigne le dossier. Quand le tsconfig est illisible, on retombe sur « tout le dossier ». Ça sur-signale, mais ça ne rate jamais un `dist` en retard : entre les deux erreurs possibles, c'est celle-là qu'on veut. Vérifié sur votre gabarit, les deux sens : | geste | avant | après | | --- | --- | --- | | `touch apps/api/test/ping.test.ts` | refus | **export réussi** | | `touch apps/api/src/ping/ping.controller.ts` | refus | refus | ## Sur votre option 3 Je ne l'ai pas prise, et voici pourquoi — dites-moi si vous n'êtes pas d'accord. Un `.test.ts` posé **dans** `src/` est réellement compilé par `include: ["src"]` : il atterrit dans `dist/`, donc `dist/` est vraiment en retard, et le contrôle a raison de le dire. L'exclure d'office ferait mentir le contrôle dans l'autre sens. Le bon endroit pour trancher est le `tsconfig.build.json` du satellite : s'il ne veut pas de ses tests dans `dist/`, il les exclut là — et le contrôle suivra tout seul, sans rien savoir de la convention. L'option 1 subsume donc l'option 3, à condition que le tsconfig soit honnête. C'est un endroit de plus où la vérité est écrite une fois. ## La chasse : la même faute, muette J'ai regardé les autres comparaisons de dates du socle. `cli.baseline-presente` datait le code ainsi : ``` git log -1 --format=%cI -- src ``` Un satellite range son code dans `apps/*/src` et **n'a pas de `src/` à la racine**. Vérifié sur le gabarit : la commande rend une sortie vide. Donc `dernierCommit` valait `null`, et le contrôle sortait `[]`. **Une baseline périmée n'était signalée dans aucun satellite.** Vert, en silence, sans même se déclarer non exécutable. | instance | périmètre | effet | | --- | --- | --- | | `eqs openapi export` (le vôtre) | trop **large** | refus sur un cas impossible — ça se voit, vous l'avez signalé le jour même | | `cli.baseline-presente` | trop **étroit** | vert permanent — ça ne se voit pas, ça vivait là depuis le début | C'est la règle qui revient à chaque chasse : un faux positif se voit, un faux négatif non, et c'est le second qui coûte. Corrigé : les dossiers de sources sont ceux qui existent (`src`, `apps`, `packages`), et les deux impasses — aucun dossier reconnu, git muet — se **déclarent non exécutables** au lieu de rendre `[]`. Un contrôle qui ne peut pas s'exécuter le dit ; c'est écrit dans `docs/conformite.md` et ce n'était pas respecté ici. ## Ce qui est ancré Sept cas ajoutés, chacun dans les deux sens : - un fichier hors du tsconfig de build qui bouge → l'export **passe** ; - une source du tsconfig de build qui bouge → l'export **refuse** ; - un satellite en `apps/*/src` avec une baseline de l'an 2000 → **rougit** ; - aucun dossier de sources, ou pas de dépôt git → **non exécutable**, pas `[]`. Le premier de ces cas a demandé de poser explicitement la date de `dist/` après celle des sources dans la fixture : sans ça, tout est écrit dans la même seconde et le cas ne prouve rien. Une porte qui n'a jamais rougi ne garde rien. ## Ce que ça ne couvre pas - `motifVersRegex` traite `*`, `?` et `**`, c'est-à-dire l'essentiel des `include` réels. Les formes rares de TypeScript — extensions implicites, champ `files` — ne sont pas gérées : on retombe alors sur le périmètre large, donc sur l'ancien comportement. - rien ne vérifie toujours qu'un **client généré committé** est à jour de son contrat. Le geste existe (`pnpm client && git diff --exit-code`), il n'est dans aucune porte, faute d'avoir tranché où il tourne — le `pre-push`, qui doit rester court, ou la CI, qui constate après le déploiement. C'est une décision qui vous revient, pas un oubli ; elle est écrite dans `reference/chasses.md` depuis deux jours. ## État Branche `chasse/knip-et-code-mort` sur `eqs-platform`. Ticket laissé ouvert jusqu'à la publication : chez vous, le refus ne cessera qu'à ce moment-là.
Author
Owner

Corrigé. eqs openapi export lit le tsconfig nommé par le script build du dépôt au lieu d'en supposer un (packages/cli/src/outils.ts) : un fichier de test qui bouge ne fait plus dérailler l'export, puisque test/ n'est effectivement jamais compilé dans dist/. La classe est consignée dans reference/chasses.md sous la chasse n°9, « un contrôle dont le périmètre n'est pas celui qu'il mesure ».

Fermé après vérification contre le code de dev le 2026-09-21. Ce ticket restait ouvert faute d'un Closes #52 dans le commit, pas faute de correctif.

Corrigé. `eqs openapi export` lit le tsconfig **nommé par le script `build` du dépôt** au lieu d'en supposer un (`packages/cli/src/outils.ts`) : un fichier de test qui bouge ne fait plus dérailler l'export, puisque `test/` n'est effectivement jamais compilé dans `dist/`. La classe est consignée dans `reference/chasses.md` sous la chasse n°9, « un contrôle dont le périmètre n'est pas celui qu'il mesure ». Fermé après vérification contre le code de `dev` le 2026-09-21. Ce ticket restait ouvert faute d'un `Closes #52` dans le commit, pas faute de correctif.
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#52
No description provided.