test(writes): count every write, drop the stack attribution (#40) #44

Merged
maximus merged 1 commit from issue-40-count-every-write into main 2026-10-02 00:46:10 +00:00
Owner

Fixes #40

Ce qui change

__tests__/writes.test.js n'attribuait une écriture à l'API que si une frame d'un fichier runtime se trouvait sur la pile synchrone. Une fonction node:fs passée telle quelle à un mécanisme asynchrone s'exécute sans cette frame, et l'écriture passait sur un chemin pourtant exercé.

  • Les espions node:fs et child_process comptent maintenant tout appel, sans condition de pile et sans drapeau.
  • Les retouches de fixtures du cas dégradé passent par les fonctions réelles capturées au chargement du fichier, avant tout espion (real.writeFileSync, real.rmSync, real.renameSync). Aucune fenêtre où les espions ne regardent pas, contrairement au drapeau paused proposé dans le commentaire 17331 de la PR #39.
  • La suppression récursive de REPORTS_DIR devient un renommage : rmSync(dir, { recursive: true }) appelle en interne le fs.rmdirSync public, donc l'espion, même depuis l'original capturé.
  • captureStack() et fromRuntimeFile() sont supprimés ; isRuntimeFile() reste pour la purge du cache.
  • En-tête du test et ligne writes.test.js de CLAUDE.md réécrits. La phrase de README.md (section Bind-mounts) et le gotcha « L'API n'est plus read-only » restent exacts, non modifiés.

Aucun code runtime ne change : pas de redéploiement après le merge.

Preuve par mutation

Chaque mutation est appliquée à index.js depuis une copie de sauvegarde, puis writes.test.js est lancé dans sa version de main (avant) et dans celle de cette branche (après). Nombre de cas rouges sur 11 :

Mutation Avant Après
aucune 0 0
G0 (témoin) process.nextTick(() => writeFileSync(tmp, "x")) dans handleDefenseurs 2 2
G1 process.nextTick(writeFileSync, tmp, "x") 0 2
G2 res.on("finish", writeFileSync.bind(null, tmp, "x")) 0 2
G3 new fs.WriteStream(tmp).end("x") 0 2
G4 Promise.resolve(tmp).then(fs.promises.rm) 0 2
R1 agents-map.json corrompu mis en quarantaine par renameSync 1 1
R2 serveJsonFile recrée le fichier absent 1 1
R3 logRejection ajoute à auth.log 1 1
R4 execFileSync("touch", …) dans /defenseurs 2 2
R5 require("node:fs/promises").writeFile après la réponse de /reports/scans 2 2
R6 openSync(…, O_WRONLY | O_CREAT) dans /defenseurs/state 2 2
R7 createWriteStream(…, { flags: "a" }) dans /hosts 2 2
R8 écriture différée par setImmediate après res.end dans /hosts 2 2
R9 le POST écrit aussi ${HOSTS_DIR}-backup.json 1 1
R10 writeFileSync(new URL("file://…")) sous REPORTS_DIR 2 2
R11 le 400 « JSON illisible » dépose le corps reçu 1 1
V1 400 « date invalide » journalisé (appendFileSync) 1 1
V2 findLatestReportForAgent recrée le dossier manquant 1 1
V3 404 : marqueur /data/defenseurs/last-404 1 1
V4 413 : corps refusé déposé sous REPORTS_DIR/.. 1 1
V5 403 : fs.promises.appendFile sans await 1 1
V6 agents-map.json corrompu copié à côté 1 1
V7 serveJsonFile recrée le fichier absent par setImmediate 1 1
C1 spawnSync("sh", ["-c", "touch …"]) dans /defenseurs/state 2 2
C3 exec("touch …") dans GET /hosts 2 2
D1 setInterval de 60 s posé au chargement 0 0 (limite écrite : différée au-delà de 20 ms)

Les revues de la PR #39 décrivent R, V et C en une ligne, sans code : ces mutations sont réécrites d'après leur description, pas recopiées. R5 à R8, R10 et R4 donnent 2 rouges ici contre 1 dans la revue 17233, écrite avant l'ajout du cas dégradé. R8b n'est décrite nulle part dans les deux revues et n'a pas été rejouée. La mutation m5 de la PR #39 (attribution morte) n'a plus d'objet.

index.js restauré depuis la copie, identique octet pour octet, git diff vide.

Vérification

  • writes.test.js : 15 passes sur 15 en boucle.
  • npm test : 355/355 (compte inchangé).
  • sh scripts/boot-from-copy.sh : PASS.
  • bash test-curl.sh : 45 passed, 0 failed, lancé sans FORCE_COLOR. Avec le FORCE_COLOR=3 du terminal de cette session, 8 cas échouent, sur main comme ici : node -e 'console.log(<nombre>)' colore sa sortie. Sans rapport avec cette PR, la CI n'a pas cette variable ; traité avec #41.

🤖 Generated with Claude Code

Fixes #40 ## Ce qui change `__tests__/writes.test.js` n'attribuait une écriture à l'API que si une frame d'un fichier runtime se trouvait sur la pile synchrone. Une fonction `node:fs` passée telle quelle à un mécanisme asynchrone s'exécute sans cette frame, et l'écriture passait sur un chemin pourtant exercé. - Les espions `node:fs` et `child_process` comptent maintenant **tout** appel, sans condition de pile et sans drapeau. - Les retouches de fixtures du cas dégradé passent par les fonctions réelles capturées au chargement du fichier, avant tout espion (`real.writeFileSync`, `real.rmSync`, `real.renameSync`). Aucune fenêtre où les espions ne regardent pas, contrairement au drapeau `paused` proposé dans le commentaire 17331 de la PR #39. - La suppression récursive de `REPORTS_DIR` devient un renommage : `rmSync(dir, { recursive: true })` appelle en interne le `fs.rmdirSync` public, donc l'espion, même depuis l'original capturé. - `captureStack()` et `fromRuntimeFile()` sont supprimés ; `isRuntimeFile()` reste pour la purge du cache. - En-tête du test et ligne `writes.test.js` de `CLAUDE.md` réécrits. La phrase de `README.md` (section Bind-mounts) et le gotcha « L'API n'est plus read-only » restent exacts, non modifiés. Aucun code runtime ne change : pas de redéploiement après le merge. ## Preuve par mutation Chaque mutation est appliquée à `index.js` depuis une copie de sauvegarde, puis `writes.test.js` est lancé dans sa version de `main` (avant) et dans celle de cette branche (après). Nombre de cas rouges sur 11 : | | Mutation | Avant | Après | |---|---|---|---| | aucune | | 0 | 0 | | G0 (témoin) | `process.nextTick(() => writeFileSync(tmp, "x"))` dans `handleDefenseurs` | 2 | 2 | | G1 | `process.nextTick(writeFileSync, tmp, "x")` | **0** | **2** | | G2 | `res.on("finish", writeFileSync.bind(null, tmp, "x"))` | **0** | **2** | | G3 | `new fs.WriteStream(tmp).end("x")` | **0** | **2** | | G4 | `Promise.resolve(tmp).then(fs.promises.rm)` | **0** | **2** | | R1 | `agents-map.json` corrompu mis en quarantaine par `renameSync` | 1 | 1 | | R2 | `serveJsonFile` recrée le fichier absent | 1 | 1 | | R3 | `logRejection` ajoute à `auth.log` | 1 | 1 | | R4 | `execFileSync("touch", …)` dans `/defenseurs` | 2 | 2 | | R5 | `require("node:fs/promises").writeFile` après la réponse de `/reports/scans` | 2 | 2 | | R6 | `openSync(…, O_WRONLY \| O_CREAT)` dans `/defenseurs/state` | 2 | 2 | | R7 | `createWriteStream(…, { flags: "a" })` dans `/hosts` | 2 | 2 | | R8 | écriture différée par `setImmediate` après `res.end` dans `/hosts` | 2 | 2 | | R9 | le POST écrit aussi `${HOSTS_DIR}-backup.json` | 1 | 1 | | R10 | `writeFileSync(new URL("file://…"))` sous `REPORTS_DIR` | 2 | 2 | | R11 | le 400 « JSON illisible » dépose le corps reçu | 1 | 1 | | V1 | 400 « date invalide » journalisé (`appendFileSync`) | 1 | 1 | | V2 | `findLatestReportForAgent` recrée le dossier manquant | 1 | 1 | | V3 | 404 : marqueur `/data/defenseurs/last-404` | 1 | 1 | | V4 | 413 : corps refusé déposé sous `REPORTS_DIR/..` | 1 | 1 | | V5 | 403 : `fs.promises.appendFile` sans `await` | 1 | 1 | | V6 | `agents-map.json` corrompu copié à côté | 1 | 1 | | V7 | `serveJsonFile` recrée le fichier absent par `setImmediate` | 1 | 1 | | C1 | `spawnSync("sh", ["-c", "touch …"])` dans `/defenseurs/state` | 2 | 2 | | C3 | `exec("touch …")` dans `GET /hosts` | 2 | 2 | | D1 | `setInterval` de 60 s posé au chargement | 0 | 0 (limite écrite : différée au-delà de 20 ms) | Les revues de la PR #39 décrivent R, V et C en une ligne, sans code : ces mutations sont réécrites d'après leur description, pas recopiées. R5 à R8, R10 et R4 donnent 2 rouges ici contre 1 dans la revue 17233, écrite avant l'ajout du cas dégradé. R8b n'est décrite nulle part dans les deux revues et n'a pas été rejouée. La mutation m5 de la PR #39 (attribution morte) n'a plus d'objet. `index.js` restauré depuis la copie, identique octet pour octet, `git diff` vide. ## Vérification - `writes.test.js` : 15 passes sur 15 en boucle. - `npm test` : 355/355 (compte inchangé). - `sh scripts/boot-from-copy.sh` : PASS. - `bash test-curl.sh` : 45 passed, 0 failed, lancé sans `FORCE_COLOR`. Avec le `FORCE_COLOR=3` du terminal de cette session, 8 cas échouent, sur `main` comme ici : `node -e 'console.log(<nombre>)'` colore sa sortie. Sans rapport avec cette PR, la CI n'a pas cette variable ; traité avec #41. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
maximus added 1 commit 2026-10-02 00:08:34 +00:00
test(writes): count every write, drop the stack attribution (#40)
All checks were successful
CI / verification (pull_request) Successful in 23s
CI / verification (push) Successful in 20s
5912f08e41
A node:fs function handed as-is to an asynchronous mechanism
(process.nextTick(writeFileSync, ...), a bound listener, a .then) runs with
no runtime frame on its stack, so the stack filter let the write through on
a path the suite does exercise.

The spies now record every call. The test's own fixture edits go through
the real functions captured before any spy exists. The recursive rmSync of
the reports directory becomes a rename: rmSync recurses through the public
fs.rmdirSync, which is the spy by then.

Fixes #40

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Author
Owner

Verdict : APPROVE

Résumé. Le correctif fait ce qu'il annonce : les espions node:fs et child_process comptent tout appel, et le trou de l'issue #40 (fonction passée telle quelle à un mécanisme asynchrone) est fermé. Suite et mutations rejouées par le relecteur sur une copie hors dépôt de la tête 5912f08 (git archive), Node v22.22.0 ; CI de la tête verte (CI / verification (pull_request) : success).

Mutations rejouées

Mutation insérée en tête de handleDefenseurs dans index.js, writes.test.js lancé dans sa version de main (avant) puis dans celle de la branche (après). Cas rouges sur 11 :

Mutation Avant Après
aucune 0 0
G0 process.nextTick(() => writeFileSync(tmp, "x")) 2 2
G1 process.nextTick(writeFileSync, tmp, "x") 0 2
G2 res.on("finish", writeFileSync.bind(null, tmp, "x")) 0 2
G3 new fs.WriteStream(tmp).end("x") 0 2
G4 Promise.resolve(tmp).then(fs.promises.rm) 0 2

Mutations du relecteur, absentes du tableau de la PR :

Mutation Avant Après
N1 setImmediate(writeFileSync, tmp, "x") 0 2
N2 queueMicrotask(writeFileSync.bind(null, tmp, "x")) 0 2
N3 setTimeout(fs.writeFile, 0, tmp, "x", cb) 0 2
N4 process.nextTick(child_process.execFileSync, "touch", [tmp]) 0 2
N5 res.on("close", child_process.exec.bind(null, "touch …")) 0 2
N6 Promise.resolve().then(fs.promises.appendFile.bind(null, tmp, "x")) 0 2
D2 setTimeout(writeFileSync, 200, tmp, "x") 0 0 (limite écrite : au-delà de 20 ms)

N4 et N5 montrent que le même trou existait côté child_process et qu'il est fermé aussi. index.js restauré, identique octet pour octet à la sauvegarde après chaque série.

Mutations côté test, pour les deux affirmations du corps de la PR :

  • real.rmSync(REPORTS_DIR, { recursive: true }) à la place du renommage : 1 rouge, rmdirSync(…/defenseurs/reports) dans les violations. L'affirmation « rmSync récursif appelle le fs.rmdirSync public » est exacte, le renommage est nécessaire.
  • Retouches de fixtures repassées par fs.* (espionné) au lieu de real.* : 1 rouge, 6 violations. Les fonctions capturées sont nécessaires, pas décoratives.

Vérification

  • writes.test.js : 15 passes sur 15 en boucle, aucune écriture parasite du lanceur.
  • npm test : 355/355, compte de CLAUDE.md inchangé et exact.
  • sh scripts/boot-from-copy.sh : PASS.
  • test-curl.sh non rejoué (aucun code runtime touché, étape verte en CI).
  • Aucun reste de captureStack / fromRuntimeFile dans le dépôt ; la phrase du README.md (ligne 160) reste exacte.
  • Timeline de l'issue #40 : pull_ref de la PR #44 avec ref_action: "closes", l'issue se fermera au merge. Commit au format conventionnel, Fixes #40 aussi dans le message.
  • Aucun secret, aucun skip/only, aucun code runtime modifié.

Suggestions (non bloquantes)

  1. La liste WRITE_APIS ne couvre pas mkdtempDisposableSync / fs.promises.mkdtempDisposable (Node 24). Sans objet tant que l'image reste node:22-alpine ; à ajouter le jour d'une montée de version.
  2. Le commentaire sur real dit « Only single-call functions belong here » : un test-épingle sur les clés de real n'apporterait rien, la mutation T1 montre que l'erreur se signale seule (cas rouge immédiat).
## Verdict : APPROVE **Résumé.** Le correctif fait ce qu'il annonce : les espions `node:fs` et `child_process` comptent tout appel, et le trou de l'issue #40 (fonction passée telle quelle à un mécanisme asynchrone) est fermé. Suite et mutations rejouées par le relecteur sur une copie hors dépôt de la tête `5912f08` (`git archive`), Node v22.22.0 ; CI de la tête verte (`CI / verification (pull_request)` : success). ### Mutations rejouées Mutation insérée en tête de `handleDefenseurs` dans `index.js`, `writes.test.js` lancé dans sa version de `main` (avant) puis dans celle de la branche (après). Cas rouges sur 11 : | | Mutation | Avant | Après | |---|---|---|---| | aucune | | 0 | 0 | | G0 | `process.nextTick(() => writeFileSync(tmp, "x"))` | 2 | 2 | | G1 | `process.nextTick(writeFileSync, tmp, "x")` | **0** | **2** | | G2 | `res.on("finish", writeFileSync.bind(null, tmp, "x"))` | **0** | **2** | | G3 | `new fs.WriteStream(tmp).end("x")` | **0** | **2** | | G4 | `Promise.resolve(tmp).then(fs.promises.rm)` | **0** | **2** | Mutations du relecteur, absentes du tableau de la PR : | | Mutation | Avant | Après | |---|---|---|---| | N1 | `setImmediate(writeFileSync, tmp, "x")` | **0** | **2** | | N2 | `queueMicrotask(writeFileSync.bind(null, tmp, "x"))` | **0** | **2** | | N3 | `setTimeout(fs.writeFile, 0, tmp, "x", cb)` | **0** | **2** | | N4 | `process.nextTick(child_process.execFileSync, "touch", [tmp])` | **0** | **2** | | N5 | `res.on("close", child_process.exec.bind(null, "touch …"))` | **0** | **2** | | N6 | `Promise.resolve().then(fs.promises.appendFile.bind(null, tmp, "x"))` | **0** | **2** | | D2 | `setTimeout(writeFileSync, 200, tmp, "x")` | 0 | 0 (limite écrite : au-delà de 20 ms) | N4 et N5 montrent que le même trou existait côté `child_process` et qu'il est fermé aussi. `index.js` restauré, identique octet pour octet à la sauvegarde après chaque série. Mutations côté test, pour les deux affirmations du corps de la PR : - `real.rmSync(REPORTS_DIR, { recursive: true })` à la place du renommage : 1 rouge, `rmdirSync(…/defenseurs/reports)` dans les violations. L'affirmation « `rmSync` récursif appelle le `fs.rmdirSync` public » est exacte, le renommage est nécessaire. - Retouches de fixtures repassées par `fs.*` (espionné) au lieu de `real.*` : 1 rouge, 6 violations. Les fonctions capturées sont nécessaires, pas décoratives. ### Vérification - `writes.test.js` : 15 passes sur 15 en boucle, aucune écriture parasite du lanceur. - `npm test` : 355/355, compte de `CLAUDE.md` inchangé et exact. - `sh scripts/boot-from-copy.sh` : PASS. - `test-curl.sh` non rejoué (aucun code runtime touché, étape verte en CI). - Aucun reste de `captureStack` / `fromRuntimeFile` dans le dépôt ; la phrase du `README.md` (ligne 160) reste exacte. - Timeline de l'issue #40 : `pull_ref` de la PR #44 avec `ref_action: "closes"`, l'issue se fermera au merge. Commit au format conventionnel, `Fixes #40` aussi dans le message. - Aucun secret, aucun `skip`/`only`, aucun code runtime modifié. ### Suggestions (non bloquantes) 1. La liste `WRITE_APIS` ne couvre pas `mkdtempDisposableSync` / `fs.promises.mkdtempDisposable` (Node 24). Sans objet tant que l'image reste `node:22-alpine` ; à ajouter le jour d'une montée de version. 2. Le commentaire sur `real` dit « Only single-call functions belong here » : un test-épingle sur les clés de `real` n'apporterait rien, la mutation T1 montre que l'erreur se signale seule (cas rouge immédiat).
maximus merged commit 5912f08e41 into main 2026-10-02 00:46:10 +00:00
maximus deleted branch issue-40-count-every-write 2026-10-02 00:46:11 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/vps-health-api#44
No description provided.