Read the archives we ship without an external tool, and give every deployment gate a binary it can start - #510
Merged
Merged
Conversation
…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>
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.mjsandassert-vsix-schema-doc.mjseach shelled out to Info-ZIP'sunzip. Windows ships neitherzipnorunzip, so on the platform awin32-x64VSIX 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.mjsreads 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/shscripts as stand-ins for shipped binaries. Windows has no shebang andCreateProcesswill 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.rsis compiled once per run —rustcis 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
binaryNameplus.exeon Windows and nothing elsewhere.test-action-diff-gate.mjsdid not carry that rule and looked fortarget/release/deslop, so it reported the CLI missing on the one platform that has it.executableNameinscripts/release/vsix-platforms.mjsis now the only implementation, read by all three verifiers, the fixtures and the action gate;currentPlatformthere replaces three more copies of the host-platform chain, including the extension's ownplatform.mjs. New spec section[DEPLOY-BINARY-FILE-NAME].Three more gates reached for things Windows resolves differently.
bashby name, which finds WSL's copy first and sees a different filesystem than the checkout; a POSIXPATHassembled by hand around a drive letter, which the shell reads as two entries and finds neither; andrm, which is not a Windows program. Each now uses the resolved shell, a shell-spelled path passed as a positional argument, or Node's ownrmSync.publish-vsixes.mjscould not startnpxon Windows. An npm command there is a.cmdshim, 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/expectSuccessshare one runner, and the stage-a-file idiom that appeared in three files iswriteFileAt/copyFileAt. The stamper proof's four copies of its spawn-and-check are onerunStamper, and its two copies of the Cargo assertions are oneassertCargoVersion. 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.mjswas over the 500-line limit before this change and is now 328, with its fixtures inverifier-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.mjsspawns 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.mjsis 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 againstclients/jetbrains/gradle/wrapper/gradle-wrapper.jar, an archive this repository did not produce: the entries it must carry, a text entry that must startManifest-Version:, and a deflated, checksummed.classentry whose first four bytes must beCA 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 against0o755, 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.mjsgoes from 13 passing of 34 on Windows to 34, with no assertion relaxed — the fixtures changed, not the expectations.binariesNameTheFileEachTargetPlatformSpellsis new and asserts the full expected path for all five published platforms from any host, sodeslop.exeis pinned on Linux anddeslopon Windows; nothing runs, which is what makes that possible. It replaces a hard-codeddarwin-arm64that left the.exebranch unexercised on every machine that has ever run this suite. Every proof that does execute a binary now runs against the host platform, sobinariesAcceptValidContractandvsixAcceptsValidPackageexercise the win32 naming and staging path positively for the first time.test-release-publish-contract.mjsgoes 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.mjsgoes from 0 of 5 (it died on the missing binary before any check) to 5, and it runs the action's ownRun deslopstep body verbatim against the branch-built CLI.The reader was also run against a real artifact, not only fixtures.
verify-vsix-package.mjswas pointed at a 14 MBvsce-produceddeslop-live-win32-x64.vsix: it read all 16 entries, checked them against the shipping allow-list, extracted and executed all three bundled.exebinaries for their version contract, and scanned the packaged assets for stub-provider strings — exit 0.assert-vsix-schema-doc.mjsmatched the packagedschema_doc.mdbyte-for-byte againstdocs/specs/REPORTING-CONTEXT.mdfrom 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-verifyorder — exits 0. Every repository contract test passes: 87 across the elevennode --testsuites, plus the four contract runners.make lint(clippy-D warningsplus every Node gate, now including the zip gate),make fmt CHECK=1, andmake dup-gateall 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 wayzip -rqdid, so the VSIX allow-list sees the same entry shape it saw before. External attributes carry the Unix type bits plus0o777in 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.describeEntrywrites the five fields both headers share; the central directory's copy sits exactly two bytes later because it carries aversionMadeBythe local header does not, which is why the two writers were a detected clone.fake-binary.rsfinds its payload withrposition, notposition: 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=symbolsbecause 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 runningexec "$@"with the command and its arguments supplied positionally.$0takes 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 toPATHinside the action step's shell, because a Windows directory handed in throughenvcarries the characterPATHseparates on.clients/vscode/scripts/*now imports../../../scripts/lib/zip.mjsand../../../scripts/release/vsix-platforms.mjs. Safe:.vscodeignoreexcludesscripts/**, so none of it is packaged, and both are dependency-free ESM run bynodedirectly.gh #500. No
unwrap/expect/panic!added, no suppressions, no file over 500 lines, no function over 20.🤖 Generated with Claude Code