Skip to content

test: walk the full lifecycle, pin golden output, and run README examples - #50

Merged
steve-calvert-glean merged 25 commits into
mainfrom
test/lifecycle-journeys
Oct 22, 2026
Merged

steve-calvert-glean merged 25 commits into
mainfrom
test/lifecycle-journeys

Conversation

@steve-calvert-glean

@steve-calvert-glean steve-calvert-glean commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The suite tested components, not journeys. No test took the current authoring shape (shared sources, target overrides, partials, repository files) through edit, delete and rebuild across all six targets. Nothing pinned emitted output, so an unannounced output change such as the one in #37 passed CI. The packed-install check ran only --version and init. Most README examples were never executed.

Change

  • tests/lifecycle-journeys.test.ts runs all six targets through build, edit, delete, add, rebuild, prune and clean, in two layouts. After each step, the project tree must equal the sources, the hand-written files and the managed files, byte for byte, and every target must validate. It covers an agent-plugins package for codex and copilot, and the codex to agent-plugins handoff in both directions.
  • tests/golden.test.ts pins full output (paths and bytes, including .pluginpack/<target>.json) for every target. Run npm run test:golden:update to accept a deliberate change. That script refuses to run in CI.
  • scripts/verify-packed-install.mjs runs init, build, validate, delete a skill, build and clean against the installed tarball, for every target init scaffolds.
  • tests/readme-examples.test.ts runs the CLI reference examples and the config and shell examples. Every fenced block must be listed in an inventory as run or not runnable.

Merge notes

This PR stacks on #45 and should merge last. After the other 0.12 PRs merge:

  1. Rebase onto main.
  2. Run npm run test:golden:update and review the diff.
  3. Add two inventory entries for the corrected install-command blocks from fix(targets): enforce native layouts and correct conformance evidence #49 (Claude Code > bash #1 and Antigravity > bash #1, both "not runnable: host CLI commands; install-snippet.test.ts checks install-info").

With every branch merged locally, npm run check passes: 499 tests, 3 skipped.

Testing

npm run check passes on this branch. Each journey and golden test was confirmed to fail when its guarded behavior is removed.

walkFiles treated ENOTDIR as "directory missing", so a source plugin whose
skills (or any component) path was a regular file built successfully and
shipped nothing from it. It now throws when the directory itself exists as
a file, naming the path. A path below a file is still read as missing.
source.partials was a bare string, so "../outside" or "/etc" was accepted
and its files were inlined into shipped skill files. Partials end up in
published output, so the field now uses the same safe-relative-path check
as the other source paths and is rejected at config load.
The delete guard, the cross-target collision checks and clean's manifest
check all compared path.resolve strings. An alias walked past them: an
outDir symlinked into a source plugin let build and clean delete authored
skills, and "out" and "OUT" on a case-insensitive filesystem let two
targets overwrite each other's files and clean delete the other target's
live output. These checks now compare physical keys: the real path of the
nearest existing ancestor, folded for case and Unicode normalization when
that volume is case-insensitive. loadConfig also rejects an outDir that
lands inside any source, overrides, partials, repositoryFiles or rootFiles
path, so the alias case fails before anything is written. An outDir that is
only a symlink to somewhere safe keeps working.

The guard no longer guesses source plugins from component directories under
the default plugins/ root. That guess read a generated package such as
plugins/acme/skills/ as source and blocked its own prune whenever outDir was
the repo root. It now protects what the config reads: an explicit
source.plugins, the legacy plugins a target reads through "from", and any
directory with a plugin.pluginpack.json. rootFiles sources and each outDir's
.git are protected too, and a rootFiles entry that copies a file onto itself
is rejected.

build and prune now share one plan that runs every check before any write.
That plan includes the delete guard over stale files, so --dry-run fails
wherever the real command would, and a refused prune no longer leaves a
half-written build. A stale path that another target produces in the same
build, or that another configured target's manifest claims, has changed
owner. It is left in place instead of being deleted from under the new
owner.

The managed manifest moves to version 2. It records a sha256 for each file.
build, prune and clean warn before overwriting or deleting a managed file
someone edited, and still go ahead. Version 1 manifests are read and
upgraded on the next build. A manifest from a newer pluginpack gets its own
error saying to upgrade, distinct from a corrupt one. Manifest writes go
through the same containment check as artifact writes, so a symlinked
.pluginpack directory cannot redirect them. A failed removal now names the
manifest and the recovery step.
The config schema checked path fields with the same helper that marketplace entry sources use, and that helper accepts any value starting with http:// or https://. Every schema field it guards is a filesystem path, though, and path.resolve reads "https://../../outside" as "../outside". So source.partials, a plugin source, or overrides could still read files from outside the project and ship them. Schema path fields now use a filesystem-only check that gives URLs no exemption.
loadProjectConfig imported the config through jiti with its default module cache. A second build() in the same process after pluginpack.config.ts changed on disk built the first config again. The CLI starts a new process per command, but a watcher or test harness calling the programmatic API silently built stale config. The loader now disables the module cache.
…uild

