Skip to content

Read the archives we ship without an external tool, and give every deployment gate a binary it can start - #510

Merged
MelbourneDeveloper merged 9 commits into
mainfrom
windows-path-spelling
Sep 4, 2026
Merged

MelbourneDeveloper merged 9 commits into
mainfrom
windows-path-spelling

Conversation

@MelbourneDeveloper

@MelbourneDeveloper MelbourneDeveloper commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator

TLDR

Five deployment gates could not run on Windows — three because they shelled out to unzip, which Windows does not ship, and two because they staged shell scripts where a binary had to be started. All ten now pass there, the archive format we ship is read in-process, and the Windows CI job runs the gates that guard a Windows release for the first time.

Details

Three verifiers could not open the artifact they verify. verify-jetbrains-package.mjs, verify-vsix-package.mjs and assert-vsix-schema-doc.mjs each shelled out to Info-ZIP's unzip. Windows ships neither zip nor unzip, so on the platform a win32-x64 VSIX is built for — and where a maintainer is most likely to want to check one before publishing — they aborted on a missing program before checking anything. That is not a weaker answer; it is indistinguishable from having no gate.

scripts/lib/zip.mjs reads and writes the format we actually ship. A VSIX and a JetBrains plugin are both zip archives, and the format is small enough to read directly — which buys two things a subprocess cannot: every entry's checksum is verified as it is decompressed, so a corrupt archive is a named failure rather than a confusing one, and an archive written here is byte-for-byte reproducible, so a fixture cannot differ between two runs. Only what our archives contain is supported: stored and deflated entries in a single-part archive, with Zip64 and any other compression method refused by name rather than guessed at.

The proofs staged something Windows cannot start. The verifier proof suite wrote #!/bin/sh scripts as stand-ins for shipped binaries. Windows has no shebang and CreateProcess will not start a text file, so every proof spawned nothing, got a null status and empty output, and compared that against an expectation which was also empty. Those comparisons passed. This is the more dangerous of the two failures, because it is green.

scripts/lib/fake-binary.rs is compiled once per run — rustc is the one compiler this repository is guaranteed to have, and the CI job that runs these gates already installs the toolchain — and copied per fixture, each copy carrying its own two answers appended after a marker. The answers ride inside the image rather than in a file beside it because a second file staged next to a binary is an undeclared package entry, which the verifiers are right to reject.

One rule about file names, written four times and left out of a fifth. A component's file is its binaryName plus .exe on Windows and nothing elsewhere. test-action-diff-gate.mjs did not carry that rule and looked for target/release/deslop, so it reported the CLI missing on the one platform that has it. executableName in scripts/release/vsix-platforms.mjs is now the only implementation, read by all three verifiers, the fixtures and the action gate; currentPlatform there replaces three more copies of the host-platform chain, including the extension's own platform.mjs. New spec section [DEPLOY-BINARY-FILE-NAME].

Three more gates reached for things Windows resolves differently. bash by name, which finds WSL's copy first and sees a different filesystem than the checkout; a POSIX PATH assembled by hand around a drive letter, which the shell reads as two entries and finds neither; and rm, which is not a Windows program. Each now uses the resolved shell, a shell-spelled path passed as a positional argument, or Node's own rmSync.

publish-vsixes.mjs could not start npx on Windows. An npm command there is a .cmd shim, and Node refuses to spawn a batch file without a shell at all — so the publish-contract suite could not distinguish a broken publisher from an unreachable stub. The script now reaches an installed command through the same POSIX shell the workflow step runs under (spawnableCommand), with every argument positional and nothing interpolated into a command line. It is the identity off Windows, so the Linux release path is unchanged.

