From a9786a24ec72c020ed0167e2498e82e31ce4e017 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 12:30:03 +0200 Subject: [PATCH 01/15] =?UTF-8?q?docs(devcontainer):=20un=20poste=20de=20d?= =?UTF-8?q?=C3=A9veloppement=20qui=20n'installe=20rien=20=E2=80=94=20conce?= =?UTF-8?q?ption?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La question était « WSL + devcontainer, sans outils sur le poste, Windows et Linux : possible ? ». Oui, et ce document nomme la frontière plutôt que de promettre l'équivalence. Six des sept vérifications de la CI se rejouent dans le conteneur. La septième — PowerShell 5.1 — ne le peut pas, et c'est écrit noir sur blanc : « estimer que les scripts sont corrects » est la faute exacte qui a livré la v0.1. Trois constats qui viennent du code et non d'une opinion : - Le matériel n'entre pas dans la question. serial.Opener est une seam injectée, et hardware_test.go pose qu'une machine sans port série est « le cas de développement ORDINAIRE ». Un conteneur donne ce que donne un poste dont la balance est débranchée. - Le conteneur RAJOUTE deux gardes à un développeur Windows : la passe -race, qui se saute faute de gcc, et TestADirectoryTheServiceCanReadButNotWriteIsRefused, qui saute sous Windows par nature — à condition que remoteUser ne soit pas root, sans quoi ce banc disparaît en silence. - Un devcontainer.json qui recopie Go 1.26.5, Node 22 et Python 3.13 en ferait un quatrième endroit où ces numéros vivent. SUIVI.md rappelle que le seul compteur d'ADR a menti trois fois pour cette raison : deploy/devcontainer_test.go les compare à go.mod, ci.yml et docs.yml dans les deux sens. L'emplacement du dépôt sous Windows est tranché sur une mesure et non sur un adjectif : 143 ms contre 5 ms pour parcourir 577 fichiers, soit x29. Le bind Windows reste le chemin par défaut — c'est ce qu'un contributeur fera sans rien lire — et le clone côté WSL est documenté comme accélérateur. Ni le Makefile ni make.ps1 ne bougent : le devcontainer est une seconde porte. --- ...evcontainer-poste-sans-outillage-design.md | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md diff --git a/docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md b/docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md new file mode 100644 index 0000000..64f634a --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md @@ -0,0 +1,309 @@ +# Un poste de développement qui n'installe rien — conception + +**Date** : 11/08/2026 · **Branche** : `feat/un-poste-de-dev-sans-rien-installer` · **État** : validé + +> Ce document est une **spécification de conception**. Il décrit ce qu'il faut faire et +> pourquoi ; il ne décrit pas dans quel ordre écrire les fichiers — c'est le rôle du plan +> d'implémentation qui en découle. + +--- + +## 0. D'où vient ce document + +La question posée : *« j'aimerais que ce projet utilise WSL + devcontainer pour son +développement et ne nécessite pas l'installation d'outils de dev sur le poste du +développeur, est-ce possible ? Il faut que ce soit fonctionnel sur Windows et Linux. »* + +La réponse est **oui**, et elle tient parce que le dépôt a déjà payé, une par une, les +décisions qui la rendent possible : aucun test n'a besoin de matériel, la version de chaque +outil est épinglée à un seul endroit, et les tests qui n'ont de sens que sous Windows le +disent eux-mêmes au lieu de mentir en vert. + +Ce document nomme la **frontière** — ce que le conteneur rend, ce qui reste dehors — et +refuse d'aller plus loin. + +--- + +## 1. Le public visé, et ce qu'il change + +Le devcontainer est écrit pour un **contributeur externe** : une autre coopérative clone le +dépôt public, ouvre VS Code, clique « Reopen in Container ». Elle ne connaît ni le projet ni +ses conventions, et il n'y aura personne pour l'aider. + +Deux conséquences qui portent tout le reste : + +1. **Le chemin par défaut doit marcher sans rien lire.** Un contributeur qui doit + comprendre `\\wsl.localhost` avant d'avoir compilé quoi que ce soit abandonne. Le bind + du dossier Windows courant marche tel quel ; l'accélération est une note, pas une + condition. +2. **Ce que le conteneur ne juge pas doit être dit, pas supposé.** « Estimer que les + scripts PowerShell sont corrects » est exactement la faute qui a livré la v0.1. Le + conteneur ne les juge pas ; la CI le fait, et la documentation le dit en une phrase. + +--- + +## 2. La frontière + +### 2.1 Ce que le conteneur rend + +Mesuré contre les six jobs de `.github/workflows/ci.yml` et celui de `docs.yml` : + +| Job | Rejouable dans le conteneur | Remarque | +|---|---|---| +| `race` — `go test -race`, `CGO_ENABLED=1` | ✅ | **Mieux qu'un poste Windows nu** : gcc est dans l'image, la passe ne se saute plus | +| `test` — `CGO_ENABLED=0`, planchers de couverture | ✅ | | +| `guards` — vet, lint, boundary, deps, format | ✅ | golangci-lint épinglé, installé hors module | +| `build` — trois cibles, zéro cgo | ✅ | | +| `front` — eslint, prettier, svelte-check, vitest, budget, `dist` à jour | ✅ | Node 22 dans l'image | +| `docs` — `mkdocs build --strict` | ✅ | Python 3.13 + `handbook/requirements.txt` | +| `scripts` — PowerShell 5.1, `windows-latest` | ⛔ | **Structurellement impossible.** Voir §4 | + +Un contributeur peut donc rejouer **six des sept** vérifications avant de pousser. + +### 2.2 Ce qui reste dehors, et pourquoi + +- **PowerShell 5.1.** Un conteneur Linux n'a pas Windows PowerShell 5.1 et n'en aura + jamais. §4 détaille ce que ça coûte réellement — moins qu'on ne croit. +- **Le matériel réel.** §3 montre que la question ne se pose pas pour la suite de tests ; + elle ne se pose que pour la mise en route manuelle d'un driver, et c'est une ligne de + documentation, pas de l'ingénierie. Voir §9. + +--- + +## 3. Le matériel : la question ne se pose pas + +Ce point avait été soulevé sous la forme « les tests devraient-ils tourner en mode matériel +simulé, ou non branché ? ». **Ni l'un ni l'autre : le dépôt a tranché avant tout +devcontainer, et il n'y a rien à ajouter.** + +Trois preuves, dans le code : + +- `internal/scale/example/conformance_test.go:54` — *« a serial port cannot be opened by + `go test` »* : `serial.Opener` est une seam injectée, et les bancs de conformité passent + une fausse implémentation. +- `internal/platform/hardware_test.go` — l'en-tête de `TestEnumeratingThePortsOfThisMachineNeverFails` + pose qu'une machine **sans aucun port série est « le cas de développement ORDINAIRE »**, + et que le contrat vérifié est « liste vide, jamais d'erreur ». +- `Makefile`, cible `driver` — *« elle ne demande NI MATÉRIEL NI RÉSEAU »*. + +Un conteneur sans port série produit donc **exactement** le résultat d'un poste de +développement dont la balance n'est pas branchée, c'est-à-dire l'état ordinaire de tous les +postes de développement du projet. Rien à simuler, rien à monter, aucun `--device`. + +--- + +## 4. PowerShell : ce qui est perdu, et ce qui ne l'est pas + +Le dépôt juge ses scripts d'installation à quatre niveaux. **Trois tournent sous Linux.** + +| Banc | Sous Linux | +|---|---| +| `deploy/parity_test.go` — parité des deux installeurs | ✅ pur Go, lit les deux scripts comme du texte | +| `deploy/shell_test.go` — analyse de `install.sh` par `sh` | ✅ (`:97` saute s'il n'y a aucun `sh`) | +| `deploy/powershell_test.go` — **analyse** des `.ps1` | ✅ sous `pwsh` 7 | +| bancs qui **exécutent** `common.ps1` | ⛔ `requireWindowsToRunCommonPs1` (`deploy/harness_test.go:176`) | + +`powershellPaths` (`deploy/harness_test.go:140`) renvoie **tous** les PowerShell installés, +et non le premier — le pluriel est ce que la v0.1 a coûté. Dans l'image, `pwsh` 7 est +présent : les scripts sont donc analysés, et une faute de syntaxe grossière rougit chez le +contributeur au lieu d'attendre la CI. + +**Ce qui reste hors de portée** : le comportement propre à 5.1 — le décodage d'un fichier +sans marque d'ordre des octets, corrigé le 10/08/2026. Le job `scripts` de `ci.yml` le rend +sur **chaque** pull request, et la note de `requireWindowsToRunCommonPs1` dit déjà qu'il est +« le SEUL endroit où ces bancs tournent ». Le devcontainer ne retire donc rien : il rend +explicite, pour un contributeur qui n'a pas de Windows, une règle déjà en vigueur — **on ne +fusionne pas sur du vert local, on fusionne sur du vert CI**. + +--- + +## 5. Composition de l'image + +Deux fichiers : `.devcontainer/devcontainer.json` et `.devcontainer/Dockerfile`. + +### 5.1 Base et utilisateur + +`mcr.microsoft.com/devcontainers/base:ubuntu-24.04`, et **`remoteUser` non root**. + +Ce dernier point n'est pas un réflexe d'hygiène, c'est une garde du dépôt : +`TestADirectoryTheServiceCanReadButNotWriteIsRefused` +(`internal/platform/pathchecker_test.go:75`) saute avec *« root écrit dans un répertoire +0555 : la branche est inatteignable sous root »*. Un devcontainer qui tourne en root — le +défaut de beaucoup d'images — ferait **disparaître ce banc en silence**, sans qu'aucune +sortie ne le signale. + +Ce banc mérite d'être suivi jusqu'au bout, parce qu'il inverse le raisonnement habituel : il +saute **aussi** sous Windows (*« un répertoire Windows se ferme par une ACL et non par +`os.Chmod` »*), et sa note dit que « la passe Linux de la CI est ce qui couvre cette +branche ». Un développeur sous Windows ne l'a donc **jamais** exécuté. Le conteneur le lui +rend — à condition de ne pas être root. + +### 5.2 Features, épinglées + +| Feature | Version | Source de vérité | +|---|---|---| +| Go | 1.26.5 | `go.mod` (`toolchain`), `ci.yml` (`GO_VERSION`) | +| Node | 22 | `ci.yml` (`node-version`) | +| Python | 3.13 | `docs.yml` (`python-version`) | +| PowerShell | 7 | — (aucune version épinglée ailleurs ; l'analyse ne dépend pas du correctif) | + +### 5.3 Paquets `apt`, et la raison de chacun + +- `build-essential` — gcc, sans quoi la passe `-race` ne peut pas tourner. C'est le gain le + plus concret du conteneur : sur un poste Windows, cette passe demande WinLibs et se saute + sinon. +- `zip` — la cible `release` du `Makefile` empaquette avec. +- `systemd` — pour `systemd-analyze` seul, que `deploy/linux_test.go:39` saute quand il + manque. Le paquet n'a pas besoin de tourner ; il fournit l'outil de vérification. + +### 5.4 `postCreateCommand` + +Trois installations, et une contrainte forte sur la première : + +1. `golangci-lint` **dans un répertoire jetable**, à la version lue par + `make -s golangci-version`. ADR-039 interdit qu'une dépendance de développement + s'inscrive dans `go.mod` — `make deps` compare `go.mod` aux tables de §17.1 **dans les + deux sens**, et une trace ici ouvrirait un écart permanent. C'est exactement ce que fait + déjà l'étape `make lint` de `ci.yml`, et c'est cette procédure-là qu'on recopie. +2. `pip install -r handbook/requirements.txt`. +3. `npm ci --prefix web`. + +### 5.5 Caches + +`GOMODCACHE`, `GOCACHE` et `web/node_modules` dans des **volumes nommés**. La conséquence +compte pour §7 : la lenteur du bind Windows ne porte plus que sur la lecture des sources, +jamais sur la compilation ni sur l'installation des paquets. + +### 5.6 Fins de ligne — vérifié, pas supposé + +Un dépôt extrait sous Windows puis monté dans un conteneur Linux est le scénario classique +du `#!/bin/sh\r`. Il ne se produit pas ici : `.gitattributes` porte `*.sh text eol=lf`, et +son en-tête dit que **le fichier existe à cause de cette panne exacte** — `dash` répondant +« Syntax error: word unexpected » sans que rien ne pointe vers les fins de ligne. Les `.ps1` +restent en CRLF, que `pwsh` lit sans difficulté. + +### 5.7 Linux + +Le **même** `devcontainer.json`, sans exception ni note : bind natif, et +`updateRemoteUserUID` aligne la propriété des fichiers sur l'utilisateur de l'hôte. La +parité entre les deux plateformes est ici gratuite — c'est la contrainte « zéro cgo » +(ADR-001) qui la paie depuis le début. + +--- + +## 6. Le banc anti-dérive + +### 6.1 Le risque + +Le dépôt écrit ses versions à des endroits qui **se lisent l'un l'autre** plutôt que de se +recopier : `ci.yml` lit la version de golangci-lint par `make -s golangci-version`, et le +`Makefile` explique pourquoi — *« un développeur sur une version plus récente verrait rouge +là où la CI voit vert — ou l'inverse, ce qui est pire, parce que personne ne cherche la +cause d'un vert »*. + +Un `devcontainer.json` qui réécrit ces numéros en fait un **quatrième endroit**. `SUIVI.md` +note que le seul compteur d'ADR a menti **trois fois** pour cette raison. + +### 6.2 Le banc + +`deploy/devcontainer_test.go`, voisin de `parity_test.go` — et `deploy/` est déjà le paquet +qui lit les fichiers de construction : `delivery_test.go:116` ouvre `../Makefile`, +`release_workflow_test.go:92` ouvre `../.github/workflows/ci.yml`. Ce n'est donc pas un +nouveau territoire, c'est le sien. + +Il vérifie, **dans les deux sens** : + +- la version Go du feature ↔ `toolchain` de `go.mod` ↔ `GO_VERSION` de `ci.yml` ; +- la version Node du feature ↔ `node-version` du job `front` ; +- la version Python du feature ↔ `python-version` de `docs.yml` ; +- que le `postCreateCommand` **ne porte aucun numéro de golangci-lint en littéral** : il + doit passer par `make -s golangci-version`. Un numéro écrit là rougit. + +### 6.3 Deux contraintes d'implémentation + +- **`devcontainer.json` est du JSONC.** Ce dépôt commente ses fichiers de configuration + abondamment, et il n'y a aucune raison d'y déroger. `encoding/json` refuse les + commentaires : le banc les dépouille avant de décoder, dans le même esprit que le lecteur + de `powershell_test.go`. +- **Le banc ne peut pas vivre dans `.devcontainer/`.** L'outil Go ignore les répertoires + dont le nom commence par un point : un test posé là ne serait jamais exécuté par + `go test ./...`, et son absence de verdict passerait pour un vert. + +--- + +## 7. Où vit le dépôt sous Windows + +Le devcontainer fonctionne dans les deux cas ; ce qui se décide ici est ce que la +**documentation** recommande. + +Mesure faite sur ce poste, parcours de 577 fichiers d'`internal/` depuis WSL : + +| Emplacement | Temps | Écart | +|---|---|---| +| `/mnt/c/_dev/OpenScale` (bind Windows) | **143 ms** | ×29 | +| `~/osbench` (ext4 WSL) | **5 ms** | référence | + +La pénalité porte sur les **métadonnées**, pas sur le volume : elle se paie à chaque +traversée de l'arbre (`go test ./...`, `gofmt -l .`, `git status`), et §5.5 la borne aux +sources en sortant les caches du bind. + +**Décision** : le bind du dossier Windows courant reste le chemin par défaut — c'est ce +qu'un contributeur fera sans rien lire, et il marche. Le clone côté WSL est documenté comme +accélérateur, avec le chiffre ci-dessus, pour celui que la lenteur gêne. Aucune contrainte +imposée avant la première compilation réussie. + +--- + +## 8. Documentation + +- **`handbook/getting-started.md`** : le parcours conteneur devient le chemin **par + défaut**. Le tableau actuel des prérequis reste **intact** en dessous, comme chemin « sans + conteneur » — il n'est ni supprimé ni résumé. +- Une note Windows portant le chiffre de §7 et le conseil du clone WSL. +- **Une phrase**, pas un paragraphe, sur ce que le conteneur ne juge pas : PowerShell 5.1, + rendu par la CI à chaque pull request. +- `handbook/contributing.md` porte un `TODO(dev)` sur l'absence de `CONTRIBUTING.md` : + **hors périmètre**, on n'y touche pas. + +Le principe d'ODR-0002 s'applique : le fait technique — la frontière, les versions, la +raison de chaque paquet — s'écrit ici et dans les commentaires des fichiers créés ; +`handbook/` n'en reprend que ce qui met en route. + +--- + +## 9. Ce qui n'est pas fait + +Chacun de ces points a été examiné et écarté ; les rouvrir demande une décision explicite. + +- **Passthrough série `usbipd`.** §3 montre qu'aucun test n'en a besoin. Le jour où + quelqu'un fait la mise en route d'un vrai driver, c'est une ligne de documentation — + `--device /dev/ttyUSB0` sous Linux, la chaîne `usbipd-win` sous Windows — et non un + élément du devcontainer. Y toucher maintenant imposerait au contributeur d'installer un + outil sur son poste, ce que cette demande cherche précisément à éviter. +- **Un job CI qui construit l'image.** Preuve plus forte, mais 4 à 6 minutes par pull + request pour redétecter ce qu'un banc de quelques dizaines de lignes voit gratuitement + (§6). +- **Docker Compose, services annexes.** Le binaire est autosuffisant ; SQLite est en pur Go + (ADR-001). Rien à orchestrer. +- **Outils d'agent dans l'image.** Hors sujet. +- **Toute modification du `Makefile` et de `make.ps1`.** Le chemin sans conteneur reste la + référence — c'est lui que la CI exécute. Le devcontainer est une **seconde porte**, pas un + remplacement. + +--- + +## 10. Comment on saura que c'est fini + +1. `deploy/devcontainer_test.go` est vert, et **rougit** si l'on modifie à la main l'une des + versions dans `devcontainer.json` sans toucher sa source de vérité — vérifié en cassant, + pas en relisant. +2. Depuis le conteneur : `make test` passe, **passe `-race` comprise** (c'est le signe que + gcc est bien là et que la garde n'est pas sautée). +3. Depuis le conteneur : `make front-check` passe, et `mkdocs build --strict` aussi. +4. La sortie de `go test ./deploy/ -v` montre les bancs Windows **sautés avec leur raison**, + et les bancs d'analyse `pwsh` **exécutés** — pas l'inverse, et aucun silence. +5. `id -u` dans le conteneur ne renvoie pas `0`, et + `go test ./internal/platform/ -run TestADirectoryTheServiceCanReadButNotWriteIsRefused -v` + s'exécute au lieu de sauter — c'est un banc qu'un poste Windows n'a jamais joué. +6. `make deps` reste vert : l'installation de golangci-lint n'a laissé aucune trace dans + `go.mod`. From b6711f9d0b9963457439b377dfc262c2dcd86869 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 12:39:15 +0200 Subject: [PATCH 02/15] docs(devcontainer): le plan d'implementation, banc avant fichiers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cinq tâches. Le banc anti-dérive est écrit AVANT les fichiers qu'il garde, sur le modèle de tools/deps : sa première exécution est rouge, et ce sont les tâches suivantes qui la font passer au vert. Un banc écrit après un fichier correct ne dit jamais s'il rougirait le jour où le fichier cesse de l'être. Le lecteur de JSONC est une tâche à lui seul parce qu'il porte le seul piège non trivial du lot : un « // » à l'intérieur d'une chaîne n'est pas un commentaire, et un lecteur naïf coupe « https://containers.dev » en deux pour livrer à json.Unmarshal une chaîne non terminée. Le banc vit dans deploy/ et non dans .devcontainer/ : l'outil Go ignore les répertoires commençant par un point, et un test qui ne s'exécute jamais passe pour un vert. La tâche 4 ne se coche pas sur une sortie supposée. Elle casse le banc exprès — 1.26.5 en 1.26.6, vscode en root — pour le voir rougir, et vérifie le même fichier depuis un hôte Linux, où un décalage d'UID ferait apparaître tout le dépôt comme modifié. --- ...08-11-devcontainer-poste-sans-outillage.md | 942 ++++++++++++++++++ 1 file changed, 942 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-11-devcontainer-poste-sans-outillage.md diff --git a/docs/superpowers/plans/2026-08-11-devcontainer-poste-sans-outillage.md b/docs/superpowers/plans/2026-08-11-devcontainer-poste-sans-outillage.md new file mode 100644 index 0000000..ea08d59 --- /dev/null +++ b/docs/superpowers/plans/2026-08-11-devcontainer-poste-sans-outillage.md @@ -0,0 +1,942 @@ +# Un poste de développement qui n'installe rien — plan d'implémentation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Un contributeur qui n'a que Docker et VS Code peut rejouer six des sept vérifications de la CI, sans installer Go, Node, gcc, Python ni golangci-lint sur son poste — sous Windows comme sous Linux. + +**Architecture:** Trois fichiers de configuration sous `.devcontainer/`, et **un banc Go qui les tient**. Le banc est écrit **avant** les fichiers qu'il garde, sur le modèle exact de `tools/deps` : sa première exécution est rouge, et ce sont les tâches suivantes qui la font passer au vert. Il vit dans `deploy/`, seul paquet du dépôt qui lit déjà `../Makefile` et `../.github/workflows/ci.yml`. Aucun changement de code fonctionnel, aucune dépendance nouvelle, ni le `Makefile` ni `make.ps1` ne bougent. + +**Tech Stack:** Go 1.26, bibliothèque standard uniquement (`encoding/json`, `regexp`, `strings`). Images et features `devcontainers` officielles. Aucun paquet npm, aucun module Go ajouté. + +**Spec de référence :** `docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md` (commit `a9786a2`). + +## Global Constraints + +- **Branche** : `feat/un-poste-de-dev-sans-rien-installer`, déjà créée. Ne pas travailler sur `main`. +- **Zéro dépendance nouvelle.** `go.mod` ne doit pas changer d'une ligne. Interdit en particulier : toute bibliothèque YAML ou JSONC. Les fichiers se lisent **comme du texte**, exactement comme le fait `deploy/release_workflow_test.go` — sa note dit pourquoi : *« parsing it would add a dependency to a repository whose whole shape comes from refusing them »*. +- **Aucun changement de code fonctionnel.** Rien sous `internal/`, rien sous `cmd/openscale/`. Le seul code neuf est un fichier `_test.go`. +- **Ni le `Makefile` ni `make.ps1` ne changent.** Le chemin sans conteneur reste la référence ; le devcontainer est une seconde porte. +- **Langue.** Code, identifiants et **commentaires** en anglais ; documentation en français ; messages d'erreur destinés à un humain qui lit une sortie de test en français. +- **Documentation du code** : `godoc` — commentaire commençant par le nom de l'élément, phrase complète, qui explique le *pourquoi* et jamais le *quoi*. +- **Une seule source par version.** Go `1.26.5` vit dans `go.mod` (`toolchain`) et `ci.yml` (`GO_VERSION`) ; Node `22` dans `ci.yml` ; Python `3.13` dans `docs.yml` ; golangci-lint `v2.12.2` dans le `Makefile`. Le devcontainer ne fait qu'y **correspondre**, et c'est le banc qui l'exige. Ne jamais corriger une divergence en changeant la source de vérité pour satisfaire le devcontainer : c'est toujours l'inverse. +- **Fins de ligne.** `.gitattributes` impose `*.sh text eol=lf`. `post-create.sh` doit être commité en LF — le shebang `#!/bin/sh\r` est une panne déjà payée par ce dépôt. +- **Messages de commit** : Conventional Commits, sujet en français **sans accents** (convention du dépôt), corps accentué. **Aucun lien de session ni mention d'outil en pied de message** : ce dépôt est public et rien n'y renvoie vers une conversation privée. + +--- + +## Structure des fichiers + +| Fichier | Responsabilité | Tâche | +|---|---|---| +| `deploy/devcontainer_test.go` (créé) | Le banc anti-dérive **et** le lecteur de JSONC dont il a besoin. Un seul travail : *ce que le conteneur installe est-il ce que le dépôt épingle ?* | 1, 2 | +| `.devcontainer/Dockerfile` (créé) | Les trois paquets `apt` que les bancs exigent, et leur raison | 3 | +| `.devcontainer/devcontainer.json` (créé) | L'image, les features épinglées, l'utilisateur non root, les volumes de cache | 3 | +| `.devcontainer/post-create.sh` (créé) | Ce qui s'installe après la construction : golangci-lint **à la version lue dans le `Makefile`**, mkdocs, les paquets du front | 3 | +| `handbook/getting-started.md` (modifié) | Le parcours conteneur en chemin par défaut ; le tableau des prérequis actuel conservé comme chemin « sans conteneur » | 5 | +| `README.md` (modifié, l. 167) | La phrase « pas de Docker » devient fausse telle quelle : elle se nuance | 5 | + +**Pourquoi le lecteur de JSONC est une tâche à lui seul.** C'est la seule pièce qui porte un piège non trivial — un `//` à l'intérieur d'une chaîne JSON n'est pas un commentaire — et un relecteur peut la rejeter en acceptant tout le reste. C'est le critère de découpe. + +**Pourquoi le banc n'est pas dans `.devcontainer/`.** L'outil Go **ignore** les répertoires dont le nom commence par un point : un `_test.go` posé là ne serait jamais exécuté par `go test ./...`, et son absence de verdict passerait pour un vert. `deploy/` est sa place — c'est déjà le paquet qui lit `../Makefile` (`delivery_test.go:116`) et `../.github/workflows/ci.yml` (`release_workflow_test.go:92`). + +--- + +## Ordre des tâches, et pourquoi il compte + +``` +1 ── 2 (le banc est écrit ; son exécution est ROUGE : .devcontainer/ n'existe pas) + │ + └── 3 (les trois fichiers → le banc passe au VERT) + │ + └── 4 (vérification RÉELLE dans le conteneur, banc cassé exprès compris) + │ + └── 5 (documentation) +``` + +Le banc **avant** les fichiers : c'est ce qui prouve qu'il attrape quelque chose. Un banc écrit après un fichier correct ne dit jamais s'il rougirait le jour où le fichier cesse de l'être — et `SUIVI.md` rappelle que le compteur d'ADR a menti **trois fois** sous une surveillance de bonne volonté. + +--- + +### Task 1: Le lecteur de JSONC + +**Files:** +- Create: `deploy/devcontainer_test.go` + +**Interfaces:** +- Consumes: rien. +- Produces: `func withoutJSONComments(source string) string` — prend le **texte** d'un fichier JSONC, rend le même texte sans ses commentaires `//` ni `/* */`, en laissant intact tout ce qui se trouve **à l'intérieur d'une chaîne**. Consommée par la tâche 2. + +**Contexte pour l'implémenteur.** `devcontainer.json` est du JSONC : la spécification `containers.dev` autorise les commentaires, et ce dépôt commente abondamment ses fichiers de configuration — il n'y a aucune raison d'y déroger ici. Mais `encoding/json` refuse un commentaire. Il faut donc les retirer avant de décoder. + +Le piège est le suivant, et il est réel : un lecteur qui chercherait `//` sans savoir où sont les chaînes couperait `"https://containers.dev"` en deux et livrerait à `json.Unmarshal` une chaîne non terminée. L'erreur rendue nomme alors un numéro de ligne et rien d'autre — on cherche la faute dans le mauvais fichier. + +- [ ] **Step 1: Écrire le test qui échoue** + +Créer `deploy/devcontainer_test.go` avec **exactement** ce contenu : + +```go +package deploy + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestTheJSONCReaderLeavesTheInsideOfStringsAlone is the whole difficulty of reading a +// devcontainer.json, and the reason this reader is not three lines of strings.ReplaceAll. +// +// A devcontainer.json is JSONC: containers.dev allows comments, and this repository +// comments its configuration files at length. encoding/json refuses a comment, so they +// have to go — but a reader that hunted for "//" without knowing where the strings are +// would cut "https://containers.dev" in half and hand json.Unmarshal an unterminated +// string. The error it reports then names a line number and nothing else, and the search +// starts in the wrong file. +func TestTheJSONCReaderLeavesTheInsideOfStringsAlone(t *testing.T) { + cases := []struct { + name string + source string + want string + }{ + { + name: "un commentaire de ligne disparaît, le retour à la ligne reste", + source: "{\n // la version vient de go.mod\n \"a\": 1\n}\n", + want: "{\n \n \"a\": 1\n}\n", + }, + { + name: "un commentaire de bloc disparaît, sur plusieurs lignes", + source: "{/* deux\nlignes */\"a\": 1}", + want: `{"a": 1}`, + }, + { + name: "les deux barres d'une URL ne sont pas un commentaire", + source: `{"doc": "https://containers.dev"}`, + want: `{"doc": "https://containers.dev"}`, + }, + { + name: "un guillemet échappé ne termine pas la chaîne", + source: `{"a": "un guillemet \" puis // rien du tout"}`, + want: `{"a": "un guillemet \" puis // rien du tout"}`, + }, + { + name: "l'identifiant d'un feature traverse sans une égratignure", + source: `{"ghcr.io/devcontainers/features/go:1": {"version": "1.26.5"}}`, + want: `{"ghcr.io/devcontainers/features/go:1": {"version": "1.26.5"}}`, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := withoutJSONComments(testCase.source); got != testCase.want { + t.Errorf("withoutJSONComments :\n reçu %q\n attendu %q", got, testCase.want) + } + }) + } +} + +// TestTheJSONCReaderProducesSomethingEncodingJSONAccepts closes the loop: stripping the +// comments is only useful if what comes out decodes. +func TestTheJSONCReaderProducesSomethingEncodingJSONAccepts(t *testing.T) { + source := "{\n // le commentaire de tête\n \"name\": \"OpenScale\", /* et un bloc */\n \"remoteUser\": \"vscode\"\n}\n" + var decoded struct { + Name string `json:"name"` + RemoteUser string `json:"remoteUser"` + } + if err := jsonDecode(withoutJSONComments(source), &decoded); err != nil { + t.Fatalf("décodage : %v", err) + } + if decoded.Name != "OpenScale" || decoded.RemoteUser != "vscode" { + t.Errorf("décodé : %+v", decoded) + } +} + +// jsonDecode is the one-line wrapper the tests of this file decode with. +func jsonDecode(source string, into any) error { + return json.Unmarshal([]byte(source), into) +} + +// withoutJSONComments removes the // and /* */ comments a JSONC file is allowed to carry. +// +// See TestTheJSONCReaderLeavesTheInsideOfStringsAlone for why it tracks strings rather +// than searching for two characters. +func withoutJSONComments(source string) string { + return strings.Clone(source) +} +``` + +Le `strings.Clone` de l'ébauche n'est pas une coquetterie : sans lui, l'`import` de +`strings` serait inutilisé et l'étape suivante échouerait à la **compilation** au lieu +d'échouer sur l'assertion — un rouge qui ne prouve rien. + +- [ ] **Step 2: Lancer le test pour le voir échouer** + +Run: `go test ./deploy/ -run TestTheJSONCReader -v` +Expected: **FAIL** — les quatre premiers sous-cas passent (l'implémentation rend son entrée telle quelle), `un commentaire de ligne disparaît` et `un commentaire de bloc disparaît` échouent, et `TestTheJSONCReaderProducesSomethingEncodingJSONAccepts` échoue sur `invalid character '/'`. + +- [ ] **Step 3: Écrire l'implémentation** + +Remplacer le corps de `withoutJSONComments` par : + +```go +func withoutJSONComments(source string) string { + var out strings.Builder + inString, inLineComment, inBlockComment, escaped := false, false, false, false + for index := 0; index < len(source); index++ { + character := source[index] + switch { + case inLineComment: + if character == '\n' { + inLineComment = false + out.WriteByte(character) + } + case inBlockComment: + if character == '*' && index+1 < len(source) && source[index+1] == '/' { + inBlockComment = false + index++ + } + case inString: + out.WriteByte(character) + switch { + case escaped: + escaped = false + case character == '\\': + escaped = true + case character == '"': + inString = false + } + case character == '"': + inString = true + out.WriteByte(character) + case character == '/' && index+1 < len(source) && source[index+1] == '/': + inLineComment = true + index++ + case character == '/' && index+1 < len(source) && source[index+1] == '*': + inBlockComment = true + index++ + default: + out.WriteByte(character) + } + } + return out.String() +} +``` + +- [ ] **Step 4: Lancer le test pour le voir passer** + +Run: `go test ./deploy/ -run TestTheJSONCReader -v` +Expected: **PASS**, sept sous-cas verts. + +- [ ] **Step 5: Vérifier que rien d'autre n'a bougé** + +Run: `go vet ./deploy/ && gofmt -l deploy/` +Expected: aucune sortie. + +- [ ] **Step 6: Commit** + +```bash +git add deploy/devcontainer_test.go +git commit -m "test(devcontainer): un lecteur de JSONC qui sait ou sont les chaines" +``` + +Corps du message (accentué) : + +``` +devcontainer.json est du JSONC, et ce dépôt commente ses fichiers de +configuration. encoding/json refuse un commentaire : il faut donc les retirer +avant de décoder. + +Le lecteur suit l'état des chaînes plutôt que de chercher deux caractères. Sans +cela, « https://containers.dev » serait coupé en deux et json.Unmarshal +répondrait « unterminated string » en nommant une ligne — on chercherait la +faute dans le mauvais fichier. +``` + +--- + +### Task 2: Le banc anti-dérive + +**Files:** +- Modify: `deploy/devcontainer_test.go` (ajouts en fin de fichier) + +**Interfaces:** +- Consumes: `withoutJSONComments(source string) string` (tâche 1) ; `readFile(t *testing.T, path string) string` et `codeOnly(script string) string`, tous deux déjà fournis par `deploy/harness_test.go`. +- Produces: rien pour les tâches suivantes — c'est le banc que la tâche 3 doit faire passer au vert. + +**Contexte pour l'implémenteur.** Quatre versions vivent déjà dans ce dépôt, chacune à un seul endroit : + +| Ce qui est épinglé | Où | Forme exacte | +|---|---|---| +| Go | `go.mod` | `toolchain go1.26.5` | +| Go | `.github/workflows/ci.yml` l. 69 | `GO_VERSION: "1.26.5"` | +| Node | `.github/workflows/ci.yml` l. 292 | `node-version: "22"` | +| Python | `.github/workflows/docs.yml` l. 46 | `python-version: "3.13"` | +| golangci-lint | `Makefile` l. 44 | `GOLANGCI_VERSION ?= v2.12.2` | + +Le `Makefile` explique lui-même l'enjeu : *« un développeur sur une version plus récente verrait rouge là où la CI voit vert — ou l'inverse, ce qui est pire, parce que personne ne cherche la cause d'un vert »*. Un `devcontainer.json` qui recopie ces numéros en fait un quatrième endroit. + +`codeOnly` retire les commentaires `#`, donc les commentaires YAML : c'est indispensable ici, et `deploy/release_workflow_test.go` dit pourquoi — *« removing fetch-depth: 0 turned nothing red, because the comment mentioning it was still there »*. + +- [ ] **Step 1: Écrire les tests qui échouent** + +Ajouter à la fin de `deploy/devcontainer_test.go` : + +```go +// The development container is guarded here for the same reason the release workflow is: +// nothing else in this repository reads .devcontainer/, and its failures are silent. A +// container that installs Go 1.27 while go.mod pins 1.26.5 does not fail — it produces +// green runs on a toolchain nobody else has, and §16.4 says exactly what that costs: the +// render golden files of §7.4 shift under a contributor who changed nothing. +// +// Every file here is read as TEXT. Parsing the YAML would add a dependency to a repository +// whose whole shape comes from refusing them, for assertions that are about four numbers. + +// devcontainerFile is the development container declaration. +const devcontainerFile = "../.devcontainer/devcontainer.json" + +// postCreateScript is what runs once the image is built. +const postCreateScript = "../.devcontainer/post-create.sh" + +// devcontainerDeclaration is the part of devcontainer.json this package has an opinion +// about. Everything else — extensions, port forwarding, editor settings — is free. +type devcontainerDeclaration struct { + Features map[string]struct { + Version string `json:"version"` + } `json:"features"` + RemoteUser string `json:"remoteUser"` + PostCreateCommand string `json:"postCreateCommand"` +} + +// readDevcontainer decodes the declaration, comments removed. +func readDevcontainer(t *testing.T) devcontainerDeclaration { + t.Helper() + var declaration devcontainerDeclaration + source := withoutJSONComments(readFile(t, devcontainerFile)) + if err := jsonDecode(source, &declaration); err != nil { + t.Fatalf("%s ne décode pas : %v", devcontainerFile, err) + } + return declaration +} + +// featureVersion returns the version a feature is pinned to, and fails when the feature is +// absent — an absent feature is the same silence as a wrong version. +func featureVersion(t *testing.T, declaration devcontainerDeclaration, feature string) string { + t.Helper() + pinned, present := declaration.Features[feature] + if !present { + t.Fatalf("%s ne déclare pas le feature %s : le conteneur ne fournirait pas cet outil", + devcontainerFile, feature) + } + if pinned.Version == "" { + t.Fatalf("le feature %s n'épingle aucune version : il installerait la dernière, "+ + "et le poste du contributeur cesserait de correspondre à la CI", feature) + } + return pinned.Version +} + +// declaredValue returns the value of a « key: "value" » line of a YAML workflow, COMMENTS +// REMOVED — see readWorkflow next door for the trap that makes codeOnly mandatory. +func declaredValue(t *testing.T, file, key string) string { + t.Helper() + pattern := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `:\s*"([^"]+)"`) + match := pattern.FindStringSubmatch(codeOnly(readFile(t, file))) + if match == nil { + t.Fatalf("%s ne déclare plus « %s: \"…\" » : ce banc ne compare plus rien", file, key) + } + return match[1] +} + +// pinnedToolchain is the Go version go.mod pins, without its « go » prefix. +func pinnedToolchain(t *testing.T) string { + t.Helper() + match := regexp.MustCompile(`(?m)^toolchain go(\S+)$`).FindStringSubmatch(readFile(t, "../go.mod")) + if match == nil { + t.Fatal("go.mod ne porte plus de ligne « toolchain go… » : §16.4 l'exige pour que " + + "les fichiers de référence du rendu ne bougent pas d'une version d'outillage à l'autre") + } + return match[1] +} + +// TestTheContainerInstallsTheGoVersionTheRepositoryPins compares THREE declarations, and +// the third is not redundant: ci.yml and go.mod are already meant to agree, so a container +// that matched only one of them would hide the day they stop. +func TestTheContainerInstallsTheGoVersionTheRepositoryPins(t *testing.T) { + inContainer := featureVersion(t, readDevcontainer(t), "ghcr.io/devcontainers/features/go:1") + inGoMod := pinnedToolchain(t) + inCI := declaredValue(t, "../.github/workflows/ci.yml", "GO_VERSION") + + if inContainer != inGoMod { + t.Errorf("le conteneur installe Go %s, go.mod épingle %s : le contributeur "+ + "compilerait sur une chaîne que personne d'autre n'a", inContainer, inGoMod) + } + if inContainer != inCI { + t.Errorf("le conteneur installe Go %s, ci.yml en utilise %s : vert chez le "+ + "contributeur ne voudrait plus dire vert en intégration continue", inContainer, inCI) + } +} + +// TestTheContainerInstallsTheNodeVersionTheFrontIsBuiltWith guards §14.1: the client screen +// is committed in internal/web/dist, and the « dist à jour » step of ci.yml compares BYTES. +// A different Node builds a different bundle, and that step turns red on a dist that is +// perfectly up to date. +func TestTheContainerInstallsTheNodeVersionTheFrontIsBuiltWith(t *testing.T) { + inContainer := featureVersion(t, readDevcontainer(t), "ghcr.io/devcontainers/features/node:1") + inCI := declaredValue(t, "../.github/workflows/ci.yml", "node-version") + if inContainer != inCI { + t.Errorf("le conteneur installe Node %s, ci.yml en utilise %s : les deux ne "+ + "produiraient pas le même internal/web/dist", inContainer, inCI) + } +} + +// TestTheContainerInstallsThePythonVersionTheHandbookIsBuiltWith: mkdocs --strict is what +// refuses a broken internal link before it is published. +func TestTheContainerInstallsThePythonVersionTheHandbookIsBuiltWith(t *testing.T) { + inContainer := featureVersion(t, readDevcontainer(t), "ghcr.io/devcontainers/features/python:1") + inDocs := declaredValue(t, "../.github/workflows/docs.yml", "python-version") + if inContainer != inDocs { + t.Errorf("le conteneur installe Python %s, docs.yml en utilise %s", inContainer, inDocs) + } +} + +// TestTheContainerDoesNotRunAsRoot keeps a bench alive that nobody would notice dying. +// +// TestADirectoryTheServiceCanReadButNotWriteIsRefused (internal/platform/pathchecker_test.go) +// skips under root — « root écrit dans un répertoire 0555 » — AND skips on Windows, where a +// directory is closed by an ACL rather than by os.Chmod. A root container would therefore +// leave that branch covered by nothing at all, and the suite would still be green. +func TestTheContainerDoesNotRunAsRoot(t *testing.T) { + user := readDevcontainer(t).RemoteUser + if user == "" || user == "root" { + t.Errorf("remoteUser vaut %q : sous root, "+ + "TestADirectoryTheServiceCanReadButNotWriteIsRefused se saute en silence, et "+ + "cette branche n'est plus couverte nulle part", user) + } +} + +// TestTheContainerNeverWritesTheGolangciVersionItself is the rule of ADR-039 applied to a +// fourth file: the version is READ from the Makefile, never copied. +func TestTheContainerNeverWritesTheGolangciVersionItself(t *testing.T) { + script := readFile(t, postCreateScript) + if !strings.Contains(script, "make -s golangci-version") { + t.Error("post-create.sh n'appelle pas « make -s golangci-version » : la version " + + "de golangci-lint serait écrite à un quatrième endroit, et le contributeur " + + "verrait rouge là où la CI voit vert") + } + literal := regexp.MustCompile(`golangci-lint@v?[0-9]`) + if literal.MatchString(script) { + t.Error("post-create.sh écrit un numéro de version de golangci-lint en clair : " + + "le Makefile en est la source unique") + } + if strings.Contains(readFile(t, devcontainerFile), "golangciLintVersion") { + t.Error("devcontainer.json utilise l'option golangciLintVersion du feature Go : " + + "c'est un cinquième endroit où ce numéro vivrait") + } +} + +// TestThePostCreateCommandRunsTheScriptThisBenchReads: the bench above is worth nothing if +// devcontainer.json stops calling the file it inspects. +func TestThePostCreateCommandRunsTheScriptThisBenchReads(t *testing.T) { + command := readDevcontainer(t).PostCreateCommand + if !strings.Contains(command, "post-create.sh") { + t.Errorf("postCreateCommand vaut %q et n'appelle pas post-create.sh : "+ + "TestTheContainerNeverWritesTheGolangciVersionItself lirait un fichier mort", command) + } +} + +// TestTheImageCarriesWhatTheBenchesNeed names three apt packages and the bench each one +// keeps alive. Slimming the image is a reasonable-looking change; losing -race to it is not. +func TestTheImageCarriesWhatTheBenchesNeed(t *testing.T) { + dockerfile := readFile(t, "../.devcontainer/Dockerfile") + needed := []struct{ packageName, why string }{ + {"build-essential", "gcc, sans quoi la passe -race de `make test` ne peut pas " + + "tourner : c'est la seule vérification automatique des trois invariants de " + + "concurrence du Hub (important-3)"}, + {"zip", "la cible `release` du Makefile empaquette avec"}, + {"systemd", "systemd-analyze, sans quoi TestTheUnitIsValidAccordingToSystemdItself " + + "se saute et plus rien ne juge les unités livrées"}, + } + for _, need := range needed { + if !strings.Contains(codeOnly(dockerfile), need.packageName) { + t.Errorf("le Dockerfile n'installe pas %s — %s", need.packageName, need.why) + } + } +} +``` + +Ajouter `"regexp"` à l'`import` du fichier. + +- [ ] **Step 2: Lancer les tests pour les voir échouer** + +Run: `go test ./deploy/ -run 'TestTheContainer|TestThePostCreate|TestTheImage' -v` +Expected: **FAIL** — chacun s'arrête sur `lecture de ../.devcontainer/… : no such file or directory`. C'est le rouge attendu : le banc existe avant ce qu'il garde. + +- [ ] **Step 3: Vérifier que le reste du paquet reste vert** + +Run: `go test ./deploy/ -run 'TestTheJSONCReader' -v && go vet ./deploy/ && gofmt -l deploy/` +Expected: PASS puis aucune sortie. Le banc neuf est rouge, le paquet n'est pas cassé. + +- [ ] **Step 4: Commit** + +```bash +git add deploy/devcontainer_test.go +git commit -m "test(devcontainer): le banc qui refuse un quatrieme endroit ou vivent les versions" +``` + +Corps du message : + +``` +Go 1.26.5 vit dans go.mod et ci.yml, Node 22 dans ci.yml, Python 3.13 dans +docs.yml, golangci-lint v2.12.2 dans le Makefile — chacun à un seul endroit, et +la CI lit le dernier plutôt que de le recopier. Un devcontainer.json qui +réécrirait ces numéros en ferait un quatrième endroit ; SUIVI.md rappelle que le +seul compteur d'ADR a menti trois fois pour cette raison. + +Le banc compare dans les deux sens, et il exige aussi remoteUser non root : +TestADirectoryTheServiceCanReadButNotWriteIsRefused saute sous root ET sous +Windows, si bien qu'un conteneur root laisserait cette branche couverte par +rien tout en restant vert. + +Rouge à ce commit : .devcontainer/ n'existe pas encore. C'est voulu — un banc +écrit après le fichier qu'il garde ne dit jamais s'il rougirait. +``` + +--- + +### Task 3: L'image, et ce qui s'installe dedans + +**Files:** +- Create: `.devcontainer/Dockerfile` +- Create: `.devcontainer/devcontainer.json` +- Create: `.devcontainer/post-create.sh` + +**Interfaces:** +- Consumes: le banc de la tâche 2, qui décrit exactement ce que ces fichiers doivent porter. +- Produces: un conteneur utilisable — consommé par la tâche 4. + +**Contexte pour l'implémenteur.** Trois pièges connus, tous vérifiés dans le dépôt : + +1. **Les volumes de cache sont créés par Docker sous `root`.** Montés dans le `$HOME` d'un utilisateur non root, ils sont inécrivables tant que personne ne les donne. D'où le premier `chown` de `post-create.sh` — sans lui, `go build` échoue sur « permission denied » dans un cache, ce qui ne ressemble pas à un problème de montage. +2. **`pip install` sur un Python système Ubuntu est refusé** (PEP 668, « externally-managed-environment »). Le feature `python` installe son propre interpréteur, qui n'est pas marqué ainsi : c'est la raison pour laquelle il est dans la liste et non un `apt install python3`. +3. **`post-create.sh` doit être commité en LF.** `.gitattributes` l'impose déjà (`*.sh text eol=lf`) et son en-tête raconte la panne : `#!/bin/sh\r` fait répondre à `dash` « Syntax error: word unexpected », et rien dans ce message ne pointe vers les fins de ligne. + +- [ ] **Step 1: Écrire le Dockerfile** + +Créer `.devcontainer/Dockerfile` : + +```dockerfile +# L'image du poste de développement — voir devcontainer.json à côté. +# +# Trois paquets, et chacun tient un banc précis. Alléger cette liste est un changement qui +# a l'air raisonnable ; deploy/devcontainer_test.go le refuse et dit ce qu'il coûterait. +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 + +# build-essential : gcc. La passe `-race` de `make test` EXIGE ThreadSanitizer, donc cgo. +# Sans lui elle se saute, et on perd la seule vérification automatique +# des trois invariants de concurrence du Hub (important-3). +# zip : la cible `release` du Makefile empaquette avec. +# systemd : pour `systemd-analyze` seul — il ne tourne pas ici. Sans lui, +# TestTheUnitIsValidAccordingToSystemdItself se saute, et plus rien ne +# demande à systemd lui-même s'il accepte les unités livrées. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + zip \ + systemd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* +``` + +- [ ] **Step 2: Écrire devcontainer.json** + +Créer `.devcontainer/devcontainer.json` : + +```jsonc +// OpenScale — le poste de développement en conteneur. +// +// CE FICHIER EST UNE SECONDE PORTE. Le chemin sans conteneur — `make`, `make.ps1` — reste +// la référence, et c'est lui que la CI exécute. Rien ici ne doit devenir un passage obligé. +// +// AUCUN NUMÉRO DE VERSION N'EST DÉCIDÉ ICI. Go vient de `go.mod` et de `ci.yml`, Node de +// `ci.yml`, Python de `docs.yml`, golangci-lint du `Makefile` — et `deploy/devcontainer_test.go` +// compare les quatre dans les deux sens. Une version modifiée ici sans l'être là-bas rougit. +// +// Ce que ce conteneur NE JUGE PAS : les scripts d'installation sous Windows PowerShell 5.1. +// Un conteneur Linux n'a que pwsh 7, qui n'est pas le shell d'un poste de balance. Le job +// « scripts » de ci.yml est le seul endroit où ils sont exécutés pour de bon. +{ + "name": "OpenScale", + "build": { "dockerfile": "Dockerfile" }, + + "features": { + "ghcr.io/devcontainers/features/go:1": { "version": "1.26.5" }, + "ghcr.io/devcontainers/features/node:1": { "version": "22" }, + "ghcr.io/devcontainers/features/python:1": { "version": "3.13" }, + // pwsh 7 ANALYSE les .ps1 — `powershellPaths` les lit sous tout shell présent, et une + // faute de syntaxe grossière rougit donc ici plutôt qu'en CI. Il ne les EXÉCUTE pas : + // `requireWindowsToRunCommonPs1` s'y oppose, et il a raison. + "ghcr.io/devcontainers/features/powershell:1": {} + }, + + // Non root, et ce n'est pas de l'hygiène : TestADirectoryTheServiceCanReadButNotWriteIsRefused + // se saute sous root — et se saute aussi sous Windows. Un conteneur root laisserait cette + // branche couverte par rien, en restant vert. + "remoteUser": "vscode", + + // Les caches sont dans des volumes et non dans le dossier monté : sous Windows, le bind + // coûte ×29 sur les métadonnées (143 ms contre 5 ms pour parcourir 577 fichiers). Sortis + // du bind, ils ne paient plus cette taxe, et la lenteur ne porte que sur les sources. + "containerEnv": { + "GOPATH": "/home/vscode/go", + "GOMODCACHE": "/home/vscode/go/pkg/mod", + "GOCACHE": "/home/vscode/.cache/go-build" + }, + "remoteEnv": { "PATH": "${containerEnv:PATH}:/home/vscode/go/bin" }, + "mounts": [ + "source=openscale-gomodcache,target=/home/vscode/go/pkg/mod,type=volume", + "source=openscale-gocache,target=/home/vscode/.cache/go-build,type=volume", + "source=openscale-node-modules,target=${containerWorkspaceFolder}/web/node_modules,type=volume" + ], + + "postCreateCommand": "bash .devcontainer/post-create.sh", + + "customizations": { + "vscode": { + "extensions": [ + "golang.go", + "svelte.svelte-vscode", + "ms-vscode.powershell" + ] + } + } +} +``` + +- [ ] **Step 3: Écrire post-create.sh** + +Créer `.devcontainer/post-create.sh` : + +```sh +#!/bin/sh +# Ce qui s'installe une fois l'image construite. +# +# Ce fichier est commité en LF, et .gitattributes l'impose (`*.sh text eol=lf`). Un +# `#!/bin/sh\r` fait répondre à dash « Syntax error: word unexpected », et rien dans ce +# message ne pointe vers les fins de ligne — la panne est déjà arrivée à install.sh. +set -eu + +# Docker crée un volume vide sous root. Monté dans le $HOME d'un utilisateur non root, il +# est inécrivable, et `go build` échoue alors sur « permission denied » au fond d'un cache +# — ce qui ne ressemble pas à un problème de montage. +sudo chown -R vscode:vscode "$HOME/go" "$HOME/.cache/go-build" web/node_modules + +# golangci-lint s'installe HORS MODULE, et ADR-039 l'impose : `make deps` compare go.mod +# aux deux tables de §17.1 dans les deux sens, et une dépendance de développement inscrite +# là ouvrirait un écart permanent. +# +# La VERSION N'EST PAS ÉCRITE ICI : elle est lue dans le Makefile, qui en est la source +# unique — exactement ce que fait l'étape `make lint` de ci.yml. +golangci_version=$(make -s golangci-version) +install_dir=$(mktemp -d) +( + cd "$install_dir" + go mod init lintinstall >/dev/null 2>&1 + go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$golangci_version" +) +rm -rf "$install_dir" + +# mkdocs, pour rejouer `mkdocs build --strict` avant de pousser. Le feature python installe +# son propre interpréteur : pip y fonctionne, là où le python système d'Ubuntu refuserait +# (PEP 668, « externally-managed-environment »). +pip install --no-cache-dir -r handbook/requirements.txt + +# `npm ci` et non `npm install` : les versions sont gelées dans package-lock.json (§14.1), +# et un « ^ » ferait bouger un fichier de référence. +npm ci --prefix web + +echo '' +echo 'Poste prêt. Ce que vous pouvez rejouer ici :' +echo ' make test les deux passes, -race comprise' +echo ' make front-check types, tests et budget de l ecran client' +echo ' mkdocs build --strict' +echo '' +echo 'Ce que ce conteneur NE juge PAS : les scripts d installation sous Windows' +echo 'PowerShell 5.1. Le job « scripts » de la CI le fait a chaque pull request.' +``` + +- [ ] **Step 4: Vérifier les fins de ligne avant de committer** + +Run: `git add .devcontainer/ && git diff --cached --stat && file .devcontainer/post-create.sh` +Expected: `POSIX shell script`, **sans** « with CRLF line terminators ». Si CRLF apparaît, `.gitattributes` n'a pas été appliqué : lancer `git add --renormalize .devcontainer/post-create.sh`. + +- [ ] **Step 5: Lancer le banc pour le voir passer** + +Run: `go test ./deploy/ -run 'TestTheContainer|TestThePostCreate|TestTheImage|TestTheJSONC' -v` +Expected: **PASS**, tous. C'est la tâche 2 qui passe du rouge au vert, sans avoir été modifiée. + +- [ ] **Step 6: Commit** + +```bash +git add .devcontainer/ +git commit -m "feat(devcontainer): une image ou six des sept verifications de la CI se rejouent" +``` + +Corps du message : + +``` +Trois fichiers : l'image et ses trois paquets apt, la déclaration, et ce qui +s'installe après la construction. + +Chaque paquet tient un banc et le dit : build-essential pour gcc, sans quoi la +passe -race se saute ; zip pour la cible release ; systemd pour systemd-analyze +seul. Le banc du commit précédent refuse qu'on allège cette liste en silence. + +Les caches Go et npm sont dans des volumes, hors du dossier monté : sous +Windows, le bind coûte ×29 sur les métadonnées — 143 ms contre 5 ms pour +parcourir 577 fichiers. Sortis du bind, ils ne paient plus cette taxe. + +post-create.sh lit la version de golangci-lint par « make -s golangci-version » +et l'installe hors module, comme ADR-039 l'exige et comme le fait déjà ci.yml. +``` + +--- + +### Task 4: Vérifier dans le conteneur, et casser le banc exprès + +**Files:** aucun fichier modifié — sauf si une vérification échoue, auquel cas la correction se fait dans les fichiers de la tâche 3. + +**Interfaces:** +- Consumes: le conteneur de la tâche 3. +- Produces: la preuve que les six critères de §10 de la spec sont tenus. + +**Contexte pour l'implémenteur.** Toutes les commandes ci-dessous se lancent **dans le conteneur** : VS Code → « Reopen in Container », puis un terminal. Sans VS Code : `devcontainer up --workspace-folder .` puis `devcontainer exec --workspace-folder . ` (`npm i -g @devcontainers/cli`). + +Ne pas cocher une étape sur une sortie supposée. Ce plan demande la **sortie réelle**. + +- [ ] **Step 1: L'utilisateur n'est pas root, et la branche qu'il débloque s'exécute** + +Run: `id -u && go test ./internal/platform/ -run TestADirectoryTheServiceCanReadButNotWriteIsRefused -v` +Expected: un identifiant **différent de 0**, puis `--- PASS`. Si la sortie porte `--- SKIP` avec « inatteignable sous root », `remoteUser` n'a pas été pris : reconstruire le conteneur. + +- [ ] **Step 2: La chaîne Go est celle du dépôt** + +Run: `go version && go env GOMODCACHE GOCACHE` +Expected: `go1.26.5`, et les deux caches sous `/home/vscode/…` — donc dans les volumes, hors du bind. + +- [ ] **Step 3: `make test` en entier, passe `-race` comprise** + +Run: `make test` +Expected: les deux passes vertes, puis `boundary` et `deps`. **Vérifier dans la sortie que la passe `-race` s'est réellement exécutée** (elle est la première, sous `CGO_ENABLED=1`) : c'est le signe que gcc est là. Sa durée la trahit — quelques minutes, contre quelques dizaines de secondes sans elle. + +- [ ] **Step 4: `make deps` n'a rien à redire** + +Run: `git diff --exit-code go.mod go.sum` +Expected: aucune sortie. L'installation de golangci-lint n'a laissé **aucune trace** dans le module — c'est ce qu'ADR-039 exige. + +- [ ] **Step 5: Le front et la documentation** + +Run: `make front-check && mkdocs build --strict` +Expected: types, tests et budget verts, puis un site construit sans lien mort. + +- [ ] **Step 6: Les bancs Windows se sautent avec leur raison, les bancs d'analyse s'exécutent** + +Run: `go test ./deploy/ -v -run 'TestEveryPowerShellScript|TestTheUnitIsValidAccordingToSystemdItself' 2>&1 | grep -E 'PASS|SKIP|FAIL'` +Expected: le banc `systemd` **PASS** (et non SKIP : c'est ce que le paquet `systemd` apporte), et les bancs d'analyse PowerShell **PASS** sous pwsh. Aucun silence : un SKIP qui apparaît doit porter sa raison dans la sortie `-v`. + +- [ ] **Step 7: Casser le banc pour le voir rougir** + +Une garantie qu'on n'a pas vue échouer n'est pas une garantie. + +```bash +sed -i 's/"version": "1.26.5"/"version": "1.26.6"/' .devcontainer/devcontainer.json +go test ./deploy/ -run TestTheContainerInstallsTheGoVersionTheRepositoryPins -v +``` + +Expected: **FAIL**, avec le message « le conteneur installe Go 1.26.6, go.mod épingle 1.26.5 ». Puis rétablir : + +```bash +git checkout .devcontainer/devcontainer.json +go test ./deploy/ -run TestTheContainerInstalls -v +``` + +Expected: PASS. Recommencer le même geste sur `remoteUser` (`"vscode"` → `"root"`) et vérifier que `TestTheContainerDoesNotRunAsRoot` rougit, puis rétablir. + +- [ ] **Step 8: Le même fichier depuis un hôte Linux** + +La demande porte sur **Windows et Linux**, et §5.7 de la spec affirme que le même +`devcontainer.json` suffit des deux côtés. Cette affirmation se vérifie, elle ne se suppose +pas — un hôte Linux monte le dossier en bind natif, et c'est là qu'un décalage d'UID entre +l'utilisateur de l'hôte et le `vscode` du conteneur se voit : les fichiers du dépôt +apparaissent alors comme appartenant à quelqu'un d'autre, et `git status` déclare tout +modifié. + +Depuis un terminal WSL Ubuntu — qui **est** un hôte Linux au sens de Docker : + +```bash +cd ~ && git clone openscale-linux && cd openscale-linux +devcontainer up --workspace-folder . +devcontainer exec --workspace-folder . sh -c 'id -u && touch preuve && ls -l preuve && rm preuve && git status --porcelain' +``` + +Expected: un identifiant non nul, un fichier créé **sans `sudo`** et appartenant à +`vscode`, et un `git status` **vide**. Si les fichiers apparaissent sous un autre +propriétaire, ajouter `"updateRemoteUserUID": true` à `devcontainer.json` et le noter dans +le commentaire de tête (c'est le défaut, mais un défaut qu'on a vérifié vaut mieux qu'un +défaut qu'on cite). + +Supprimer le clone d'essai ensuite : `cd ~ && rm -rf openscale-linux`. + +- [ ] **Step 9: Consigner ce qui a été mesuré** + +Aucune commande. Reporter dans le message de commit de la tâche 5 **les faits observés** : durée de la première construction du conteneur, durée de `make test` dedans. Ces deux nombres iront dans la documentation — `handbook/getting-started.md` donne déjà « 16 s de téléchargement, 24 s de compilation » pour le chemin sans conteneur, et une page qui promettrait « cinq minutes » sans avoir mesuré ne vaut rien. + +--- + +### Task 5: La documentation + +**Files:** +- Modify: `handbook/getting-started.md` (section « Prérequis » et « Installer ») +- Modify: `README.md:167` + +**Interfaces:** +- Consumes: les durées mesurées à la tâche 4, étape 9. +- Produces: rien. + +**Contexte pour l'implémenteur.** Deux phrases du dépôt deviennent fausses telles quelles, et c'est le seul endroit où la documentation doit bouger : + +- `README.md:167` — « Go 1.26.5, Node 22 seulement pour le front. **Pas de chaîne C, pas de Docker.** » +- `handbook/getting-started.md` — « **Pas de Docker**, pas de chaîne C, pas de service à installer. » + +Aucune des deux ne ment aujourd'hui : elles disent qu'aucun de ces outils **n'est requis**. Elles doivent continuer à le dire tout en nommant la seconde porte. On ne les supprime pas — le chemin sans conteneur reste la référence. + +ODR-0002 s'applique : `handbook/` ne reprend que ce qui met en route et renvoie au reste. La frontière détaillée est dans la spec et dans les commentaires de `.devcontainer/` ; la page ne porte qu'une phrase là-dessus. + +- [ ] **Step 1: Ouvrir le parcours conteneur dans getting-started.md** + +Dans `handbook/getting-started.md`, **avant** la section `## Prérequis`, insérer : + +```markdown +## Deux chemins + +| Chemin | Ce qu'il faut sur votre poste | Pour qui | +|---|---|---| +| **Conteneur** | Docker et VS Code, rien d'autre | Découverte, contribution ponctuelle, poste qu'on ne veut pas encombrer | +| **Local** | Go, et le reste selon ce que vous touchez | Développement quotidien, mise en route d'une balance ou d'une imprimante réelle | + +### Le chemin conteneur + +Ouvrez le dépôt dans VS Code, puis « Reopen in Container ». L'extension *Dev Containers* +construit une image qui porte Go, Node, Python, gcc et golangci-lint aux versions **exactes** +de l'intégration continue — vous n'installez rien d'autre que Docker. + +Vous pouvez alors rejouer, avant de pousser, tout ce que la CI vérifie **sauf un point** : +les scripts d'installation sous Windows PowerShell 5.1, qu'aucun conteneur Linux ne peut +exécuter. C'est le job `scripts` de la CI qui les juge, à chaque pull request. + +!!! note "Sous Windows, si les compilations traînent" + + Le dépôt reste sur votre disque Windows et le conteneur le lit à travers un montage : + parcourir 577 fichiers y prend 143 ms contre 5 ms depuis le système de fichiers de WSL, + soit **×29 sur les métadonnées**. Les caches Go et npm sont déjà hors de ce montage, si + bien que seule la lecture des sources le paie. Si cela vous gêne, clonez le dépôt côté + WSL (`~/dev/OpenScale` depuis un terminal Ubuntu) et rouvrez-le de là. + +Aucune balance ni imprimante n'est nécessaire : aucun test du projet n'ouvre de port série, +et une machine sans port série est le cas de développement ordinaire. + +### Le chemin local +``` + +Le tableau des prérequis existant et la section « Installer » suivent, **inchangés**. + +- [ ] **Step 2: Corriger la phrase de getting-started.md qui devient fausse** + +Remplacer : + +```markdown +Pas de Docker, pas de chaîne C, pas de service à installer. +``` + +par : + +```markdown +Pas de chaîne C, pas de service à installer. Docker n'est nécessaire que si vous +choisissez le chemin conteneur ci-dessus. +``` + +- [ ] **Step 3: Corriger README.md:167** + +Remplacer : + +```markdown +Go 1.26.5, Node 22 seulement pour le front. Pas de chaîne C, pas de Docker. +``` + +par : + +```markdown +Go 1.26.5, Node 22 seulement pour le front. Pas de chaîne C. Un devcontainer est fourni +pour qui préfère ne rien installer : `.devcontainer/`, et +[le parcours de démarrage](https://lostmind84.github.io/OpenScale/getting-started/) le +détaille. +``` + +- [ ] **Step 4: Compléter la note avec les durées réellement mesurées** + +Reprendre les deux nombres relevés à la tâche 4, étape 9, et les ajouter sous le paragraphe +« Le chemin conteneur », sur le modèle de la note déjà présente dans cette page : + +```markdown +!!! note "Ce que ça coûte, mesuré" + + Première construction de l'image : ****. Elle n'est payée qu'une fois ; + les ouvertures suivantes sont immédiates. `make test` dedans : ****, + passe `-race` comprise — celle qu'un poste Windows saute faute de gcc. +``` + +Remplacer les deux `` par les valeurs observées. **Ne pas les inventer** : une +page qui promet cinq minutes sans avoir mesuré est une page qu'on cesse de croire. + +- [ ] **Step 5: Vérifier que le site se construit toujours** + +Run: `mkdocs build --strict` +Expected: aucune erreur. `--strict` échoue sur un lien interne cassé — l'ancre +`getting-started/` du README est une URL absolue, donc hors de son contrôle : vérifier à +l'œil qu'elle correspond bien au chemin publié. + +- [ ] **Step 6: La suite complète, une dernière fois** + +Run: `make test` +Expected: tout vert, `deploy` compris. + +- [ ] **Step 7: Commit** + +```bash +git add handbook/getting-started.md README.md +git commit -m "docs(devcontainer): deux chemins, et ce que le conteneur ne juge pas" +``` + +Corps du message : + +``` +getting-started.md ouvre sur deux chemins au lieu d'un seul. Le tableau des +prérequis existant ne bouge pas : il devient le chemin local, qui reste la +référence. + +Deux phrases devenaient fausses telles quelles — « pas de Docker » ici et dans +le README. Elles disaient qu'aucun outil n'est requis, ce qui reste vrai : elles +le disent maintenant en nommant la seconde porte. + +Ce que le conteneur ne juge pas tient en une phrase et pas en un paragraphe : +les scripts d'installation sous Windows PowerShell 5.1, rendus par le job +« scripts » de la CI à chaque pull request. +``` + +--- + +## Ce que ce plan ne fait pas + +Repris de §9 de la spec, pour l'implémenteur qui serait tenté : + +- **Aucun passthrough série** (`usbipd`, `--device`). Aucun test n'en a besoin : `serial.Opener` est une seam injectée, et une machine sans port série est le cas nominal. +- **Aucun job CI qui construit l'image.** Le banc de la tâche 2 attrape la dérive de version pour quelques millisecondes ; une construction d'image coûterait 4 à 6 minutes par pull request. +- **Aucun Docker Compose**, aucun service annexe. SQLite est en pur Go. +- **Aucune modification du `Makefile` ni de `make.ps1`.** Si une tâche semble en demander une, c'est que quelque chose a été mal compris : s'arrêter et le signaler. From ea32250671b2ddbb81968d00a798dc21a41126b4 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 13:42:42 +0200 Subject: [PATCH 03/15] test(devcontainer): un lecteur de JSONC qui sait ou sont les chaines MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit devcontainer.json est du JSONC, et ce dépôt commente ses fichiers de configuration. encoding/json refuse un commentaire : il faut donc les retirer avant de décoder. Le lecteur suit l'état des chaînes plutôt que de chercher deux caractères. Sans cela, « https://containers.dev » serait coupé en deux et json.Unmarshal répondrait « unterminated string » en nommant une ligne — on chercherait la faute dans le mauvais fichier. --- deploy/devcontainer_test.go | 125 ++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 deploy/devcontainer_test.go diff --git a/deploy/devcontainer_test.go b/deploy/devcontainer_test.go new file mode 100644 index 0000000..dc423d2 --- /dev/null +++ b/deploy/devcontainer_test.go @@ -0,0 +1,125 @@ +package deploy + +import ( + "encoding/json" + "strings" + "testing" +) + +// TestTheJSONCReaderLeavesTheInsideOfStringsAlone is the whole difficulty of reading a +// devcontainer.json, and the reason this reader is not three lines of strings.ReplaceAll. +// +// A devcontainer.json is JSONC: containers.dev allows comments, and this repository +// comments its configuration files at length. encoding/json refuses a comment, so they +// have to go — but a reader that hunted for "//" without knowing where the strings are +// would cut "https://containers.dev" in half and hand json.Unmarshal an unterminated +// string. The error it reports then names a line number and nothing else, and the search +// starts in the wrong file. +func TestTheJSONCReaderLeavesTheInsideOfStringsAlone(t *testing.T) { + cases := []struct { + name string + source string + want string + }{ + { + name: "un commentaire de ligne disparaît, le retour à la ligne reste", + source: "{\n // la version vient de go.mod\n \"a\": 1\n}\n", + want: "{\n \n \"a\": 1\n}\n", + }, + { + name: "un commentaire de bloc disparaît, sur plusieurs lignes", + source: "{/* deux\nlignes */\"a\": 1}", + want: `{"a": 1}`, + }, + { + name: "les deux barres d'une URL ne sont pas un commentaire", + source: `{"doc": "https://containers.dev"}`, + want: `{"doc": "https://containers.dev"}`, + }, + { + name: "un guillemet échappé ne termine pas la chaîne", + source: `{"a": "un guillemet \" puis // rien du tout"}`, + want: `{"a": "un guillemet \" puis // rien du tout"}`, + }, + { + name: "l'identifiant d'un feature traverse sans une égratignure", + source: `{"ghcr.io/devcontainers/features/go:1": {"version": "1.26.5"}}`, + want: `{"ghcr.io/devcontainers/features/go:1": {"version": "1.26.5"}}`, + }, + } + + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + if got := withoutJSONComments(testCase.source); got != testCase.want { + t.Errorf("withoutJSONComments :\n reçu %q\n attendu %q", got, testCase.want) + } + }) + } +} + +// TestTheJSONCReaderProducesSomethingEncodingJSONAccepts closes the loop: stripping the +// comments is only useful if what comes out decodes. +func TestTheJSONCReaderProducesSomethingEncodingJSONAccepts(t *testing.T) { + source := "{\n // le commentaire de tête\n \"name\": \"OpenScale\", /* et un bloc */\n \"remoteUser\": \"vscode\"\n}\n" + var decoded struct { + Name string `json:"name"` + RemoteUser string `json:"remoteUser"` + } + if err := jsonDecode(withoutJSONComments(source), &decoded); err != nil { + t.Fatalf("décodage : %v", err) + } + if decoded.Name != "OpenScale" || decoded.RemoteUser != "vscode" { + t.Errorf("décodé : %+v", decoded) + } +} + +// jsonDecode is the one-line wrapper the tests of this file decode with. +func jsonDecode(source string, into any) error { + return json.Unmarshal([]byte(source), into) +} + +// withoutJSONComments removes the // and /* */ comments a JSONC file is allowed to carry. +// +// See TestTheJSONCReaderLeavesTheInsideOfStringsAlone for why it tracks strings rather +// than searching for two characters. +func withoutJSONComments(source string) string { + var out strings.Builder + inString, inLineComment, inBlockComment, escaped := false, false, false, false + for index := 0; index < len(source); index++ { + character := source[index] + switch { + case inLineComment: + if character == '\n' { + inLineComment = false + out.WriteByte(character) + } + case inBlockComment: + if character == '*' && index+1 < len(source) && source[index+1] == '/' { + inBlockComment = false + index++ + } + case inString: + out.WriteByte(character) + switch { + case escaped: + escaped = false + case character == '\\': + escaped = true + case character == '"': + inString = false + } + case character == '"': + inString = true + out.WriteByte(character) + case character == '/' && index+1 < len(source) && source[index+1] == '/': + inLineComment = true + index++ + case character == '/' && index+1 < len(source) && source[index+1] == '*': + inBlockComment = true + index++ + default: + out.WriteByte(character) + } + } + return out.String() +} From a1c7705db94a54c205fce45dbbee027fb727dfe9 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 13:50:59 +0200 Subject: [PATCH 04/15] test(devcontainer): le banc qui refuse un quatrieme endroit ou vivent les versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Go 1.26.5 vit dans go.mod et ci.yml, Node 22 dans ci.yml, Python 3.13 dans docs.yml, golangci-lint v2.12.2 dans le Makefile — chacun à un seul endroit, et la CI lit le dernier plutôt que de le recopier. Un devcontainer.json qui réécrirait ces numéros en ferait un quatrième endroit ; SUIVI.md rappelle que le seul compteur d'ADR a menti trois fois pour cette raison. Le banc compare dans les deux sens, et il exige aussi remoteUser non root : TestADirectoryTheServiceCanReadButNotWriteIsRefused saute sous root ET sous Windows, si bien qu'un conteneur root laisserait cette branche couverte par rien tout en restant vert. Rouge à ce commit : .devcontainer/ n'existe pas encore. C'est voulu — un banc écrit après le fichier qu'il garde ne dit jamais s'il rougirait. --- deploy/devcontainer_test.go | 181 ++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/deploy/devcontainer_test.go b/deploy/devcontainer_test.go index dc423d2..f2236e1 100644 --- a/deploy/devcontainer_test.go +++ b/deploy/devcontainer_test.go @@ -2,6 +2,7 @@ package deploy import ( "encoding/json" + "regexp" "strings" "testing" ) @@ -123,3 +124,183 @@ func withoutJSONComments(source string) string { } return out.String() } + +// The development container is guarded here for the same reason the release workflow is: +// nothing else in this repository reads .devcontainer/, and its failures are silent. A +// container that installs Go 1.27 while go.mod pins 1.26.5 does not fail — it produces +// green runs on a toolchain nobody else has, and §16.4 says exactly what that costs: the +// render golden files of §7.4 shift under a contributor who changed nothing. +// +// Every file here is read as TEXT. Parsing the YAML would add a dependency to a repository +// whose whole shape comes from refusing them, for assertions that are about four numbers. + +// devcontainerFile is the development container declaration. +const devcontainerFile = "../.devcontainer/devcontainer.json" + +// postCreateScript is what runs once the image is built. +const postCreateScript = "../.devcontainer/post-create.sh" + +// devcontainerDeclaration is the part of devcontainer.json this package has an opinion +// about. Everything else — extensions, port forwarding, editor settings — is free. +type devcontainerDeclaration struct { + Features map[string]struct { + Version string `json:"version"` + } `json:"features"` + RemoteUser string `json:"remoteUser"` + PostCreateCommand string `json:"postCreateCommand"` +} + +// readDevcontainer decodes the declaration, comments removed. +func readDevcontainer(t *testing.T) devcontainerDeclaration { + t.Helper() + var declaration devcontainerDeclaration + source := withoutJSONComments(readFile(t, devcontainerFile)) + if err := jsonDecode(source, &declaration); err != nil { + t.Fatalf("%s ne décode pas : %v", devcontainerFile, err) + } + return declaration +} + +// featureVersion returns the version a feature is pinned to, and fails when the feature is +// absent — an absent feature is the same silence as a wrong version. +func featureVersion(t *testing.T, declaration devcontainerDeclaration, feature string) string { + t.Helper() + pinned, present := declaration.Features[feature] + if !present { + t.Fatalf("%s ne déclare pas le feature %s : le conteneur ne fournirait pas cet outil", + devcontainerFile, feature) + } + if pinned.Version == "" { + t.Fatalf("le feature %s n'épingle aucune version : il installerait la dernière, "+ + "et le poste du contributeur cesserait de correspondre à la CI", feature) + } + return pinned.Version +} + +// declaredValue returns the value of a « key: "value" » line of a YAML workflow, COMMENTS +// REMOVED — see readWorkflow next door for the trap that makes codeOnly mandatory. +func declaredValue(t *testing.T, file, key string) string { + t.Helper() + pattern := regexp.MustCompile(`(?m)^\s*` + regexp.QuoteMeta(key) + `:\s*"([^"]+)"`) + match := pattern.FindStringSubmatch(codeOnly(readFile(t, file))) + if match == nil { + t.Fatalf("%s ne déclare plus « %s: \"…\" » : ce banc ne compare plus rien", file, key) + } + return match[1] +} + +// pinnedToolchain is the Go version go.mod pins, without its « go » prefix. +func pinnedToolchain(t *testing.T) string { + t.Helper() + match := regexp.MustCompile(`(?m)^toolchain go(\S+)$`).FindStringSubmatch(readFile(t, "../go.mod")) + if match == nil { + t.Fatal("go.mod ne porte plus de ligne « toolchain go… » : §16.4 l'exige pour que " + + "les fichiers de référence du rendu ne bougent pas d'une version d'outillage à l'autre") + } + return match[1] +} + +// TestTheContainerInstallsTheGoVersionTheRepositoryPins compares THREE declarations, and +// the third is not redundant: ci.yml and go.mod are already meant to agree, so a container +// that matched only one of them would hide the day they stop. +func TestTheContainerInstallsTheGoVersionTheRepositoryPins(t *testing.T) { + inContainer := featureVersion(t, readDevcontainer(t), "ghcr.io/devcontainers/features/go:1") + inGoMod := pinnedToolchain(t) + inCI := declaredValue(t, "../.github/workflows/ci.yml", "GO_VERSION") + + if inContainer != inGoMod { + t.Errorf("le conteneur installe Go %s, go.mod épingle %s : le contributeur "+ + "compilerait sur une chaîne que personne d'autre n'a", inContainer, inGoMod) + } + if inContainer != inCI { + t.Errorf("le conteneur installe Go %s, ci.yml en utilise %s : vert chez le "+ + "contributeur ne voudrait plus dire vert en intégration continue", inContainer, inCI) + } +} + +// TestTheContainerInstallsTheNodeVersionTheFrontIsBuiltWith guards §14.1: the client screen +// is committed in internal/web/dist, and the « dist à jour » step of ci.yml compares BYTES. +// A different Node builds a different bundle, and that step turns red on a dist that is +// perfectly up to date. +func TestTheContainerInstallsTheNodeVersionTheFrontIsBuiltWith(t *testing.T) { + inContainer := featureVersion(t, readDevcontainer(t), "ghcr.io/devcontainers/features/node:1") + inCI := declaredValue(t, "../.github/workflows/ci.yml", "node-version") + if inContainer != inCI { + t.Errorf("le conteneur installe Node %s, ci.yml en utilise %s : les deux ne "+ + "produiraient pas le même internal/web/dist", inContainer, inCI) + } +} + +// TestTheContainerInstallsThePythonVersionTheHandbookIsBuiltWith: mkdocs --strict is what +// refuses a broken internal link before it is published. +func TestTheContainerInstallsThePythonVersionTheHandbookIsBuiltWith(t *testing.T) { + inContainer := featureVersion(t, readDevcontainer(t), "ghcr.io/devcontainers/features/python:1") + inDocs := declaredValue(t, "../.github/workflows/docs.yml", "python-version") + if inContainer != inDocs { + t.Errorf("le conteneur installe Python %s, docs.yml en utilise %s", inContainer, inDocs) + } +} + +// TestTheContainerDoesNotRunAsRoot keeps a bench alive that nobody would notice dying. +// +// TestADirectoryTheServiceCanReadButNotWriteIsRefused (internal/platform/pathchecker_test.go) +// skips under root — « root écrit dans un répertoire 0555 » — AND skips on Windows, where a +// directory is closed by an ACL rather than by os.Chmod. A root container would therefore +// leave that branch covered by nothing at all, and the suite would still be green. +func TestTheContainerDoesNotRunAsRoot(t *testing.T) { + user := readDevcontainer(t).RemoteUser + if user == "" || user == "root" { + t.Errorf("remoteUser vaut %q : sous root, "+ + "TestADirectoryTheServiceCanReadButNotWriteIsRefused se saute en silence, et "+ + "cette branche n'est plus couverte nulle part", user) + } +} + +// TestTheContainerNeverWritesTheGolangciVersionItself is the rule of ADR-039 applied to a +// fourth file: the version is READ from the Makefile, never copied. +func TestTheContainerNeverWritesTheGolangciVersionItself(t *testing.T) { + script := readFile(t, postCreateScript) + if !strings.Contains(script, "make -s golangci-version") { + t.Error("post-create.sh n'appelle pas « make -s golangci-version » : la version " + + "de golangci-lint serait écrite à un quatrième endroit, et le contributeur " + + "verrait rouge là où la CI voit vert") + } + literal := regexp.MustCompile(`golangci-lint@v?[0-9]`) + if literal.MatchString(script) { + t.Error("post-create.sh écrit un numéro de version de golangci-lint en clair : " + + "le Makefile en est la source unique") + } + if strings.Contains(readFile(t, devcontainerFile), "golangciLintVersion") { + t.Error("devcontainer.json utilise l'option golangciLintVersion du feature Go : " + + "c'est un cinquième endroit où ce numéro vivrait") + } +} + +// TestThePostCreateCommandRunsTheScriptThisBenchReads: the bench above is worth nothing if +// devcontainer.json stops calling the file it inspects. +func TestThePostCreateCommandRunsTheScriptThisBenchReads(t *testing.T) { + command := readDevcontainer(t).PostCreateCommand + if !strings.Contains(command, "post-create.sh") { + t.Errorf("postCreateCommand vaut %q et n'appelle pas post-create.sh : "+ + "TestTheContainerNeverWritesTheGolangciVersionItself lirait un fichier mort", command) + } +} + +// TestTheImageCarriesWhatTheBenchesNeed names three apt packages and the bench each one +// keeps alive. Slimming the image is a reasonable-looking change; losing -race to it is not. +func TestTheImageCarriesWhatTheBenchesNeed(t *testing.T) { + dockerfile := readFile(t, "../.devcontainer/Dockerfile") + needed := []struct{ packageName, why string }{ + {"build-essential", "gcc, sans quoi la passe -race de `make test` ne peut pas " + + "tourner : c'est la seule vérification automatique des trois invariants de " + + "concurrence du Hub (important-3)"}, + {"zip", "la cible `release` du Makefile empaquette avec"}, + {"systemd", "systemd-analyze, sans quoi TestTheUnitIsValidAccordingToSystemdItself " + + "se saute et plus rien ne juge les unités livrées"}, + } + for _, need := range needed { + if !strings.Contains(codeOnly(dockerfile), need.packageName) { + t.Errorf("le Dockerfile n'installe pas %s — %s", need.packageName, need.why) + } + } +} From 2fc9ee7b04b19ddf9c1ecdda8b5f2e09dbae9a56 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 13:56:34 +0200 Subject: [PATCH 05/15] feat(devcontainer): une image ou six des sept verifications de la CI se rejouent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois fichiers : l'image et ses trois paquets apt, la déclaration, et ce qui s'installe après la construction. Chaque paquet tient un banc et le dit : build-essential pour gcc, sans quoi la passe -race se saute ; zip pour la cible release ; systemd pour systemd-analyze seul. Le banc du commit précédent refuse qu'on allège cette liste en silence. Les caches Go et npm sont dans des volumes, hors du dossier monté : sous Windows, le bind coûte ×29 sur les métadonnées — 143 ms contre 5 ms pour parcourir 577 fichiers. Sortis du bind, ils ne paient plus cette taxe. post-create.sh lit la version de golangci-lint par « make -s golangci-version » et l'installe hors module, comme ADR-039 l'exige et comme le fait déjà ci.yml. --- .devcontainer/Dockerfile | 20 ++++++++++++ .devcontainer/devcontainer.json | 58 +++++++++++++++++++++++++++++++++ .devcontainer/post-create.sh | 45 +++++++++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 .devcontainer/Dockerfile create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/post-create.sh diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..165bf73 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,20 @@ +# L'image du poste de développement — voir devcontainer.json à côté. +# +# Trois paquets, et chacun tient un banc précis. Alléger cette liste est un changement qui +# a l'air raisonnable ; deploy/devcontainer_test.go le refuse et dit ce qu'il coûterait. +FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 + +# build-essential : gcc. La passe `-race` de `make test` EXIGE ThreadSanitizer, donc cgo. +# Sans lui elle se saute, et on perd la seule vérification automatique +# des trois invariants de concurrence du Hub (important-3). +# zip : la cible `release` du Makefile empaquette avec. +# systemd : pour `systemd-analyze` seul — il ne tourne pas ici. Sans lui, +# TestTheUnitIsValidAccordingToSystemdItself se saute, et plus rien ne +# demande à systemd lui-même s'il accepte les unités livrées. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + build-essential \ + zip \ + systemd \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4691290 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,58 @@ +// OpenScale — le poste de développement en conteneur. +// +// CE FICHIER EST UNE SECONDE PORTE. Le chemin sans conteneur — `make`, `make.ps1` — reste +// la référence, et c'est lui que la CI exécute. Rien ici ne doit devenir un passage obligé. +// +// AUCUN NUMÉRO DE VERSION N'EST DÉCIDÉ ICI. Go vient de `go.mod` et de `ci.yml`, Node de +// `ci.yml`, Python de `docs.yml`, golangci-lint du `Makefile` — et `deploy/devcontainer_test.go` +// compare les quatre dans les deux sens. Une version modifiée ici sans l'être là-bas rougit. +// +// Ce que ce conteneur NE JUGE PAS : les scripts d'installation sous Windows PowerShell 5.1. +// Un conteneur Linux n'a que pwsh 7, qui n'est pas le shell d'un poste de balance. Le job +// « scripts » de ci.yml est le seul endroit où ils sont exécutés pour de bon. +{ + "name": "OpenScale", + "build": { "dockerfile": "Dockerfile" }, + + "features": { + "ghcr.io/devcontainers/features/go:1": { "version": "1.26.5" }, + "ghcr.io/devcontainers/features/node:1": { "version": "22" }, + "ghcr.io/devcontainers/features/python:1": { "version": "3.13" }, + // pwsh 7 ANALYSE les .ps1 — `powershellPaths` les lit sous tout shell présent, et une + // faute de syntaxe grossière rougit donc ici plutôt qu'en CI. Il ne les EXÉCUTE pas : + // `requireWindowsToRunCommonPs1` s'y oppose, et il a raison. + "ghcr.io/devcontainers/features/powershell:1": {} + }, + + // Non root, et ce n'est pas de l'hygiène : TestADirectoryTheServiceCanReadButNotWriteIsRefused + // se saute sous root — et se saute aussi sous Windows. Un conteneur root laisserait cette + // branche couverte par rien, en restant vert. + "remoteUser": "vscode", + + // Les caches sont dans des volumes et non dans le dossier monté : sous Windows, le bind + // coûte ×29 sur les métadonnées (143 ms contre 5 ms pour parcourir 577 fichiers). Sortis + // du bind, ils ne paient plus cette taxe, et la lenteur ne porte que sur les sources. + "containerEnv": { + "GOPATH": "/home/vscode/go", + "GOMODCACHE": "/home/vscode/go/pkg/mod", + "GOCACHE": "/home/vscode/.cache/go-build" + }, + "remoteEnv": { "PATH": "${containerEnv:PATH}:/home/vscode/go/bin" }, + "mounts": [ + "source=openscale-gomodcache,target=/home/vscode/go/pkg/mod,type=volume", + "source=openscale-gocache,target=/home/vscode/.cache/go-build,type=volume", + "source=openscale-node-modules,target=${containerWorkspaceFolder}/web/node_modules,type=volume" + ], + + "postCreateCommand": "bash .devcontainer/post-create.sh", + + "customizations": { + "vscode": { + "extensions": [ + "golang.go", + "svelte.svelte-vscode", + "ms-vscode.powershell" + ] + } + } +} diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100644 index 0000000..dd3cdb1 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,45 @@ +#!/bin/sh +# Ce qui s'installe une fois l'image construite. +# +# Ce fichier est commité en LF, et .gitattributes l'impose (`*.sh text eol=lf`). Un +# `#!/bin/sh\r` fait répondre à dash « Syntax error: word unexpected », et rien dans ce +# message ne pointe vers les fins de ligne — la panne est déjà arrivée à install.sh. +set -eu + +# Docker crée un volume vide sous root. Monté dans le $HOME d'un utilisateur non root, il +# est inécrivable, et `go build` échoue alors sur « permission denied » au fond d'un cache +# — ce qui ne ressemble pas à un problème de montage. +sudo chown -R vscode:vscode "$HOME/go" "$HOME/.cache/go-build" web/node_modules + +# golangci-lint s'installe HORS MODULE, et ADR-039 l'impose : `make deps` compare go.mod +# aux deux tables de §17.1 dans les deux sens, et une dépendance de développement inscrite +# là ouvrirait un écart permanent. +# +# La VERSION N'EST PAS ÉCRITE ICI : elle est lue dans le Makefile, qui en est la source +# unique — exactement ce que fait l'étape `make lint` de ci.yml. +golangci_version=$(make -s golangci-version) +install_dir=$(mktemp -d) +( + cd "$install_dir" + go mod init lintinstall >/dev/null 2>&1 + go install "github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$golangci_version" +) +rm -rf "$install_dir" + +# mkdocs, pour rejouer `mkdocs build --strict` avant de pousser. Le feature python installe +# son propre interpréteur : pip y fonctionne, là où le python système d'Ubuntu refuserait +# (PEP 668, « externally-managed-environment »). +pip install --no-cache-dir -r handbook/requirements.txt + +# `npm ci` et non `npm install` : les versions sont gelées dans package-lock.json (§14.1), +# et un « ^ » ferait bouger un fichier de référence. +npm ci --prefix web + +echo '' +echo 'Poste prêt. Ce que vous pouvez rejouer ici :' +echo ' make test les deux passes, -race comprise' +echo ' make front-check types, tests et budget de l ecran client' +echo ' mkdocs build --strict' +echo '' +echo 'Ce que ce conteneur NE juge PAS : les scripts d installation sous Windows' +echo 'PowerShell 5.1. Le job « scripts » de la CI le fait a chaque pull request.' From 7187972e4d223c23822d0b5806289cc21a0271fb Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 14:13:01 +0200 Subject: [PATCH 06/15] fix(devcontainer): deux fuites root et quatre commentaires qui affirmaient faux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le chown de post-create.sh ne portait que sur go-build ; $HOME/.cache reste un parent que Docker crée sous root, et c'est là que golangci-lint écrit. Le chown couvre maintenant tout $HOME/.cache. apt-get install manquait DEBIAN_FRONTEND=noninteractive : dbus ou libpam-systemd, tirés par systemd, peuvent poser une question debconf, et une invite dans docker build fige la construction au lieu d'échouer. Placé dans le RUN, pas en ENV, pour ne pas fuiter dans le conteneur en marche. Quatre commentaires corrigés pour dire le vrai mécanisme : build-essential fournit aussi make, dont post-create.sh dépend directement ; le banc ne compare que trois versions et interdit l'écriture de la quatrième, il ne les compare pas toutes ; npm ci est choisi pour son échec déterministe sur un lock désynchronisé, pas parce qu'un « ^ » bougerait un fichier que ci ne toucherait pas davantage ; et le shebang de post-create.sh n'est jamais consulté puisque devcontainer.json l'invoque par bash, pas par exécution directe — la règle LF reste vraie, seul le mécanisme de panne cité était emprunté à install.sh. --- .devcontainer/Dockerfile | 10 ++++++---- .devcontainer/devcontainer.json | 6 ++++-- .devcontainer/post-create.sh | 21 +++++++++++++-------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 165bf73..8248178 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -4,14 +4,16 @@ # a l'air raisonnable ; deploy/devcontainer_test.go le refuse et dit ce qu'il coûterait. FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 -# build-essential : gcc. La passe `-race` de `make test` EXIGE ThreadSanitizer, donc cgo. -# Sans lui elle se saute, et on perd la seule vérification automatique -# des trois invariants de concurrence du Hub (important-3). +# build-essential : gcc ET make. La passe `-race` de `make test` EXIGE ThreadSanitizer, +# donc cgo — sans lui elle se saute, et on perd la seule vérification +# automatique des trois invariants de concurrence du Hub (important-3). +# Et post-create.sh appelle `make -s golangci-version` en première +# commande réelle : réduire ce paquet à gcc seul casserait le script. # zip : la cible `release` du Makefile empaquette avec. # systemd : pour `systemd-analyze` seul — il ne tourne pas ici. Sans lui, # TestTheUnitIsValidAccordingToSystemdItself se saute, et plus rien ne # demande à systemd lui-même s'il accepte les unités livrées. -RUN apt-get update \ +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ && apt-get install -y --no-install-recommends \ build-essential \ zip \ diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 4691290..7b6d389 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -4,8 +4,10 @@ // la référence, et c'est lui que la CI exécute. Rien ici ne doit devenir un passage obligé. // // AUCUN NUMÉRO DE VERSION N'EST DÉCIDÉ ICI. Go vient de `go.mod` et de `ci.yml`, Node de -// `ci.yml`, Python de `docs.yml`, golangci-lint du `Makefile` — et `deploy/devcontainer_test.go` -// compare les quatre dans les deux sens. Une version modifiée ici sans l'être là-bas rougit. +// `ci.yml`, Python de `docs.yml` — et `deploy/devcontainer_test.go` compare les trois +// premières dans les deux sens. golangci-lint n'est pas comparée : elle est INTERDITE +// D'ÉCRITURE ici et lue dans le `Makefile`, une garantie plus forte qu'une comparaison. +// Une version modifiée ici sans l'être là-bas rougit. // // Ce que ce conteneur NE JUGE PAS : les scripts d'installation sous Windows PowerShell 5.1. // Un conteneur Linux n'a que pwsh 7, qui n'est pas le shell d'un poste de balance. Le job diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index dd3cdb1..93657a4 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -1,15 +1,19 @@ #!/bin/sh # Ce qui s'installe une fois l'image construite. # -# Ce fichier est commité en LF, et .gitattributes l'impose (`*.sh text eol=lf`). Un -# `#!/bin/sh\r` fait répondre à dash « Syntax error: word unexpected », et rien dans ce -# message ne pointe vers les fins de ligne — la panne est déjà arrivée à install.sh. +# Ce fichier est commité en LF, et .gitattributes l'impose (`*.sh text eol=lf`) — la règle +# reste vraie ici, mais pas par le mécanisme qui l'a fait poser sur install.sh : ce shebang +# n'est jamais consulté, devcontainer.json lance ce fichier par `bash .devcontainer/post- +# create.sh`, pas par exécution directe. Un CRLF ici sortirait donc en `$'\r': command not +# found` de bash, pas en « Syntax error: word unexpected » de dash. set -eu -# Docker crée un volume vide sous root. Monté dans le $HOME d'un utilisateur non root, il -# est inécrivable, et `go build` échoue alors sur « permission denied » au fond d'un cache -# — ce qui ne ressemble pas à un problème de montage. -sudo chown -R vscode:vscode "$HOME/go" "$HOME/.cache/go-build" web/node_modules +# Docker crée sous root les PARENTS manquants d'une cible de montage, pas seulement la +# cible elle-même. $HOME/.cache est un tel parent — go-build en est la seule cible montée +# — et golangci-lint écrit dans $HOME/.cache/golangci-lint : sans ce chown récursif sur +# .cache entier, `go build` échoue sur « permission denied » au fond d'un cache, ce qui +# ne ressemble pas à un problème de montage. +sudo chown -R vscode:vscode "$HOME/go" "$HOME/.cache" web/node_modules # golangci-lint s'installe HORS MODULE, et ADR-039 l'impose : `make deps` compare go.mod # aux deux tables de §17.1 dans les deux sens, et une dépendance de développement inscrite @@ -32,7 +36,8 @@ rm -rf "$install_dir" pip install --no-cache-dir -r handbook/requirements.txt # `npm ci` et non `npm install` : les versions sont gelées dans package-lock.json (§14.1), -# et un « ^ » ferait bouger un fichier de référence. +# et `ci` est DÉTERMINISTE — il ÉCHOUE sur un lock désynchronisé au lieu de le réparer +# en silence, là où `install` l'aurait accepté et modifié sans le dire. npm ci --prefix web echo '' From 42e760639deb58ec443156f98ff4f64e98c0d017 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 14:47:08 +0200 Subject: [PATCH 07/15] fix(deploy): le banc systemd verifie une copie, pas le bind mount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dans le devcontainer, le dépôt est monté par bind depuis l'hôte Windows, et NTFS présente chaque fichier comme exécutable et inscriptible par tous à Linux. systemd-analyze verify refusait donc les deux unités sur leur MODE de fichier, jamais sur leur contenu, ce qui faisait rougir TestTheUnitIsValidAccordingToSystemdItself sans qu'aucune unité ne soit en cause. Le banc copie désormais les deux unités dans un répertoire temporaire avec le mode 0644, puis fait vérifier les copies par systemd-analyze. Il juge ainsi le contenu des unités, indépendamment du système de fichiers qui héberge le checkout. --- deploy/linux_test.go | 37 +++++++++++++++++++++++++++++++++++-- 1 file changed, 35 insertions(+), 2 deletions(-) diff --git a/deploy/linux_test.go b/deploy/linux_test.go index 1a9a63e..8b2283c 100644 --- a/deploy/linux_test.go +++ b/deploy/linux_test.go @@ -2,6 +2,7 @@ package deploy import ( "bytes" + "os" "os/exec" "path/filepath" "regexp" @@ -38,8 +39,15 @@ func TestTheUnitIsValidAccordingToSystemdItself(t *testing.T) { if err != nil { t.Skip("systemd-analyze absent : les directives sont vérifiées par le test suivant") } - output, err := exec.Command(analyze, "verify", - unitPath("openscale.service"), unitPath("openscale-kiosk.service")).CombinedOutput() + // systemd-analyze verify also inspects the MODE of the file it is handed, and refuses + // one that is executable or world-writable — sound on a real filesystem, but this + // repository can be checked out through a bind mount (the project's devcontainer mounts + // it from a Windows host), where NTFS reports every file as 0777 to Linux regardless of + // what install.sh or the unit itself asks for. Verifying COPIES written with an explicit + // 0644 makes the bench judge the unit's CONTENT, independent of whatever filesystem + // happens to host the checkout. + units := copyUnitsForVerification(t, "openscale.service", "openscale-kiosk.service") + output, err := exec.Command(analyze, append([]string{"verify"}, units...)...).CombinedOutput() // systemd-analyze checks that ExecStart points at something EXECUTABLE, which on a // CI runner it never is: nothing is installed there. That complaint is about the // MACHINE, not about the unit, and failing on it would mean this test can only run @@ -58,6 +66,31 @@ func TestTheUnitIsValidAccordingToSystemdItself(t *testing.T) { } } +// copyUnitsForVerification copies the named shipped units into a throwaway directory with +// mode 0644, and returns their new paths in the same order. +// +// systemd-analyze verify judges the file it is given, mode included, and this repository's +// checkout does not always control that mode (see the comment above its one caller). A copy +// with a mode this test chooses itself is what keeps the bench about the unit and not about +// the checkout. +func copyUnitsForVerification(t *testing.T, names ...string) []string { + t.Helper() + directory := t.TempDir() + copies := make([]string, 0, len(names)) + for _, name := range names { + content, err := os.ReadFile(unitPath(name)) + if err != nil { + t.Fatalf("lecture de %s : %v", name, err) + } + copyPath := filepath.Join(directory, name) + if err := os.WriteFile(copyPath, content, 0o644); err != nil { + t.Fatalf("copie de %s : %v", name, err) + } + copies = append(copies, copyPath) + } + return copies +} + // TestTheStopTimeoutFollowsTheMeasuredShutdownBudget is the §13.4 fix, guarded where it // can actually drift. // From 2dbf8287ad360ac5431ef989fa41ed1995afacb0 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 14:48:49 +0200 Subject: [PATCH 08/15] build(devcontainer): epingler les quatre features par empreinte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ghcr.io/devcontainers/features/go:1` est un tag MOBILE, et ci.yml dit déjà ce que ça vaut : « un tag n'est pas une version, c'est un pointeur — et sur un dépôt public, c'est le chemin d'attaque le moins coûteux qui existe contre une chaîne de construction ». Le raisonnement qui épingle chaque action GitHub sur un SHA de commit s'applique mot pour mot aux features du conteneur. Ce fichier est produit par le CLI et non écrit à la main. Il ne déplace aucune version : devcontainer.json continue de déclarer Go 1.26.5, Node 22 et Python 3.13, que deploy/devcontainer_test.go compare à go.mod, ci.yml et docs.yml. Ce qu'il fige, c'est la révision des features elles-mêmes. --- .devcontainer/devcontainer-lock.json | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .devcontainer/devcontainer-lock.json diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 0000000..79f58cb --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,24 @@ +{ + "features": { + "ghcr.io/devcontainers/features/go:1": { + "version": "1.3.4", + "resolved": "ghcr.io/devcontainers/features/go@sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032", + "integrity": "sha256:d85e921f91b41340055bb12b325d9d551170ed04b3b832e33530bf42f167c032" + }, + "ghcr.io/devcontainers/features/node:1": { + "version": "1.7.1", + "resolved": "ghcr.io/devcontainers/features/node@sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6", + "integrity": "sha256:8c0de46939b61958041700ee89e3493f3b2e4131a06dc46b4d9423427d06e5f6" + }, + "ghcr.io/devcontainers/features/powershell:1": { + "version": "1.5.1", + "resolved": "ghcr.io/devcontainers/features/powershell@sha256:df7baa89598c93bfd15808641d9ec9eb03e0ccdf52e5de4cbbce9ab2d9755d18", + "integrity": "sha256:df7baa89598c93bfd15808641d9ec9eb03e0ccdf52e5de4cbbce9ab2d9755d18" + }, + "ghcr.io/devcontainers/features/python:1": { + "version": "1.8.0", + "resolved": "ghcr.io/devcontainers/features/python@sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511", + "integrity": "sha256:fbcad6955caeecc5ad3f7886baf652e25cba5225a6c4c2287c536de2e5607511" + } + } +} From 6bcb5367e15ee8a5eb552288a1fc3f0de533a051 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 15:00:08 +0200 Subject: [PATCH 09/15] fix(devcontainer): declarer le depot monte comme safe.directory pour git MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur un poste Windows, le workspace est monte tel quel et tout y appartient a root alors que post-create.sh tourne sous vscode : git refuse le depot pour « dubious ownership », et `go list` perd son horodatage VCS sur ce meme refus. Le premier symptome vu ne nomme ni git ni les droits d'acces : c'est « boundary: 1 violation(s) — voir docs/02-architecture.md §5.2 », qui fait mourir `make test` dans `make boundary`. Touche tout contributeur passant par le chemin par defaut (cloner sous Windows, rouvrir dans le conteneur). Un banc dans deploy/devcontainer_test.go garde la ligne en place : verifie rouge sans elle puis vert avec, avant ce commit. --- .devcontainer/post-create.sh | 8 ++++++++ deploy/devcontainer_test.go | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 93657a4..bdacd24 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -8,6 +8,14 @@ # found` de bash, pas en « Syntax error: word unexpected » de dash. set -eu +# Le poste Windows monte le dépôt tel quel, et tout y appartient à root alors que ce +# script tourne sous vscode : git refuse alors le dépôt pour « dubious ownership », et +# la panne ne se présente jamais sous ce nom. Le premier symptôme vu est « boundary: 1 +# violation(s) — voir docs/02-architecture.md §5.2 », parce que `go list` perd son +# horodatage VCS sur ce même refus de git — rien n'y mentionne git ni les droits. Le +# --replace-all évite d'empiler des doublons si ce script est rejoué. +git config --global --replace-all safe.directory "$PWD" + # Docker crée sous root les PARENTS manquants d'une cible de montage, pas seulement la # cible elle-même. $HOME/.cache est un tel parent — go-build en est la seule cible montée # — et golangci-lint écrit dans $HOME/.cache/golangci-lint : sans ce chown récursif sur diff --git a/deploy/devcontainer_test.go b/deploy/devcontainer_test.go index f2236e1..ba64d73 100644 --- a/deploy/devcontainer_test.go +++ b/deploy/devcontainer_test.go @@ -276,6 +276,23 @@ func TestTheContainerNeverWritesTheGolangciVersionItself(t *testing.T) { } } +// TestThePostCreateScriptDeclaresTheWorkspaceASafeDirectory guards a fix that does not +// look like what it fixes: a Windows host bind-mounts the workspace owned by root while +// the container runs as vscode, git then refuses the repository for "dubious ownership", +// and the failure a contributor actually sees is "boundary: 1 violation(s) — voir +// docs/02-architecture.md §5.2" — `go list` losing its VCS stamp on that same refusal, +// with nothing in the message naming git or file ownership. Losing this line silently +// turns every `make test` red at `make boundary` for anyone on the default clone path. +func TestThePostCreateScriptDeclaresTheWorkspaceASafeDirectory(t *testing.T) { + script := readFile(t, postCreateScript) + if !strings.Contains(script, "git config --global --replace-all safe.directory") { + t.Error("post-create.sh ne déclare plus le dépôt comme safe.directory : sur un " + + "poste Windows, git refusera le dépôt monté pour « dubious ownership », et " + + "`make test` mourra dans `make boundary` sur un message qui ne parle ni de " + + "git ni de droits d'accès") + } +} + // TestThePostCreateCommandRunsTheScriptThisBenchReads: the bench above is worth nothing if // devcontainer.json stops calling the file it inspects. func TestThePostCreateCommandRunsTheScriptThisBenchReads(t *testing.T) { From dd46d725c4e47c8a1cc5058c46ecd93dcf92e066 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 15:04:20 +0200 Subject: [PATCH 10/15] docs(devcontainer): clarifier deux commentaires qui pretaient a confusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « vscode » dans remoteUser est le nom d'un compte Unix livré par l'image de base, sans rapport avec l'éditeur — une question réelle du propriétaire du produit montre que ce n'était pas évident à la lecture. Le bloc customizations.vscode.extensions n'avait aucune mention de sa portée : un contributeur qui n'ouvre pas VS Code pouvait se demander si sa présence rendait l'éditeur obligatoire. Elle ne le rend pas : la clé est ignorée par tout ce qui n'est pas VS Code ou l'un de ses forks. --- .devcontainer/devcontainer.json | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7b6d389..fd52174 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -26,6 +26,10 @@ "ghcr.io/devcontainers/features/powershell:1": {} }, + // "vscode" est ici le nom d'un COMPTE UNIX — uid 1000, sans mot de passe — livré par + // l'image de base mcr.microsoft.com/devcontainers/base. Rien à voir avec l'éditeur : ce + // conteneur tourne identiquement sous VS Code, un fork, ou la CLI seule. + // // Non root, et ce n'est pas de l'hygiène : TestADirectoryTheServiceCanReadButNotWriteIsRefused // se saute sous root — et se saute aussi sous Windows. Un conteneur root laisserait cette // branche couverte par rien, en restant vert. @@ -48,6 +52,9 @@ "postCreateCommand": "bash .devcontainer/post-create.sh", + // La clé customizations.vscode est IGNORÉE par tout ce qui n'est pas VS Code ou l'un de + // ses forks (Cursor, Windsurf...) : sa présence n'oblige à rien qui ouvre ce conteneur + // autrement, CLI comprise. "customizations": { "vscode": { "extensions": [ From 05ae6df4171d307647732973864d45104842e96e Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 15:06:23 +0200 Subject: [PATCH 11/15] docs(devcontainer): deux chemins, et ce que le conteneur ne juge pas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit getting-started.md ouvre sur deux chemins au lieu d'un seul. Le tableau des prérequis existant ne bouge pas : il devient le chemin local, qui reste la référence. Le chemin conteneur mène avec la CLI `devcontainer`, indépendante de tout éditeur — c'est elle qui a servi à construire, lancer et tester cette fonctionnalité de bout en bout, sans jamais ouvrir VS Code. Un éditeur qui la porte (VS Code, Cursor, Windsurf, une JetBrains récente) n'a besoin que de Docker ; la CLI demande en plus Node sur le poste. Les deux coûts mesurés tiennent dans une note : 8 min 3 s pour la première image, 84 s pour `make test` dedans, passe `-race` comprise. Deux phrases devenaient fausses telles quelles — « pas de Docker » ici et dans le README. Elles disaient qu'aucun outil n'est requis, ce qui reste vrai : elles le disent maintenant en nommant la seconde porte. Ce que le conteneur ne juge pas tient en une phrase et pas en un paragraphe : les scripts d'installation sous Windows PowerShell 5.1, rendus par le job « scripts » de la CI à chaque pull request. --- README.md | 5 +++- handbook/getting-started.md | 54 ++++++++++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 781a383..9a3083b 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,10 @@ qu'un détail compte. ## Développer -Go 1.26.5, Node 22 seulement pour le front. Pas de chaîne C, pas de Docker. +Go 1.26.5, Node 22 seulement pour le front. Pas de chaîne C. Un devcontainer est fourni +pour qui préfère ne rien installer : `.devcontainer/`, et +[le parcours de démarrage](https://lostmind84.github.io/OpenScale/getting-started/) le +détaille. ```bash make test # ou : pwsh -File ./make.ps1 test diff --git a/handbook/getting-started.md b/handbook/getting-started.md index 6a7986d..31e821e 100644 --- a/handbook/getting-started.md +++ b/handbook/getting-started.md @@ -3,6 +3,57 @@ Objectif : un poste complet qui tourne sur votre machine, **sans balance et sans imprimante**. Comptez cinq minutes. +## Deux chemins + +| Chemin | Ce qu'il faut sur votre poste | Pour qui | +|---|---|---| +| **Conteneur** | Docker, et Node si vous passez par la CLI (Docker seul suffit avec un éditeur qui la porte) | Découverte, contribution ponctuelle, poste qu'on ne veut pas encombrer | +| **Local** | Go, et le reste selon ce que vous touchez | Développement quotidien, mise en route d'une balance ou d'une imprimante réelle | + +### Le chemin conteneur + +La commande `devcontainer`, indépendante de tout éditeur, construit une image qui porte Go, +Node, Python, gcc et golangci-lint aux versions **exactes** de l'intégration continue : + +```bash +npm i -g @devcontainers/cli +devcontainer up --workspace-folder . +devcontainer exec --workspace-folder . make test +``` + +C'est le chemin réellement vérifié : cette fonctionnalité a été construite, lancée et +testée par cette commande et par `docker exec`, sans jamais ouvrir VS Code. + +Un éditeur qui porte les conteneurs de développement — VS Code, Cursor, Windsurf, une +JetBrains récente — arrive au même résultat depuis « Reopen in Container » et n'a besoin +que de Docker : l'implémentation du devcontainer est intégrée à l'éditeur. La CLI, elle, +demande en plus Node sur votre poste. Le compromis est réel : à vous de choisir. + +Vous pouvez alors rejouer, avant de pousser, tout ce que la CI vérifie **sauf un point** : +les scripts d'installation sous Windows PowerShell 5.1, qu'aucun conteneur Linux ne peut +exécuter. C'est le job `scripts` de la CI qui les juge, à chaque pull request. + +!!! note "Sous Windows, si les compilations traînent" + + Le dépôt reste sur votre disque Windows et le conteneur le lit à travers un montage : + parcourir 577 fichiers y prend 143 ms contre 5 ms depuis le système de fichiers de WSL, + soit **×29 sur les métadonnées**. Les caches Go et npm sont déjà hors de ce montage, si + bien que seule la lecture des sources le paie. Si cela vous gêne, clonez le dépôt côté + WSL (`~/dev/OpenScale` depuis un terminal Ubuntu) et rouvrez-le de là. + +!!! note "Ce que ça coûte, mesuré" + + Première construction de l'image : **8 min 3 s** (`devcontainer up`). Elle n'est payée + qu'une fois ; les ouvertures suivantes sont immédiates. `make test` dedans : **84 s**, + passe `-race` comprise — celle qu'un poste Windows saute faute de gcc. + +Aucune balance ni imprimante n'est nécessaire : aucun test du projet n'ouvre de port série, +et une machine sans port série est le cas de développement ordinaire. + +### Le chemin local + +Le tableau des prérequis qui suit reste la référence. + ## Prérequis | Outil | Version | Pourquoi cette version | @@ -20,7 +71,8 @@ Deux outils sont **facultatifs** et vous n'en avez pas besoin pour ce parcours : `make test` est sautée avec un avertissement et l'intégration continue la couvre. Sous Windows : `winget install BrechtSanders.WinLibs.POSIX.UCRT`. -Pas de Docker, pas de chaîne C, pas de service à installer. +Pas de chaîne C, pas de service à installer. Docker n'est nécessaire que si vous +choisissez le chemin conteneur ci-dessus. ## Installer From a8c7eef25507dd99df9e7811ccb12db45b379512 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 15:14:31 +0200 Subject: [PATCH 12/15] docs(devcontainer): l'intro annonce le cout des deux chemins, pas d'un seul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Comptez cinq minutes » ouvrait la page avant toute bifurcation, alors que le chemin conteneur — présenté en premier — coûte 8 min 3 s à la première construction, mesurés. Le lecteur ne l'apprenait que quarante lignes plus bas. La phrase annonce désormais les deux, et dit que la note salée n'est payée qu'une fois. --- handbook/getting-started.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/handbook/getting-started.md b/handbook/getting-started.md index 31e821e..2d52a7a 100644 --- a/handbook/getting-started.md +++ b/handbook/getting-started.md @@ -1,7 +1,8 @@ # Démarrer Objectif : un poste complet qui tourne sur votre machine, **sans balance et sans -imprimante**. Comptez cinq minutes. +imprimante**. Comptez cinq minutes par le chemin local, une dizaine par le conteneur +la première fois — les suivantes sont immédiates. ## Deux chemins From 7647faa46d4b2d50ba740cce7152ea9553434a54 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 15:44:01 +0200 Subject: [PATCH 13/15] test(devcontainer): fermer trois portes ouvertes par la revue finale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le banc anti-dérive avait trois angles morts. F1 : rien ne vérifiait que devcontainer.json construit encore l'image depuis le Dockerfile qu'il inspecte — un « image » préconstruit aurait laissé les sept tests verts pendant que gcc, zip et systemd disparaissaient. F2 : la disparition du feature powershell se traduit par un skip « ni pwsh ni powershell » qui ressemble à une machine sans Windows, jamais à une perte. F3 : le fichier de verrou devcontainer-lock.json n'était gardé par rien — le supprimer ou y oublier un feature laissait tout vert. F6 corrige aussi un banc existant : strings.Contains(dockerfile, "zip") est satisfait par gzip ou bzip2. Un \b le ferme. F9 étend le banc `sh -n` de deploy/shell_test.go à .devcontainer/post-create.sh, jusque-là non analysé : une faute de syntaxe n'y aurait été découverte qu'après une construction de huit minutes. --- deploy/devcontainer_test.go | 81 ++++++++++++++++++++++++++++++++++++- deploy/shell_test.go | 5 +++ 2 files changed, 84 insertions(+), 2 deletions(-) diff --git a/deploy/devcontainer_test.go b/deploy/devcontainer_test.go index ba64d73..4491b64 100644 --- a/deploy/devcontainer_test.go +++ b/deploy/devcontainer_test.go @@ -143,6 +143,9 @@ const postCreateScript = "../.devcontainer/post-create.sh" // devcontainerDeclaration is the part of devcontainer.json this package has an opinion // about. Everything else — extensions, port forwarding, editor settings — is free. type devcontainerDeclaration struct { + Build struct { + Dockerfile string `json:"dockerfile"` + } `json:"build"` Features map[string]struct { Version string `json:"version"` } `json:"features"` @@ -303,10 +306,81 @@ func TestThePostCreateCommandRunsTheScriptThisBenchReads(t *testing.T) { } } +// TestTheBuildDeclarationBuildsFromTheFileTheseBenchesRead: TestTheImageCarriesWhatTheBenchesNeed +// is worth nothing if devcontainer.json stops building the image from the file it inspects — +// exactly the argument TestThePostCreateCommandRunsTheScriptThisBenchReads already makes for +// post-create.sh. Replacing `"build": { "dockerfile": "Dockerfile" }` with a prebuilt +// `"image": "…"` would leave every test in this file green while gcc, zip and systemd +// silently stop being installed. +func TestTheBuildDeclarationBuildsFromTheFileTheseBenchesRead(t *testing.T) { + dockerfile := readDevcontainer(t).Build.Dockerfile + if dockerfile != "Dockerfile" { + t.Errorf("build.dockerfile vaut %q : TestTheImageCarriesWhatTheBenchesNeed et les "+ + "bancs de version liraient un Dockerfile que l'image ne construit plus — par "+ + "exemple si devcontainer.json était passé à une clé « image » prébuilt", dockerfile) + } +} + +// TestTheContainerDeclaresThePowerShellFeature guards a loss that reads like an honest skip. +// +// Removing this feature does not turn any test in this file red: powershellPaths +// (deploy/harness_test.go) simply finds nothing on the container's Linux, and the .ps1 +// parse benches skip with « ni pwsh ni powershell » — the exact message a machine that never +// had Windows would produce. TestTheContainerDoesNotRunAsRoot already guards the same shape +// of loss for the root user; this is the same reasoning applied to the feature that lets +// three of the four PowerShell benches run under Linux at all (§4 of the design). +// +// No version is asserted: this feature pins none (§5.2), and featureVersion would wrongly +// fail on an intentionally empty version. +func TestTheContainerDeclaresThePowerShellFeature(t *testing.T) { + if _, present := readDevcontainer(t).Features["ghcr.io/devcontainers/features/powershell:1"]; !present { + t.Error("devcontainer.json ne déclare plus le feature powershell : les bancs de " + + "deploy/harness_test.go qui analysent les .ps1 se sauteraient sur ce conteneur " + + "avec « ni pwsh ni powershell » — le même message qu'une machine qui n'a jamais " + + "eu Windows, sans rien qui distingue les deux causes") + } +} + +// devcontainerLockFile is the CLI-generated companion of devcontainerFile: one content +// digest per feature, on top of the version devcontainerFile pins. +const devcontainerLockFile = "../.devcontainer/devcontainer-lock.json" + +// devcontainerLock is the part of devcontainer-lock.json this package has an opinion about. +type devcontainerLock struct { + Features map[string]struct { + Integrity string `json:"integrity"` + } `json:"features"` +} + +// TestEveryFeatureIsPinnedByDigestInTheLockFile guards a supply-chain control that has no +// guard of its own otherwise: devcontainer-lock.json pins each feature to a content digest, +// not just the version devcontainer.json names. Adding a fifth feature without touching the +// lock ships it unpinned, and deleting the lock file entirely leaves every other test in +// this file green — nothing else reads it. +func TestEveryFeatureIsPinnedByDigestInTheLockFile(t *testing.T) { + var lock devcontainerLock + source := withoutJSONComments(readFile(t, devcontainerLockFile)) + if err := jsonDecode(source, &lock); err != nil { + t.Fatalf("%s ne décode pas : %v", devcontainerLockFile, err) + } + for feature := range readDevcontainer(t).Features { + pinned, present := lock.Features[feature] + if !present { + t.Errorf("%s ne pin pas le feature %s : il s'installerait sans empreinte de "+ + "contenu, seulement à la version que devcontainer.json déclare", devcontainerLockFile, feature) + continue + } + if !strings.HasPrefix(pinned.Integrity, "sha256:") { + t.Errorf("%s : le feature %s a une integrity %q, pas une empreinte sha256", + devcontainerLockFile, feature, pinned.Integrity) + } + } +} + // TestTheImageCarriesWhatTheBenchesNeed names three apt packages and the bench each one // keeps alive. Slimming the image is a reasonable-looking change; losing -race to it is not. func TestTheImageCarriesWhatTheBenchesNeed(t *testing.T) { - dockerfile := readFile(t, "../.devcontainer/Dockerfile") + dockerfile := codeOnly(readFile(t, "../.devcontainer/Dockerfile")) needed := []struct{ packageName, why string }{ {"build-essential", "gcc, sans quoi la passe -race de `make test` ne peut pas " + "tourner : c'est la seule vérification automatique des trois invariants de " + @@ -316,7 +390,10 @@ func TestTheImageCarriesWhatTheBenchesNeed(t *testing.T) { "se saute et plus rien ne juge les unités livrées"}, } for _, need := range needed { - if !strings.Contains(codeOnly(dockerfile), need.packageName) { + // \b évite qu'un « zip » soit satisfait par gzip, unzip ou bzip2 — un Dockerfile qui + // remplacerait le paquet zip par une dépendance transitive de gzip resterait vert. + pattern := regexp.MustCompile(`\b` + regexp.QuoteMeta(need.packageName) + `\b`) + if !pattern.MatchString(dockerfile) { t.Errorf("le Dockerfile n'installe pas %s — %s", need.packageName, need.why) } } diff --git a/deploy/shell_test.go b/deploy/shell_test.go index 6c8170b..275fcba 100644 --- a/deploy/shell_test.go +++ b/deploy/shell_test.go @@ -91,6 +91,10 @@ func TestNoLinuxArtifactCarriesAWindowsLineEnding(t *testing.T) { } // TestTheShellScriptsAreValidAccordingToTheShell runs `sh -n` when a shell is available. +// +// .devcontainer/post-create.sh joins the list: it is not under linux/, but a syntax error in +// it is otherwise discovered only after an eight-minute container build, while `sh -n` costs +// nothing and runs on the same Linux CI that already builds this list. func TestTheShellScriptsAreValidAccordingToTheShell(t *testing.T) { shell, err := exec.LookPath("sh") if err != nil { @@ -100,6 +104,7 @@ func TestTheShellScriptsAreValidAccordingToTheShell(t *testing.T) { if err != nil || len(scripts) == 0 { t.Fatalf("aucun script shell trouvé : %v", err) } + scripts = append(scripts, filepath.Join("..", ".devcontainer", "post-create.sh")) for _, script := range scripts { output, err := exec.Command(shell, "-n", script).CombinedOutput() if err != nil { From f134856032b9764faefa3d8502d47c838e5da8f8 Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 15:44:01 +0200 Subject: [PATCH 14/15] fix(devcontainer): un volume par conteneur, un piege sous set -e, un commentaire faux MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F4 : source=openscale-node-modules était un nom fixe. Deux conteneurs OpenScale ouverts à la fois — un arbre de revue et le clone principal — partageaient un seul web/node_modules pour deux package-lock.json potentiellement différents ; le `npm ci` de l'un vidait celui de l'autre, sans qu'aucun message ne parle de montage. ${devcontainerId} isole désormais ce volume par conteneur. GOMODCACHE et GOCACHE restent partagés : leur contenu est adressé par version et l'outil Go verrouille ce qu'il lit, le partage n'y est qu'un gain — la distinction est dans le commentaire. F5 : le Dockerfile affirmait que post-create.sh appelle `make -s golangci-version` en première commande réelle ; ce sont `git config` puis `sudo chown` qui s'exécutent avant. L'argument de fond (retirer ce paquet à gcc seul casserait le script, qui a besoin de make) reste, reformulé sans l'affirmation fausse. F7 : sous `set -e`, un `go install` en échec sortait le script avant le `rm -rf "$install_dir"` de la ligne suivante, laissant le répertoire temporaire. Un `trap ... EXIT` posé juste après le `mktemp -d` couvre les deux chemins. F8 : `npm ci --prefix web` s'aligne désormais sur `npm --prefix web ci`, l'ordre que le Makefile emploie partout — un grep trouve maintenant tous les appels npm avec la même forme. --- .devcontainer/Dockerfile | 5 +++-- .devcontainer/devcontainer.json | 8 +++++++- .devcontainer/post-create.sh | 5 ++++- 3 files changed, 14 insertions(+), 4 deletions(-) diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8248178..2136225 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -7,8 +7,9 @@ FROM mcr.microsoft.com/devcontainers/base:ubuntu-24.04 # build-essential : gcc ET make. La passe `-race` de `make test` EXIGE ThreadSanitizer, # donc cgo — sans lui elle se saute, et on perd la seule vérification # automatique des trois invariants de concurrence du Hub (important-3). -# Et post-create.sh appelle `make -s golangci-version` en première -# commande réelle : réduire ce paquet à gcc seul casserait le script. +# Et post-create.sh appelle `make -s golangci-version` pour lire cette +# version : réduire ce paquet à gcc seul casserait le script, qui a besoin +# de make autant que de gcc. # zip : la cible `release` du Makefile empaquette avec. # systemd : pour `systemd-analyze` seul — il ne tourne pas ici. Sans lui, # TestTheUnitIsValidAccordingToSystemdItself se saute, et plus rien ne diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index fd52174..329c281 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -44,10 +44,16 @@ "GOCACHE": "/home/vscode/.cache/go-build" }, "remoteEnv": { "PATH": "${containerEnv:PATH}:/home/vscode/go/bin" }, + // GOMODCACHE et GOCACHE restent partagés entre deux conteneurs OpenScale ouverts en même + // temps : leur contenu est adressé par version, l'outil Go verrouille ce qu'il lit, et le + // partage n'est qu'un gain. web/node_modules ne peut pas suivre la même règle — un dépôt + // principal et un arbre de revue ouverts ensemble peuvent avoir des package-lock.json + // différents, et le `npm ci` de l'un viderait et reconstruirait le dossier de l'autre, en + // silence. ${devcontainerId} isole ce volume par conteneur. "mounts": [ "source=openscale-gomodcache,target=/home/vscode/go/pkg/mod,type=volume", "source=openscale-gocache,target=/home/vscode/.cache/go-build,type=volume", - "source=openscale-node-modules,target=${containerWorkspaceFolder}/web/node_modules,type=volume" + "source=openscale-node-modules-${devcontainerId},target=${containerWorkspaceFolder}/web/node_modules,type=volume" ], "postCreateCommand": "bash .devcontainer/post-create.sh", diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index bdacd24..5d63408 100644 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -31,6 +31,9 @@ sudo chown -R vscode:vscode "$HOME/go" "$HOME/.cache" web/node_modules # unique — exactement ce que fait l'étape `make lint` de ci.yml. golangci_version=$(make -s golangci-version) install_dir=$(mktemp -d) +# Sous `set -e`, un `go install` qui échoue fait sortir le script AVANT le `rm -rf` de la +# dernière ligne du bloc : ce trap couvre ce chemin-là en plus du chemin heureux. +trap 'rm -rf "$install_dir"' EXIT ( cd "$install_dir" go mod init lintinstall >/dev/null 2>&1 @@ -46,7 +49,7 @@ pip install --no-cache-dir -r handbook/requirements.txt # `npm ci` et non `npm install` : les versions sont gelées dans package-lock.json (§14.1), # et `ci` est DÉTERMINISTE — il ÉCHOUE sur un lock désynchronisé au lieu de le réparer # en silence, là où `install` l'aurait accepté et modifié sans le dire. -npm ci --prefix web +npm --prefix web ci echo '' echo 'Poste prêt. Ce que vous pouvez rejouer ici :' From eac9707b43c1d77f99652ec3503d0e4a364edf3f Mon Sep 17 00:00:00 2001 From: "Fabien F." Date: Tue, 11 Aug 2026 15:44:01 +0200 Subject: [PATCH 15/15] docs(devcontainer): deux politiques d'epinglage, et ce qu'est le fichier de verrou MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §5.1 : l'image de base reste sur l'étiquette mobile ubuntu-24.04 quand les features sont épinglées par empreinte — deux arbitrages du propriétaire du produit, chacun sa raison, qui coexistaient jusque-là sans qu'aucun des deux ne soit écrit. §5.2 : devcontainer-lock.json n'était mentionné nulle part dans la conception. Le paragraphe ajouté dit ce qu'il est (produit par la CLI, jamais écrit à la main), pourquoi il est committé (dépôt public, features/go:1 est une étiquette mobile) et comment le rafraîchir (`devcontainer upgrade`) — sans quoi un fichier généré se fait un jour supprimer comme un artefact. --- ...-devcontainer-poste-sans-outillage-design.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md b/docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md index 64f634a..af9f791 100644 --- a/docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md +++ b/docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md @@ -138,6 +138,14 @@ saute **aussi** sous Windows (*« un répertoire Windows se ferme par une ACL et branche ». Un développeur sous Windows ne l'a donc **jamais** exécuté. Le conteneur le lui rend — à condition de ne pas être root. +L'image de base reste sur cette **étiquette mobile**, `ubuntu-24.04`, plutôt que sur une +empreinte figée — à la différence des features de §5.2 — et c'est un arbitrage du +propriétaire du produit, pas un oubli : une étiquette mobile est ce par quoi l'image reçoit +ses correctifs de sécurité `apt`, et personne ici ne rafraîchirait une empreinte gelée à la +main. Les features, elles, exécutent du code d'installation à la construction ; l'image de +base n'exécute que ce que `apt-get upgrade` livrerait de toute façon. Deux politiques, +chacune sa raison. + ### 5.2 Features, épinglées | Feature | Version | Source de vérité | @@ -147,6 +155,15 @@ rend — à condition de ne pas être root. | Python | 3.13 | `docs.yml` (`python-version`) | | PowerShell | 7 | — (aucune version épinglée ailleurs ; l'analyse ne dépend pas du correctif) | +Chaque feature est de plus épinglée par empreinte de contenu dans +`.devcontainer/devcontainer-lock.json`, en plus de la version du tableau ci-dessus. Ce fichier +n'est pas écrit à la main : il est produit par la CLI `devcontainer`, qui le lit et le complète +à chaque `up` ou `build`. Il est committé parce que ce dépôt est public et que l'un des quatre +features, `ghcr.io/devcontainers/features/go:1`, est justement de la forme d'une étiquette +mobile — sans le lock, deux constructions du même `devcontainer.json` pourraient résoudre deux +contenus différents, en silence. Pour le rafraîchir volontairement, la commande est +`devcontainer upgrade` ; il ne se modifie jamais à la main. + ### 5.3 Paquets `apt`, et la raison de chacun - `build-essential` — gcc, sans quoi la passe `-race` ne peut pas tourner. C'est le gain le