physicalKey decided whether to fold case by looking up the case-swapped name of the deepest existing path component in its parent directory. For an outDir on a case-sensitive volume mounted under a case-insensitive parent, that component is the mount point, and its parent answers for the wrong filesystem. Distinct out and OUT directories then got one key and a valid build was refused. The probe also compared inodes, so two hard links whose names differ only by case made a case-sensitive directory look case-insensitive.

The probe now asks the volume that holds the nearest existing directory. It looks up names inside that directory, or inside an ancestor on the same device. A directory that lists both spellings is case-sensitive, so hard links no longer fool it. An empty volume gets a short-lived, uniquely named scratch file instead. The platform default remains the last resort.

Build and prune plans now run inside a per-plan cache of real paths, nearest directories and per-volume answers. The physical checks had tripled build time on a 3,001-file package. That build now takes about as long as before them (about 1.0s fresh, 0.7s rebuild).
The cross-target collision check keyed each output path with only its parent directory resolved. That is the right key for deletion, since unlinking a symlink removes the link. A write follows the link, though. A generated SKILL.md that was a symlink to another target's file let both targets build green, and the first target's skill shipped the second target's content. Write collisions now compare fully resolved paths, and ownership of stale paths still compares entries.
The checks that stop prune and clean from deleting through a symlink out of the output directory ran only inside the delete itself. A dry run skipped them and succeeded where the real command refused. A build wrote its new output and then refused at prune time. clean deleted one target's files and then refused for the next. The containment checks now run in the shared preflight for every stale or managed path, on dry runs too. clean now plans every target before deleting anything.

A managed path below a directory someone replaced with a file made clean throw a raw ENOTDIR after it had already deleted other files. That managed file cannot exist, so it now counts as already gone, and the file in its way stays. Any other failure to inspect a managed path, such as EACCES on a locked directory, now names the managed manifest, says nothing was deleted, and gives the way out.
…their edits

When a path moves from one target to another in one build, the donor's new manifest drops it and the recipient's new manifest adds it. build wrote each target's manifest right after that target's prune. If the recipient's prune failed after the donor's manifest was written, neither manifest listed the path, and a later clean left the file behind. build now first writes every target's manifest with its new paths and its old ones, then prunes each target and writes its final manifest. A handed-over path is then always listed by at least one manifest.

The hand-edit warning compared a file only against the overwriting target's own previous manifest. A handed-over file is new to that manifest, so an edit to it vanished without a warning. The plan now carries the hash the previous owner recorded, and the warning names the target it came from.
…te them

The delete guard stopped treating every plugins/<name> directory with a component directory as source, because that guess blocked a generated package from pruning itself under outDir ".". It went too far: an authored legacy plugin that no target reads through "from" lost all protection. A managed manifest naming one of its files then let clean delete authored source.

The guard now protects those directories again, unless pluginpack provably generated them. Proof means every file in the directory is listed, with a recorded hash, in a configured target's managed manifest. A version 1 manifest records paths but not what wrote them, so it does not count. A directory holding any file no manifest lists stays protected too.
build, prune and clean read only the managed manifests of targets the current config lists. A config that dropped a target, or moved an outDir from one target to another (codex to agent-plugins, say), without cleaning first left the old target's files and its .pluginpack manifest in place forever. Some of those files, like a codex hooks file inside an agent-plugins package, made the new output invalid.

build and prune now look for manifests in every configured outDir that belong to no target building there, and refuse with the manifest path and the command to run. Pruning those files automatically would delete them on the authority of a manifest alone. No config would describe that output any more, so a target dropped by mistake, or a second project sharing the output repository, would lose its files silently. clean --target <old> now finds the old target's manifest in any configured outDir, even when that target is no longer configured. A plain clean includes those manifests too, and both run them through the same guard and collision checks.
On a case-insensitive volume, changing a plugin path from first to FIRST writes the new files into the existing first/ directory. The old spellings then count as stale, and prune deleted them. Those are the files the build had just written, so the build ended with no output for that plugin. A stale path whose physical key matches one of the target's own current paths is now kept, as a path another target owns already was.
…er upgrade