Duplication. Measured with the CLI over the same tree, with the **/scripts/** exclusion lifted so the numbers mean something (that exclusion is why the repo-wide figure says nothing about this tree): 42 clusters / mass 2154 before, 32 / 1600 after — a 26% reduction — and none of the seven clusters that touched the changed files survive. The zip header writers now share one field-writing routine, the twenty-two proof bodies share one runner per verifier, the two package builders share their staging root and their bundled-LSP staging, expectFail/expectSuccess share one runner, and the stage-a-file idiom that appeared in three files is writeFileAt/copyFileAt. The stamper proof's four copies of its spawn-and-check are one runStamper, and its two copies of the Cargo assertions are one assertCargoVersion. What is left in a changed file is two clusters of mandatory idiom at the noise floor — making a temp directory and naming two children of it, in two unrelated files, and a two-line "nothing leaked, clean up" tail whose copies assert different outcomes with different messages. A helper for either would read worse than the lines it replaced. test-verifiers.mjs was over the 500-line limit before this change and is now 328, with its fixtures in verifier-fixtures.mjs.

New spec sections [DEPLOY-BINARY-FILE-NAME] and [DEPLOY-GATE-PORTABILITY] in docs/specs/deployment.md, cross-referenced from every file that implements them.

Breaking changes

None. No CLI flag, report field, or existing spec ID changed. publish-vsixes.mjs spawns through a shell on Windows only; on the Linux runners the release actually uses, the argv is byte-identical to before.

How Do The Automated Tests Prove It Works?

scripts/deployment/zip.test.mjs is new and exists to defeat the obvious way this change could be wrong — a reader and a writer that agree with each other and with nothing else. It holds the reader against clients/jetbrains/gradle/wrapper/gradle-wrapper.jar, an archive this repository did not produce: the entries it must carry, a text entry that must start Manifest-Version:, and a deflated, checksummed .class entry whose first four bytes must be CA FE BA BE — which exercises inflate and the CRC check against bytes nothing here wrote. Only then is the writer held against the reader: a staged tree round-trips by name and content, entry names carry no host separator even though the host joined those paths with one, extraction restores the mode the staged file had (stated against the source, not against 0o755, because NTFS cannot record an execute bit and a fixed expectation would be asserting the host), two archives of one tree are byte-identical, and the archive is smaller than its contents. Refusals are asserted too: a non-archive and a truncated archive are both refused naming the missing end-of-central-directory record.

test-verifiers.mjs goes from 13 passing of 34 on Windows to 34, with no assertion relaxed — the fixtures changed, not the expectations. binariesNameTheFileEachTargetPlatformSpells is new and asserts the full expected path for all five published platforms from any host, so deslop.exe is pinned on Linux and deslop on Windows; nothing runs, which is what makes that possible. It replaces a hard-coded darwin-arm64 that left the .exe branch unexercised on every machine that has ever run this suite. Every proof that does execute a binary now runs against the host platform, so binariesAcceptValidContract and vsixAcceptsValidPackage exercise the win32 naming and staging path positively for the first time.

test-release-publish-contract.mjs goes from 3 passing of 8 to 8, including the five that assert each platform is attempted exactly once when one registry call fails. test-action-diff-gate.mjs goes from 0 of 5 (it died on the missing binary before any check) to 5, and it runs the action's own Run deslop step body verbatim against the branch-built CLI.

The reader was also run against a real artifact, not only fixtures. verify-vsix-package.mjs was pointed at a 14 MB vsce-produced deslop-live-win32-x64.vsix: it read all 16 entries, checked them against the shipping allow-list, extracted and executed all three bundled .exe binaries for their version contract, and scanned the packaged assets for stub-provider strings — exit 0. assert-vsix-schema-doc.mjs matched the packaged schema_doc.md byte-for-byte against docs/specs/REPORTING-CONTEXT.md from the same archive. Neither had ever run on Windows before.

The reader also refuses rather than guesses: an entry whose 32-bit size or offset carries the Zip64 sentinel is rejected by name, proved by stamping that sentinel onto a real catalogue entry and asserting the read fails. Handing back a wrong length there is the one corruption a checksum cannot catch, because the checksum would be computed over the wrong bytes.

Full local Windows run of the deployment sequence — all ten gates in make deployment-verify order — exits 0. Every repository contract test passes: 87 across the eleven node --test suites, plus the four contract runners. make lint (clippy -D warnings plus every Node gate, now including the zip gate), make fmt CHECK=1, and make dup-gate all exit 0.

CI could not previously catch any of this. The Windows job ran Rust only. It now runs the host-shell gate, the zip gate, the PATH scrub, the installer snippet, the verifier proofs and the publish contract, ahead of the instrumented build so they fail fast.

For AI

openArchive(path) reads the central directory once and returns {names, read, readText, extract}; writeArchive(archivePath, sourceRoot, entryRoot) walks sorted and emits directory records the way zip -rq did, so the VSIX allow-list sees the same entry shape it saw before. External attributes carry the Unix type bits plus 0o777 in the high half — note that OR-ing into a shifted value yields a signed int32 in JS and must be made unsigned on the outside, not the inside, which is the one bug that bit during development. describeEntry writes the five fields both headers share; the central directory's copy sits exactly two bytes later because it carries a versionMadeBy the local header does not, which is why the two writers were a detected clone.

fake-binary.rs finds its payload with rposition, not position: the marker is also a string constant in the compiled image, so the first match is the program's own rodata and the appended copy is always last. Built -O -C strip=symbols because each fixture is copied into a zip and deflated several times.

spawnableCommand(name, args) returns [file, argv] — the bare name and its arguments off win32, and on it the resolved POSIX shell running exec "$@" with the command and its arguments supplied positionally. $0 takes the name so $@ is the command plus its arguments; nothing is interpolated into script text. The same positional-argument technique prepends the CLI's directory to PATH inside the action step's shell, because a Windows directory handed in through env carries the character PATH separates on.

clients/vscode/scripts/* now imports ../../../scripts/lib/zip.mjs and ../../../scripts/release/vsix-platforms.mjs. Safe: .vscodeignore excludes scripts/**, so none of it is packaged, and both are dependency-free ESM run by node directly.

gh #500. No unwrap/expect/panic! added, no suppressions, no file over 500 lines, no function over 20.

🤖 Generated with Claude Code

MelbourneDeveloper and others added 6 commits September 4, 2026 07:51
…ates a shell they can spawn

[OUTPUT-SCHEMA-PATH-SEPARATOR] One JSON report said two different things about the same file: metrics.folders[].path read src/billing while per_file[].path and every occurrence path read the host spelling. A report is read somewhere other than the machine that wrote it, so a consumer matching paths across two platforms got different strings for one file. Every workspace-relative path a report publishes is now spelled with a forward slash on every platform. Only separators change: a Windows volume keeps its own spelling because it names a device rather than a segment, nothing is canonicalised, and no case is folded, so a spelled path still names exactly the file it named and stays a usable map key beside paths that were never spelled. deslop-core::paths::reported is the one implementation; report_render::relative_to_scan_root and the diff verifier's resolve_to_scan_root are its only callers, so every published path is built through one of them.

[DEPLOY-EXTENSION-BUNDLED-TESTS] The gates that drive Deslop's published POSIX scripts spawned /bin/bash, which is not a path Windows can execute — it is an MSYS name that exists only once bash is already running. Every spawn returned a null status and empty output, so assertions written against what the script printed were comparing two empty strings and passing. scripts/lib/posix-shell.mjs now answers the three questions once: which shell to spawn, how that shell spells a path, and how this host spells a path the shell named. The Windows location is read from the Makefile's own GIT_BASH rather than copied, so one override moves both. makefile.mjs gains variableValue, because variableWords splits on whitespace and the two words it returns for a path with spaces in it are neither of them a shell.

[DEPLOY-DOCS-INSTALLER-FAILCLOSED] The installer gate read the platform from the host and refused to run on anything but the four it publishes for, so it tested one platform per machine. uname is stubbed now and the test chooses: all four published platforms are exercised everywhere, and the snippet's own unsupported-platform refusal — a branch nothing asserted anywhere — is exercised too. Fixture archives are built through the same shell that extracts them, because GNU tar reads a leading drive letter as a remote host and refuses the archive outright.

[LSP-CAPABILITIES] One navigation unit test asserted that /repo/A.cs is absolute. It is not on Windows, where a path with no volume names a location on whatever drive the caller is on; the subject now comes from the crate directory, which is absolute everywhere and fixed for a checkout.

The Windows CI job ran one target, so none of this was under test on the only platform that can break it. It now also runs deslop-core's paths:: unit tests and the seven suites whose assertions compare a rendered path against the published spelling, the two report goldens among them.

Still failing on Windows and deliberately left: five deployment-verify scripts shell to zip and unzip, which Windows does not ship, and write an extensionless shell script where the verifier correctly looks for a .exe. Both would need production release-verification code changed, which is not Windows-specific. That pipeline runs on ubuntu-latest.

No CLI flag, spec ID or report field changes. The values of path fields rendered on Windows change, which is the defect. gh #500.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A plain YAML scalar cannot carry a colon followed by a space, and the
filter list is made of module paths that end in `::`. Every run failed
before a single job started.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nt this platform binds

[DEPLOY-EXTENSION-BUNDLED-TESTS] The host-shell contract test asserted that
GIT_BASH is absolute using the host's own path flavour. `C:/Program
Files/Git/usr/bin/bash.exe` is not absolute to a POSIX `isAbsolute`, so the
gate failed on Linux — the same class of mistake the helper exists to prevent,
made inside its own test. The Windows value is now judged as a Windows path
and the POSIX one as a POSIX path, and the identity direction is asserted with
an explicit host so it is checked on every machine rather than only the one
that happens to be POSIX.

[LIVE-IPC-SOCKET] [LIVE-IPC-TCP] The MCP end-to-end suite waited for
`.deslop/cache/deslop.sock` before talking to the companion LSP. Windows has
no Unix-domain sockets and publishes a TCP endpoint record instead, so all
ninety-four tests sat out a thirty-second timeout against a server that had
bound correctly seconds earlier. transport::endpoint_path names whichever
artefact this platform's server publishes, and the three waits now ask it
rather than spelling one of the two, so a test cannot disagree with the engine
about which transport is in use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The job now builds the deslop E2E suite under llvm-cov instrumentation on top
of the LSP binary and the MCP transport target. A cold Windows build of that
set runs past the old thirty-minute cap before a single assertion has been
evaluated. The alternative is dropping the path-spelling guards, which would
leave the one rule only this platform can break unenforced — which is what the
cap would be buying.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…te a binary it can start

[DEPLOY-GATE-PORTABILITY] Five deployment gates could not run on Windows, for two reasons that were both about the gate rather than the thing it guards. The package verifiers shelled out to Info-ZIP `unzip`, which Windows does not ship, so on the one platform a `win32-x64` VSIX is built for they aborted on a missing program before checking anything — indistinguishable from having no gate. And the proof suite staged `#!/bin/sh` scripts as stand-ins for shipped binaries; Windows has no shebang and will not start a text file, so every proof spawned nothing, read empty output, and compared it against an expectation that was also empty. That half passed. A green gate over an unexercised verifier is the worse of the two failures.

scripts/lib/zip.mjs reads and writes the archive format we ship, in process. A VSIX and a JetBrains plugin are both zips; the reader verifies every entry's checksum as it decompresses it, and the writer archives children in sorted order so one tree always produces the same bytes. verify-jetbrains-package.mjs, verify-vsix-package.mjs and assert-vsix-schema-doc.mjs read through it. Its own contract test holds the reader against the checked-in Gradle wrapper jar — an archive this repository did not write — before the writer is ever held against the reader, so the two cannot agree with each other and with nothing else.

scripts/lib/fake-binary.rs is compiled once per run and copied per fixture, each copy carrying its own two answers appended after a marker. `rustc` is the one compiler this repository is guaranteed to have. The answers ride inside the image rather than in a file beside it because a second file staged next to a binary is an undeclared package entry, which the verifiers are right to reject.

[DEPLOY-BINARY-FILE-NAME] The rule that a component's file is `binaryName` plus `.exe` on Windows was written out in four places and left out of a fifth, where the action diff-gate proof looked for `target/release/deslop` and reported the CLI missing on the platform that has it. `executableName` in vsix-platforms.mjs is now the only implementation, and `currentPlatform` replaces three more copies of the host-platform chain.

Three more gates were reaching for things Windows resolves differently: `bash` by name, which finds WSL's copy first and sees a different filesystem; a POSIX `PATH` assembled by hand around a drive letter; and `rm`. The publish contract's stubs were shell scripts that `publish-vsixes.mjs` could not spawn from Node — and Node refuses to start a `.cmd` shim at all, so that script now reaches an installed command through the same POSIX shell the workflow step runs under, with every argument positional and nothing interpolated into a command line.

The proofs run against the platform this host can execute, so the `.exe` naming rule is under test on Windows and the Unix one on Linux; the naming rule itself is proved for all five published platforms from any host, because that proof needs to run nothing. It replaces a hard-coded `darwin-arm64` that left the `.exe` branch unexercised on every machine that has ever run this suite.

Duplication measured with the CLI over the same tree: 42 clusters / mass 2154 before, 35 / 1852 after, and none of the seven clusters that touched the changed files remain. The zip header writers, the proof scaffolding, the fixture builders, the two verifier assertions and the stage-a-file idiom were each collapsed onto one implementation.

The Windows CI job now runs these gates. It ran none of them before, so the gates guarding a Windows release were only ever exercised on machines that cannot produce one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread scripts/lib/zip.mjs Fixed
MelbourneDeveloper and others added 3 commits September 4, 2026 11:33
… from the environment

Three findings from code scanning, all in this branch's own new code, fixed at the cause rather than dismissed.

The archive writer asked the filesystem about a path twice — once to learn what it was and once to read it — so the bytes it stored were not provably the bytes it measured. One open descriptor now answers both questions and is closed in a `finally`.

The action diff-gate proof handed the built CLI's directory to the step's shell as a positional argument. It travels in the environment instead, under a named variable the script composes PATH from, which is where it lived before this branch touched the file and is the form that provably raises no alert. The reason it could not simply be `env.PATH` is unchanged and still written down: a Windows directory carries the character PATH separates on.

Two more corrections found while reviewing the diff:

`makefile.mjs` read the Makefile at import. `publish-vsixes.mjs` now imports it transitively, for one variable's value on one platform, so a release script had acquired a hard dependency on a file it never asks about. The read happens on first use.

The archive reader accepted an entry whose 32-bit size or offset carries the Zip64 sentinel, and would have handed back a wrong length as though it were right — the one failure a checksum cannot catch, because the checksum is computed over the wrong bytes. It is refused by name, and a test stamps the sentinel onto a real catalogue entry to prove it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`runActionStep` was already long and this branch made it longer, so it now composes the step's environment and reads the published exit code through named helpers, and the subtree size it scans at has a name instead of being a bare "30" in the middle of an env block.

Two neighbours in the same files were one and two lines over: the installer gate's `uname` stub is lifted out with the comment explaining why it refuses every flag it was not given, and the stamper proof's per-document assertions are lifted out of their loop.

Still over, and untouched by this branch, so left alone rather than folded in: the two typediagram postprocessors, the corpus fetcher, `action-read-outputs`, and two release-workflow contract cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hrough another

The three largest remaining clusters in the scripts tree were all in the stamper proof. Four tests each spelled out the same spawn-and-check to run the stamper, and two spelled out the same three Cargo assertions against different roots and different expected versions. Both are named once now; the copy of the inputs stays at each call site, because the generated-manifest test has to stage a file between copying and stamping and that ordering is the thing it proves.

Measured over the scripts tree with the CLI, exclusions lifted: 42 clusters / mass 2154 at the start of this branch, 32 / 1600 now. Nothing remains in any file this branch created.

The two clusters left that touch a changed file are mandatory idiom at the noise floor — making a temp directory and naming two children of it, in two unrelated files, and a two-line "nothing leaked, clean up" tail whose two copies assert different outcomes with different messages. A helper for either would read worse than the lines it replaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@MelbourneDeveloper
MelbourneDeveloper merged commit 54d06fb into main Sep 4, 2026
16 checks passed
@MelbourneDeveloper
MelbourneDeveloper deleted the windows-path-spelling branch September 4, 2026 02:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants