Repository navigation
fix(parser): reconcile docker reference splitting and skip npm subcommands (narrows #180) - #184
Conversation
…sions Closes-plan-for: #180 Planning artifact only; no source changes. Under board review at time of commit, so the plan may be revised before execution. Research found two root causes rather than one, and a third collision the issue does not record: docker run registry:5000/old-image -> ('docker', 'registry') docker run registry:5000/new-image -> ('docker', 'registry') docker run localhost:5000/a/b:tag -> ('docker', 'localhost') npm exec old-pkg -> ('npm', 'exec') npm exec new-pkg -> ('npm', 'exec') Docker splits on the FIRST colon, but an OCI reference only carries a tag when the colon follows the last slash -- before that it is a registry host:port. npm has no subcommand skip at all, which is an asymmetry: _docker_image_arg already skips its own subcommands at version_checker.py:365. The plan's flagged hazard is _npm_package_arg. It is deliberately SHARED between detect_package_type and update_server's pin detection (handlers.py:307), with a docstring stating the two must never disagree about which argument is "the package", so a signature change must update both call sites in one commit. Scope deliberately excludes uvx --from (already correct) and the pip/cargo branches (no measured collision). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
Correctness seat returned PARTIALLY AGREE with three must-fixes. All verified against source before acting; all three hold. The docker rule I specified was wrong, and the right one already exists. "Split on the last colon when it follows the last slash" regresses img:1.2@sha256:abc from img (correct today) to img:1.2@sha256, because that last colon belongs to the digest. Worse, it would have been a SECOND home for a rule _docker_image_tag (version_checker.py:377) already implements correctly -- last segment, first colon -- and which update_server's pin detection already uses. The two diverge on the same input: _docker_image_tag gives 1.2@sha256:abc, my rule gave abc. The plan now adds _docker_image_name as _docker_image_tag's paired complement, strips @digest first, and requires paired assertions so they cannot drift. npm skip semantics were ambiguous. The draft said both "leading" and "the same way the docker path does"; those contradict, and docker skips subcommands ANYWHERE, which would swallow real packages -- npm install i (a genuine package) and npm exec exec both resolve to None under that reading. Now specified skip-once, leading-only, with pinning tests. The pin-detection call site is a behaviour FIX, not a mechanical update. The draft claimed no behavioural change; in fact npm exec pkg@1.2 currently pin-detects on "exec", so _npm_tag returns None and a real pin is MISSED. After the change it is found. Needs its own RED test and a CHANGELOG line. Scope claim corrected and a new issue filed. The draft asserted the pip and cargo branches "are not implicated" and uvx was fine. False: every excluded branch collides through unskipped value-carrying flag values -- uvx --python 3.12 A/B, uvx --with requests A/B, pip --index-url URL A/B, cargo --features full A/B all collapse to one identity. uvx --from is correct only by luck. Filed as #182 rather than folded in; uvx is arguably the most realistic trigger of the whole class. Also: localhost:5000/a/b:tag was filed under "must not move" when it is a FIX case, which an executor reading literally could have enshrined. And the mutation proofs must now have their output recorded in the handoff, not merely performed -- both prior hollow tests were caught only by a board, and one was written by the agent that had just documented the failure mode. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
…mands Narrows #180. Does NOT close it -- see below. Docker: detect_package_type took the FIRST colon (raw.split(":")[0]), so registry:5000/img read as image "registry" and two different images on one host:port registry confirmed as the same package under 2.4.0's identity gate. The correct rule already existed in this file as _docker_image_tag, so the fix adds its paired complement _docker_image_name rather than a second, divergent rule -- the same design as _strip_npm_tag/_npm_tag. Digest handling is why the pairing matters. An earlier draft split on the LAST colon after the last slash, which regressed img:1.2@sha256:abc from img to img:1.2@sha256, because that colon belongs to the digest. Both helpers now strip @digest first, then split the last path segment on its first colon. That also fixed a live defect in _docker_image_tag: img@sha256:abc reported a tag of "abc", so update_server's pin detection reported a DIGEST FRAGMENT as a version pin, and img:1.2@sha256:abc reported 1.2@sha256:abc instead of 1.2. npm: _npm_package_arg had no subcommand skip, so npm exec old-pkg resolved to "exec" -- identical for every package. It now skips ONE leading subcommand, for npm only. Once, not repeatedly, because i and exec are real package names and a docker-style anywhere-loop turns npm install i and npm exec exec into None. npm only, because npx -y exec genuinely names a package called exec. The x alias is included; without it npm x collides exactly as npm exec did. command is now a REQUIRED parameter, not defaulted: a default would let one of the two shared call sites keep old behaviour silently while type-checking clean, which is the drift this shared scan exists to prevent. The pin- detection call site is updated in the same commit, and that is a behaviour fix in its own right -- npm exec pkg@1.2 scanned to "exec", whose _npm_tag is None, so a real pin was reported as unpinned. Board found two things this does NOT fix, both filed: #182 (docker's _value_flags table is incomplete, so --env-file/--mount still collide, and npm exec --package= is discarded) and #183 (update_server builds npx -y <parsed>@latest --help, so a misparsed npm run <script> installs and executes a registry package). Tests and CHANGELOG follow in the next commit. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
…oofs Tests and CHANGELOG for the parser fix in a906c85. Three new classes in test_version_checker.py. TestPackageIdentityCollisions asserts INEQUALITY of real pairs rather than single expected values -- a value assertion passes against a parser that collapses both forms onto some other shared name, so only the inequality is falsified by the collision itself. TestDockerReferenceSplitting parametrises name/tag together so the two helpers cannot drift apart. TestNpmSubcommandSkipFiresOnce pins that npm install i and npm exec exec still find their real packages and npx never skips. The pin matrix in test_tools.py gains the docker digest forms and the npm exec/x pin cases. Six mutants, each run against ONE test node alone, never the class -- a class-level run reddens on any member and so cannot show the specific test is load-bearing, which is the hole two hollow tests shipped through in the last two phases. Evidence recorded in the handoff: docker branch -> raw.split(":")[0] kills the docker collision test skip_subcommand = False kills both npm collision tests skip fires repeatedly kills both real-package-name tests digest not stripped first kills ONLY the 2 digest cases pin call site -> _npm_package_arg(args,"npx") kills the pin matrix _docker_image_tag stops stripping digest kills the pin matrix The last two are what pin the helpers as complements: each half has its own mutant, so a divergence cannot pass silently. CHANGELOG says plainly that this NARROWS #180 rather than closing it, names the forms that still collide, and records the one-time refresh affected servers will do -- the same migration note 2.4.0 used for package_type. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
Cosmetic line-joining in TestPackageIdentityCollisions; no assertion changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
…g mutants
Second board round on the implementation. grok DISAGREE, codex DISAGREE,
gemini AGREE, fable PARTIALLY AGREE. Every finding verified against source
before acting; all hold.
REGRESSION THIS PR INTRODUCED, caught by the red-team seat and confirmed by
the correctness seat. Stripping @digest before the tag read was right for the
NAME but broke the PIN: a digest-only reference then reported None, which
reads as UNPINNED. Before this PR the same reference produced the garbage pin
'abc' -- wrong, but truthy, so update_server's refusal still fired. I turned
wrong-but-safe into right-looking-but-unsafe: update_server would have pulled
image:latest, restarted the unchanged digest-pinned config, and recorded the
registry's newest digest while still running the old immutable image.
Fixed with _docker_image_digest, the third member of the reference-splitting
family, consulted BEFORE the tag. A digest is the tightest pin docker offers.
img:latest@sha256:abc is the same trap wearing a tag the == "latest" test
would otherwise discard; all eight docker pin forms now verified.
SURVIVING MUTANT A (grok and fable, independently): swapping the flag skip
and the subcommand check passed all 2717 tests. The code was already correct
-- npm --silent exec old-pkg resolves to old-pkg -- but nothing PINNED the
ordering, because every npm test put the subcommand first. Correct by
accident is not correct by contract. Pinned now.
SURVIVING MUTANT B (fable, unique): the guard I wrote against a hardcoded
call site was itself HOLLOW. detect("npm","npx",["-y","exec@1.2"]) == "1.2"
cannot enforce "npx must not skip a package named exec", because the skip
matches the RAW token against _NPM_SUBCOMMANDS and exec@1.2 is not in the
set -- so the mutant returns the identical 1.2. The bare exec token
discriminates: correct gives None, mutant gives 1.2. That is the third hollow
test in three phases, and the second written by the agent that had just
documented the failure mode.
TOOLING: stale bytecode fabricated a false RED in the full-suite run (a
pure-reorder mutant has identical size, and apply/compile/restore inside one
second defeats pyc invalidation). The same mechanism can fabricate a false
GREEN, i.e. certify an unkilled mutant. All nine proofs re-confirmed with
__pycache__ purged and PYTHONDONTWRITEBYTECODE=1, and mutation-180.md now
requires it.
Mutation evidence moved into the repo at .consiliency/evidence/mutation-180.md
-- the plan required the output recorded, and scratch is not the repo.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
Second board round on the implementation — all four seats, four findings, all closed in
|
| Seat | Verdict |
|---|---|
| grok-4.6 (adversarial) | DISAGREE — surviving mutant A |
| gpt-5.6-sol (red-team) | DISAGREE — digest-pin regression |
| gemini-3.7-flash (alternative-approach) | AGREE |
| claude-fable-5 (correctness) | PARTIALLY AGREE — confirmed A, found B |
1. A regression this PR introduced (red-team, confirmed by correctness)
Stripping @digest before the tag read was correct for the name and broke the pin:
BEFORE img@sha256:abc -> pin 'abc' (garbage, but truthy → refusal fired)
AFTER img@sha256:abc -> pin None (reads as UNPINNED)
A digest is the tightest pin docker offers. With None, update_server would pull image:latest, restart the unchanged digest-pinned configuration, and record the registry's newest digest — announcing an update while still running the old immutable image. I converted wrong-but-safe into right-looking-but-unsafe, which is strictly worse than the bug I was fixing.
Fixed with _docker_image_digest, the third member of the reference-splitting family, consulted before the tag. img:latest@sha256:… is the same trap wearing a tag that the == "latest" test would otherwise discard. All eight docker pin forms verified.
2. Surviving mutant A (grok and fable, independently)
Swapping the flag-skip and subcommand checks passed all 2717 tests. The code was already correct — npm --silent exec old-pkg → old-pkg — but nothing pinned the ordering, because every npm test put the subcommand first. Correct by accident is not correct by contract. Now pinned; the mutant dies.
3. Surviving mutant B (fable, unique) — the guard I wrote was itself hollow
detect("npm","npx",["-y","exec@1.2"]) == "1.2", commented "npx still must not skip a package genuinely named exec", cannot enforce that. The skip matches the raw token against _NPM_SUBCOMMANDS, and exec@1.2 is not in the set — so a call site hardcoded to "npm" returns the identical 1.2:
correct : npx -y exec@1.2 -> '1.2'
mutant : npx -y exec@1.2 -> '1.2' <- no divergence, guard useless
correct : npx -y exec pkg@1.2 -> None
mutant : npx -y exec pkg@1.2 -> '1.2' <- divergence
That is the third hollow test in three phases, and the second written by the agent that had just documented the failure mode. Reading an assertion cannot distinguish a hollow one from a real one; only mutating the exact line it claims to pin can.
4. Tooling: stale bytecode fabricates false red and false green
A full-suite run reported mutant A's new test as FAILING against correct source. Cause: a pure-reorder mutant has identical byte size, and apply/compile/restore inside one second defeats .pyc invalidation (whole-second mtime + size). Purging src/**/__pycache__ → 5 passed.
The same mechanism can fabricate a false green — certifying a mutant as killed when it was not. All nine proofs re-confirmed with __pycache__ purged and PYTHONDONTWRITEBYTECODE=1, and .consiliency/evidence/mutation-180.md now requires it for any future proof in this repo.
Evidence location
Moved from scratch into the repo at .consiliency/evidence/mutation-180.md. The plan required the mutation output recorded; scratch is not the repo, so that acceptance criterion was genuinely unmet until now (red-team finding 2).
Verification
2718 passed, 3 skipped, 0 failed with pycache purged and bytecode disabled; ruff clean; mypy clean. CI green on all ten checks.
…review grok PARTIALLY AGREE, codex DISAGREE, gemini AGREE. Two seats independently found the same blocking defect and both were verified against the workflow. workflow_dispatch as specified was not merely a security widening -- it was INCORRECT. actions/checkout at release.yml:12 and :63 pins no ref, and both tag derivations read GITHUB_REF_NAME (:73, :96). Under workflow_dispatch that is the ref chosen in the "Use workflow from" dropdown, not a workflow input. So dispatching tag=v2.4.1 from main would check out main, publish MAIN's artifacts, and try to create a release named "main" -- the required input silently ignored. Writing an input and never wiring it is the same class of defect as a hollow test: it looks like it constrains something. grok found the worse half: PyPI publish can SUCCEED while the GitHub release step fails, leaving a half-published version. Both mitigations I offered fail on inspection -- trusted publishing scoped to repo+workflow does not restrict to tag events, and environment: release only gates if that environment has required reviewers, which the plan never verified. Recovery already exists and is what unblocked v2.4.1: gh run rerun on the failed tag-push run. The timeouts are themselves the recovery improvement -- a 6-hour silent hang becomes a ~20-minute red X. Also fixed my own fail-open verification command. `command -v actionlint && actionlint ... || echo skipped` swallows a non-zero actionlint into the || branch and exits 0, so any finding prints "skipped" -- the same shape as the hollow assertions caught in #184 and #185. Now an explicit if/else where a non-zero actionlint fails. Both seats confirmed the timeout table: 4x with a 10-minute floor is right, and grok noted tightening the floor to 5 would be the actual false-kill risk for min-version-smoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
* chore(plan): detailed plan for CI job timeouts Closes-plan-for: #187 Planning artifact only; no workflow changes. Under board review at time of commit. Every timeout value is MEASURED, not estimated -- per-job durations pulled from the GitHub jobs API across the last five runs of each workflow, sized at roughly 4x observed p100 with a 10-minute floor. release.yml build is 4m10s observed and gets 20; the test matrix is 4-5m and gets 20; everything else is sub-minute and gets 10. pipeline-bootstrap.yml already sets 30 and is left alone. The plan flags workflow_dispatch on release.yml as the part I am least sure of: it makes a stalled release recoverable without rewriting a published tag, but it also means anyone with write access can trigger a PyPI publish for an arbitrary ref. Written so the board can strike it and keep the timeouts, which are the actual fix for #187. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb * chore(plan): strike workflow_dispatch from the #187 plan after board review grok PARTIALLY AGREE, codex DISAGREE, gemini AGREE. Two seats independently found the same blocking defect and both were verified against the workflow. workflow_dispatch as specified was not merely a security widening -- it was INCORRECT. actions/checkout at release.yml:12 and :63 pins no ref, and both tag derivations read GITHUB_REF_NAME (:73, :96). Under workflow_dispatch that is the ref chosen in the "Use workflow from" dropdown, not a workflow input. So dispatching tag=v2.4.1 from main would check out main, publish MAIN's artifacts, and try to create a release named "main" -- the required input silently ignored. Writing an input and never wiring it is the same class of defect as a hollow test: it looks like it constrains something. grok found the worse half: PyPI publish can SUCCEED while the GitHub release step fails, leaving a half-published version. Both mitigations I offered fail on inspection -- trusted publishing scoped to repo+workflow does not restrict to tag events, and environment: release only gates if that environment has required reviewers, which the plan never verified. Recovery already exists and is what unblocked v2.4.1: gh run rerun on the failed tag-push run. The timeouts are themselves the recovery improvement -- a 6-hour silent hang becomes a ~20-minute red X. Also fixed my own fail-open verification command. `command -v actionlint && actionlint ... || echo skipped` swallows a non-zero actionlint into the || branch and exits 0, so any finding prints "skipped" -- the same shape as the hollow assertions caught in #184 and #185. Now an explicit if/else where a non-zero actionlint fails. Both seats confirmed the timeout table: 4x with a 10-minute floor is right, and grok noted tightening the floor to 5 would be the actual false-kill risk for min-version-smoke. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb * ci: give every job a timeout-minutes (#187) No job in release.yml, test.yml, docker.yml or maintenance.yml set timeout-minutes, so a stalled job ran to GitHub's 6-hour default. On the release path publish and github-release each needs: the job before them, so a single stall silently blocked the whole publish — and because the tag push *is* the publish, a version could sit tagged-but-unshipped with nothing surfacing it. Cutting v2.4.1, build hung 3h11m on "Run tests with coverage" against a healthy commit; cancel + gh run rerun cleared it. Values are ~4x the measured p100 per job (from the Actions jobs API), floored at 10 minutes: the point is to fail fast on a stall, not to police normal variance. docker build gets 20 despite running under a minute because image builds are network-bound and legitimately spiky. test gets 25 rather than 20 because the live p100 for the 3.10 leg is 5.30m, and 4x that is 21.2 — a 20 would have falsified the >=4x rule on day one. release.yml build 20, publish 10, github-release 10 test.yml test 25, install-smoke 10, min-version-smoke 10, lint 10, changelog 10, typecheck 10 docker.yml build 20 maintenance.yml notify-worker 10 Job level, not step level, so the bound covers setup too; on the test matrix it applies per leg. pipeline-bootstrap.yml already sets 30 and is left untouched. workflow_dispatch on release.yml was proposed as a recovery path and is deliberately NOT added. Beyond widening who can publish, the specified form was incorrect: checkout pins no ref and both tag derivations read GITHUB_REF_NAME, so a dispatch with tag=v2.4.1 from main would build and publish main's artifacts and name the release "main" — the input would be silently ignored, and PyPI can succeed while the release step fails. gh run rerun on the tag-push run is the existing recovery, and these timeouts are the real improvement: a 6-hour silent hang becomes a ~25-minute red X. CI-only, no src/ change, so no CHANGELOG.md entry per repo convention. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CbUnAuFarzK5kRz92t8MW8 * fix(ci): raise the test-matrix timeout to 25 and harden the acceptance test Follow-up to a694f87, applying the advisor board's findings on the plan. VALUE CORRECTION. Live test(3.10) p100 is 5.30m measured over five runs, not the 4-5m the plan's table recorded, so the plan's own acceptance criterion ("each value >= 4x observed") was falsified by its own value of 20 on day one. Raised to 25. Every other value stands; two seats independently confirmed the 4x rule and the 10-minute floor, and noted that tightening the floor to 5 would be the real false-kill risk for min-version-smoke. THREE ACCEPTANCE-TEST BYPASSES, found by the board's correctness seat and each reproduced before being acted on. Recorded with before/after exit codes in .consiliency/evidence/bypass-proofs-187.md: A a .yaml workflow escapes a *.yml-only glob exit 0 -> 1 B timeout-minutes: 360 passes a presence check -- exit 0 -> 1 re-creating the very 6-hour default this fixes C an over-indented job is swallowed into the previous job's mapping; jobs parses without it and the check passes anyway exit 0 -> 1 C is the dangerous one. release.yml triggers only on tag push, so it never appears in PR checks -- the plan's claim that "the real proof is CI itself" is false for exactly the file this change is about. A mangled release.yml would have surfaced at the next tag push as a broken release. The hardened check compares job-name sets against main and FAILS rather than printing. The hardened check also validates the value (int, not bool, 1..30) rather than its presence, globs both extensions, and skips `uses:` jobs since a reusable-workflow call cannot carry timeout-minutes. actionlint is now mandatory rather than best-effort: release.yml cannot be exercised pre-merge, so it is the only Actions-schema check on the release path before a tag is cut. The plan's original invocation was fail-open (`&& actionlint || echo skipped` swallows a non-zero exit), the same defect class as B. Verified on this tree: all three bypasses exit 1, actionlint 1.7.12 exit 0, job sets match main, pipeline-bootstrap.yml untouched at 30. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb * docs(evidence): correct the bypass matrix and record that check #2 stands alone Implementation pass re-ran the full matrix against the committed state and found three things the evidence file got wrong or missed. All re-verified here before recording. My instruction said all three bypasses must make CHECK #1 exit non-zero. That is wrong for C: check #1 counts jobs that survive parsing, so a swallowed job cannot make a presence-check fail. C is caught by check #2. The bypass is genuinely closed, just by a different check than I claimed. The finding worth acting on is c3. Deleting a terminal job OUTRIGHT yields a perfectly valid workflow: check #1 exits 0, actionlint exits 0, and only the job-set comparison against main exits 1. actionlint caught c and c2 only because over-indentation is a syntax error; deletion is not. So for release.yml -- tag-push only, never in PR checks -- the job-set comparison is the SOLE guard between a dropped publish job and a silently broken release. That is a stronger argument for its fail-rather-than-print form than this document originally made. Also: my actionlint invocation had the same extension gap as bypass A. `actionlint .github/workflows/*.yml` never schema-checks a .yaml workflow -- verified, exit 0 against a sneaky.yaml with a bogus `with:` key, while bare `actionlint` exits 3. I widened check #1 to both extensions and left the actionlint line globbing. Use bare actionlint. And the `uses:` exemption in check #1 is currently inert -- no job in the five workflows is a reusable-workflow call, so it exempts nothing today. No workflow file changed in this commit; evidence only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb * docs: harden the plan's verification block and record check 0 Implementation board: grok AGREE, gemini AGREE, codex DISAGREE, fable PARTIALLY AGREE. Two live findings, both closed here; the third (CI evidence) was independently confirmed -- 11/11 green, check set identical to a main PR. THE PLAN SHIPPED THE PROVEN-WEAK VERIFICATION. Both dissenting seats found it. This PR's plan still carried check #1 as glob("*.yml") presence-only, check #2 as print-only, and actionlint with the extension gap -- the exact three bypasses the adjacent evidence file proves are exploitable. It would have shipped the weak script as the canonical procedure with the proof of its weakness one file away. Replaced with the hardened forms, plus a note forbidding reintroduction, and the stale table row (test -> 20, "4-5m") corrected to 25 / 5.35m p100. CHECK 0 IS STRONGER THAN 1-3 COMBINED. The correctness seat's contribution: checks 1-3 guard only {job-name set} x {timeout presence and value}, so every other byte of release.yml is unguarded. Seven mutants pass all three -- dropping `environment: release`, rewiring `needs:`, corrupting the tag pattern, deleting the tags filter, and more. The tag-pattern case is #187's own nightmare exactly: corrupt "v*" to "V*" and NOTHING runs, so there is never a red X. Verified that check 0 -- every changed line matches ^\+\s+timeout-minutes: \d+$, nothing removed -- catches all three silent release.yml mutants the others miss. It leads the block now. FLOOR RAISED 1 -> 10. Red-team found that accepting 1..30 leaves build 20 -> 1 passing every check while killing a 4m10s job after a minute. Verified; the floor now matches the plan's own stated sizing rule. Also recorded: none of these checks runs in CI, so a future PR touching release.yml inherits no protection. A persistent guard belongs in its own issue rather than appended untested here. No workflow file changed in this commit; docs and evidence only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Narrows #180. Deliberately does not close it — see What this does not fix.
2.4.0's identity gate decides whether a cached description still describes the configured package by comparing the name
detect_package_typereturns. A name that is stable across two different packages is therefore not a cosmetic parsing wart:_same_packagereads it as a positive confirmation, and the freshness short-circuit goes on serving the wrong package's tool descriptions.Two causes, both closed
Docker split on the first
:— soregistry:5000/old-imageandregistry:5000/new-imageboth resolved toregistry, the registry host rather than an image. A colon only introduces a tag in the final path segment; before the last/it is ahost:port.The correct rule already existed in the same module as
_docker_image_tag, which is what pin detection uses. So the fix adds its paired complement_docker_image_name— same design as_strip_npm_tag/_npm_tag— rather than a second, divergent implementation. My first draft did write a second rule, and the board caught that it both regressedimg:1.2@sha256:abc(→img:1.2@sha256, where today's code correctly givesimg) and disagreed with_docker_image_tagon that same input.npm subcommands were taken as the package —
npm exec old-pkgandnpm exec new-pkgboth resolved toexec. A leading subcommand is now skipped once, and fornpmonly:iandexecare real npm packages — a docker-style anywhere-loop turnsnpm install iandnpm exec execintoNonenpx -y execgenuinely names a package calledexecxis included; without itnpm xcollides exactly asnpm execdid_npm_package_arggains a requiredcommandparameter, not a defaulted one: a default would let one of the two shared call sites keep old behaviour silently while type-checking clean — the exact drift the shared scan exists to prevent.Two live defects found along the way
Neither is in #180; both fell out of the same shared-scan bugs.
img@sha256:abcreported a pin ofabc— a fragment of the digest — andimg:1.2@sha256:abcreported1.2@sha256:abcrather than1.2.npm execserver was reported as unpinned.npm exec pkg@1.2scanned toexec, which carries no version suffix, so a real pin was missed.What this does not fix
My plan asserted that docker's image-token selection was sound and only tag-stripping was broken. The board falsified that. Identity is still collapsed wherever a flag's value is taken as the package name — and these are ordinary forms:
Tracked on #182 (broadened for this), with #183 for the more serious consequence:
update_serverbuildsnpx -y {parsed}@latest --help, andnpx -yinstalls without prompting, so a misparsednpm run <script>installs and executes a registry package.Verification
2717 passed, 3 skipped, 0 failed; ruff clean; mypy clean (42 files).Six mutants, each run against one test node alone — never the class. A class-level run reddens on any member, so it cannot show the specific test is load-bearing; that is the hole two hollow tests shipped through in this repo's last two phases.
raw.split(":")[0]skip_subcommand = False_npm_package_arg(args, "npx")_docker_image_tagstops stripping digestThe last two are what pin the helpers as complements: each half has its own mutant, so a divergence cannot pass silently.
Collision tests assert inequality of real pairs rather than single expected values — a value assertion passes against a parser that collapses both forms onto some other shared name.
Migration
Affected servers refresh once: a docker server on a
host:portregistry or annpm execserver now has a different package identity than its cache recorded, so that entry fails the identity check once and regenerates. Same one-time migration 2.4.0'spackage_typeaddition caused.🤖 Generated with Claude Code
https://claude.ai/code/session_01XbREMUsxgJ8TDBLpJ56PWb
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.