A version 1 manifest records paths but no hashes, so the delete guard treated every plugins/<name> directory it named as authored legacy source. For codex with outDir ".", the generated package lives at plugins/<name>, and the codex format moved its manifest from .codex-plugin/plugin.json to the package root after 0.11. The first build after upgrading therefore had to prune a stale path inside a protected directory, refused, and never rewrote the manifest as version 2. Build, prune and clean all stayed refused without --force.

A file listed without a hash now also counts as written by pluginpack when it sits inside a plugin directory that the same target's config places its output in. That is config-derived, so clean still works when the source tree no longer loads. A directory a version 1 manifest names but no target writes to stays protected, as does any directory holding a file no manifest lists.
…cycle

Component tests check each piece of a build, but no test ran the shape the README recommends (shared sources, overrides, partials and repository files) through build, edit, delete, add, rebuild, prune and clean. These journeys do, for all six targets at once, in two layouts: one outDir per target and the README recommended shape. After every step the whole project tree must equal the authored sources, the hand-written output files and each target's managed files, so a step that touches a source, deletes a hand-written file or leaves a stale file fails.

They also cover the agent-plugins package for both the codex and copilot clients, and moving a plugin between the codex target and agent-plugins with clients.codex in one outDir, in both directions. The documented move (clean the old target first) passes. Two known gaps are kept as expected failures: switching the config without cleaning leaves the old target's manifest and files behind, and build() reuses a cached config module after the config file changes in the same process.
Emitted output is a compatibility surface, but nothing stopped a renamed path, a reordered manifest field or a changed byte from shipping unannounced. The golden test builds two fixed configs (all six targets, plus legacy codex and standalone agent-plugins) and compares every emitted file, including the serialized .pluginpack/<target>.json, against tests/golden. An intended change is regenerated with npm run test:golden:update and reviewed in the diff; the update refuses to run in CI. Prettier skips tests/golden so the files stay exactly as pluginpack writes them.
The packed-install check stopped after init, right before the steps that have broken for real users. It now adds a skill, builds, validates every target, deletes the skill, rebuilds and validates again, then cleans, using the binary installed from the packed tarball. It fails if the rebuild leaves the deleted skill behind, if clean leaves managed files or removes a hand-written one, or if any step changes an authored file.
markdown-code keeps four README blocks in sync with snippets/ but runs none of them, which is how a documented command that fails can ship. This test runs the CLI Reference examples, the Install Snippet and CI Change Detection commands, the Components and multi-client configs, the MCP Directory shipping map and the Programmatic API import list with the built CLI against a project that imports the local package. Every bash, sh, ts and json block must be listed in the test as run, run elsewhere or not runnable with a reason, so a new block fails until someone decides. The Quick Start is left to its own materialization test.
The base branch now loads the config without the module cache and refuses to build or prune while an outDir holds a manifest for a target no longer built there. The journey tests that expected those bugs now assert the fixed behavior.

Switching an outDir between codex and agent-plugins without cleaning must make build and prune refuse, name the stale manifest and the clean command, and leave the tree untouched. Running that clean for the old target, which is no longer configured, must then let the new build finish with no stale files. The handoff tests now rewrite pluginpack.config.ts in place, as a user would, instead of keeping one config file per side to dodge the cache.
An artifact file value may be a plain Uint8Array, not only a Buffer, once the public FileValue type widens. Uint8Array#toString takes no encoding, so the journey decodes through Buffer.from, which works for both.
Read the targets from the first build's output instead of a fixed list, so the packed-install walk keeps covering every target when init gains one.
@steve-calvert-glean steve-calvert-glean added the internal Internal refactoring, cleanup, or maintenance work label Oct 21, 2026
Base automatically changed from fix/physical-path-safety to main October 21, 2026 23:56
# ------------------------ >8 ------------------------
# Do not modify or remove the line above.
# Everything below it will be ignored.
#
# Conflicts:
#	README.md
#	src/config.ts
The conformance change replaced the Claude Code and Antigravity install examples with host CLI commands. install-snippet.test.ts checks install-info output for both.
@steve-calvert-glean
steve-calvert-glean marked this pull request as ready for review October 22, 2026 00:04
@steve-calvert-glean
steve-calvert-glean merged commit e1fde5d into main Oct 22, 2026
@steve-calvert-glean
steve-calvert-glean deleted the test/lifecycle-journeys branch October 22, 2026 00:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal Internal refactoring, cleanup, or maintenance work

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant