diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 0000000..2136225 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,23 @@ +# 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 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` 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 +# demande à systemd lui-même s'il accepte les unités livrées. +RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ + && 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-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" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..329c281 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,73 @@ +// 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` — 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 +// « 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": {} + }, + + // "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. + "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" }, + // 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-${devcontainerId},target=${containerWorkspaceFolder}/web/node_modules,type=volume" + ], + + "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": [ + "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..5d63408 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,61 @@ +#!/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`) — 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 + +# 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 +# .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 +# 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) +# 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 + 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 `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 --prefix web ci + +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.' 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/deploy/devcontainer_test.go b/deploy/devcontainer_test.go new file mode 100644 index 0000000..4491b64 --- /dev/null +++ b/deploy/devcontainer_test.go @@ -0,0 +1,400 @@ +package deploy + +import ( + "encoding/json" + "regexp" + "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() +} + +// 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 { + Build struct { + Dockerfile string `json:"dockerfile"` + } `json:"build"` + 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") + } +} + +// 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) { + 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) + } +} + +// 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 := 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 " + + "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 { + // \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/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. // 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 { 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. 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..af9f791 --- /dev/null +++ b/docs/superpowers/specs/2026-08-11-devcontainer-poste-sans-outillage-design.md @@ -0,0 +1,326 @@ +# 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. + +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é | +|---|---|---| +| 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) | + +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 + 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`. diff --git a/handbook/getting-started.md b/handbook/getting-started.md index 6a7986d..2d52a7a 100644 --- a/handbook/getting-started.md +++ b/handbook/getting-started.md @@ -1,7 +1,59 @@ # 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 + +| 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 @@ -20,7 +72,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