Elke PDF-load crashte met dezelfde ReferenceError. De stack trace wees nergens nuttigs naar, en de specificatie die de code leidde, zag er op papier volkomen redelijk uit. Er stond dat er een feature flag gecontroleerd moest worden en dat elke regio vergeleken moest worden met de helft van pageWidth. Het beschreef wat er moest gebeuren. Het liet echter na te beschrijven waar pageWidth vandaan moest komen, en die enkele omissie was genoeg om de hele pipeline plat te leggen.

Architectuurdocumenten zijn goed in het uitleggen van gedrag. Ze zijn vaak slecht in het uitleggen van grenzen. Een zin als "de functie controleert x tegen pageWidth" is geen technisch contract; het is een narratief dat een afhankelijkheid verbergt in gewoon Engels. Wanneer een ontwikkelaar die zin leest en een functie op moduleniveau schrijft die naar pageWidth verwijst bij naam, ziet de code er correct uit omdat deze aan de beschrijving voldoet. Vervolgens probeert de runtime de naam op te lossen, vindt niets in de scope, en gooit een error.

De refactor die eenvoudig had moeten zijn

Ik zag dit exacte patroon tijdens een refactor van de pagina-assemblage. De specificatie bevatte twee schijnbaar heldere vereisten:

  • Check FEATURE_LAYOUT.
  • Vergelijk elke regio met pageWidth / 2.

De ontwikkelaar volgde de instructies nauwgezet op. Ze extraheerden een utility-functie op moduleniveau en plaatsten pageWidth direct in de functiebody zonder het als parameter te definiëren. De specificatie had niet vermeld dat pageWidth via een argumentenlijst moest worden meegegeven. Er stond niet dat de functie op moduleniveau stond, waar pageWidth niet langer zichtbaar was. Er werd er simpelweg van uitgegaan dat de implementeerder de executiecontext impliciet begreep.

Het resultaat was een ReferenceError bij elke PDF-load. Omdat de variabele ontbrak in de module-scope, wierp de functie onmiddellijk een error. Als de specificatie de functiegrens en de inputs expliciet had benoemd, had de ontwikkelaar pageWidth meegegeven en was de bug structureel onmogelijk geweest. In plaats daarvan werkte de instructie als een valstrik, die de implementeerder uitnodigde om te grijpen naar een parent scope die niet bestond.

De vier betekenissen van "gebruikt"

Het dieperliggende probleem is dat natuurlijke taal geen typesysteem heeft. Wanneer een specificatie zegt "de functie gebruikt X", is de zin op minstens vier specifieke manieren ambigu in een moderne JavaScript-codebase:

  • De functie ontvangt X als een formeel parameter.
  • De functie leest X uit een variabele op moduleniveau die in hetzelfde bestand is verklaard.
  • De functie maakt gebruik van een closure over X vanuit een geneste parent scope.
  • De functie extraheert X uit een groter object dat aan de functie wordt meegegeven.

Al deze opties voldoen aan de bewoording van de specificatie. Ze passeren allemaal de statische analyse. Maar slechts één ervan is correct voor een gegeven grens, en de verkeerde keuze lekt aannames over die grens heen op een manier die stilzwijgend compileert.

Ontwikkelaars kiezen op het moment van schrijven meestal de weg van de minste weerstand. Als pageWidth toevallig in een outer scope staat, zullen ze het van daaruit lezen in plaats van de functiesignatuur aan te passen. Een closure verbergt de afhankelijkheid. De code werkt bij de eerste run, slaagt voor de testsuite en wordt gepusht. Weken later verplaatst iemand diezelfde functie naar een ander bestand voor hergebruik of om de leesbaarheid te verbeteren. De parent scope verdwijnt. De code breekt, en de fout lijkt op een nieuwe regressie, ook al was de hoofdoorzaak de oorspronkelijke verborgen afhankelijkheid.

Web Workers wissen het bewijs uit

Dit probleem wordt echt verraderlijk zodra Web Workers onderdeel worden van de architectuur. Wanneer er een fout optreedt in een worker, stript de browser de informatie die je het hardst nodig hebt.

Dit is wat er feitelijk gebeurt. In een worker triggert een niet-opgevangen uitzondering een ErrorEvent. Als de worker die fout doorstuurt naar de main thread, is het gebruikelijke patroon om de message-string te pakken en deze over de grens te sturen. De main thread ontvangt die string, maakt er een nieuw Error-object van en logt of hergooit deze. Wat in DevTools verschijnt, is de gereconstrueerde fout binnen de message handler van de main thread. De originele bestandsnaam, het regelnummer en de stack trace worden weggegooid. De werkelijke locatie van de fout wordt onzichtbaar.

Dus toen de ontbrekende pageWidth een ReferenceError in de worker triggerde, rapporteerde de main thread alleen de tekst "pageWidth is not defined" op de plek waar het bericht werd afgehandeld. De eigenlijke functie op moduleniveau stond in