diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 56327736a..91d6c84f8 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -8,7 +8,7 @@ "name": "memesh", "source": "./", "description": "MeMesh \u2014 agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.", - "version": "4.8.3", + "version": "4.8.4", "author": { "name": "PCIRCLE AI" }, diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3c755d535..fd6c90cac 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -4,7 +4,7 @@ "author": { "name": "PCIRCLE AI" }, - "version": "4.8.3", + "version": "4.8.4", "mcpServers": "./.claude-plugin/mcp.json", "homepage": "https://github.com/PCIRCLE-AI/memesh", "repository": "https://github.com/PCIRCLE-AI/memesh", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index f6f98b03a..fc78e2419 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "memesh", "description": "MeMesh — agentic memory for coding agents.", - "version": "4.8.3", + "version": "4.8.4", "mcpServers": "./.codex-plugin/mcp.json" } diff --git a/.gitignore b/.gitignore index 1a6e42853..b654c74ca 100644 --- a/.gitignore +++ b/.gitignore @@ -123,3 +123,6 @@ docs/guides/ PRE_RELEASE_CHECKLIST.md docs/notes/ .smoke-test*.db-* + +# === qa:live-journey receipts (owner-machine evidence, never shipped) === +.qa/ diff --git a/CHANGELOG.md b/CHANGELOG.md index b07d3d584..8cef68702 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,15 +4,34 @@ All notable changes to MeMesh are documented here. ## [Unreleased] +## [4.8.4] — 2026-09-04 + ### Added - **`npm run qa:pre-release` — one door before a release, that says what it did not check.** It runs `npm run build`, `verify:artifact` (lint, typecheck, - version coherence, doc claims, the isolated suite, the packed artifact and - every derived upgrade path — the same sequence `prepublishOnly` runs, now - named once instead of copied) and `audit:memory`, reports each step's real - exit code, and prints the checks it cannot run: the interactive live journey, - the post-release check, and the entry-point start gate. + version coherence, doc claims, the isolated suite, the packed artifact, + every derived upgrade path, and now the entry-point start gate below — the + same sequence `prepublishOnly` runs, now named once instead of copied) and + `audit:memory`, reports each step's real exit code, and prints the checks it + still cannot run here: the interactive live journey (gated separately by + `release:finish`, below) and the post-release check. +- **A release gate that actually starts every shipped entry point.** Eight + binaries and nine hooks ship; every existing check only asked whether a + file existed, whether JSON parsed, or whether a referenced path resolved — + none of them ran the code. `scripts/check-entry-points-start.mjs` + (`npm run check:entry-points-start`, folded into `verify:release`) spawns + each of the 17 for real against a throwaway `MEMESH_DIR`: a CLI must accept + `--version`, an MCP server must exit 0 on stdin EOF, a long-lived daemon + must reach its "running" signal (a log line, or its socket file appearing), + and a host runtime with no config must fail closed with a named message, + never a raw stack trace. It also fails on any unresolved `${...}` left in + `.mcp.json` or `hooks/hooks.json`, evaluated against each manifest's own + real substitution environment — `CLAUDE_PLUGIN_ROOT` is always defined for + the plugin-loader-only `hooks/hooks.json`, but not for `.mcp.json`, which + Claude Code also auto-discovers with no plugin loader involved at all. The + Windows skip list is pinned to exactly one entry (`memesh-router`, no + `AF_UNIX` there) so it cannot grow silently. - **`npm run qa:post-release` — the check that runs on the machine, not on a fresh clone.** Every gate here runs on a fresh checkout or a fresh install, and all three release incidents lived in state that already existed: v4.7.0 @@ -26,9 +45,55 @@ All notable changes to MeMesh are documented here. resolve, not the first one: two installs on one PATH, four releases apart, is the shape of the incident, and a check that stops at the first hit cannot see it. +- **Repeatable owner-run live delivery checks.** `npm run qa:live-journey -- --host codex|claude` + (`scripts/qa/live-journey.mjs`) starts this checkout's router in a throwaway + `MEMESH_DIR`, registers one real host session, sends one exact-session + envelope, and passes only on model-visible proof: the Codex path requires the + model to quote the envelope's `message_id` and `delivery_id` back on its next + turn; the Claude path requires an `intake` receipt written by the interactive + session the operator launched with the printed command. Both then stop the + session and require `recipient_unavailable` while the durable row stays + fetchable and the router still answers `discover`. It refuses to run against + `$HOME/.memesh`, reads no auth files, never runs in CI, and records its + limitations in the JSON report — including that the Codex registration is + harness-driven because `codex exec --ignore-user-config` bypasses the plugin + `SessionStart` hook, and that print-mode Claude is unsupported (#275). + Closes the "repeatable check in the repository" box on #270 and #272. +- **A write-side reminder hook.** The read side of MeMesh was already automatic + (SessionStart and PreToolUse inject memories) but nothing prompted an agent + to *store* anything, so decisions made mid-session were routinely lost until + the user said "remember this" (#277). A ninth hook, + `scripts/hooks/decision-nudge.js`, runs on `PostToolUse` for `ExitPlanMode` + and `AskUserQuestion` — the two calls where a decision has most likely just + been made — and adds one line of context asking the model to `remember` it + if it is worth keeping. At most once per tool per session, enforced by an + `O_EXCL` flag file under `MEMESH_DIR/decision-nudge-flags/`; the hook never + opens the database, exits 0 on any malformed input, and writes nothing to + the graph itself. Hook inventories in the READMEs (three languages), + `docs/ARCHITECTURE.md`, `AGENTS.md`, `CODEMAP.md` and the skill now list nine + hooks, and `check-codemap-parity` no longer hard-codes the count. ### Changed +- **`npm run release:finish` now runs the real-credential checks instead of + merely documenting that they exist.** `qa:pre-release` and `qa:live-journey` + were both available since the previous release but neither was in the one + command that actually cuts a release — a check nobody has to run is a check + that gets skipped exactly when a release is rushed. `finish-release.mjs` now + runs `npm run qa:pre-release` itself (build + `verify:artifact` + + `audit:memory`, several minutes, streamed live) and blocks on its exit code; + a receipt cannot substitute here because it can go stale the moment the next + commit lands. `qa:live-journey` cannot run unattended — it needs a Codex + login or a person at an interactive Claude Code session — so it stays + receipt-based: `npm run qa:live-journey -- --host codex|claude --out + .qa/-report.json` writes a report, and `release:finish` requires ONE + of the two hosts' reports to be readable, `verdict: "PASS"`, recorded + against a clean tree, and naming this exact commit — an older PASS proves an + earlier revision, not this one. Neither host is preferred; only Codex can be + driven unattended today, but a human-run Claude receipt satisfies the gate + exactly as well. `.qa/` is gitignored — a receipt is owner-machine evidence, + never shipped. + - **The packed-upgrade gate derives its upgrade paths instead of pinning them.** `scripts/smoke-packed-upgrade.mjs` named both ends by hand (`expectedPreviousVersion = '4.8.2'`, `expectedCandidateVersion = '4.8.3'`, @@ -45,6 +110,19 @@ All notable changes to MeMesh are documented here. ### Fixed +- **The Claude Code plugin cache never cleaned up an old version after an + upgrade — only the ONE it had just replaced.** `upgrade-plugin.sh`'s atomic + swap always removed the previous cache directory, but anything left behind + by an interrupted upgrade, or one from before this swap mechanism existed, + had no path back to zero: measured on the maintainer's machine, 9 stale + version directories, 1.2 GB, with nothing ever sweeping them. A new + `sweep_stale_cache_versions` removes every OTHER directory under the cache + root whose name is exactly `..` after a successful + upgrade — never the version just installed, and never the registry's own + recorded install path even when that path is a stray non-canonical + directory the "repairing it" branch above deliberately leaves for a human, + whatever it happens to be named. + - **Three release-gate guards that could not fail, and the silent failure one of them was hiding.** A mutation audit reintroduced the defect each guard was written for and re-ran the suite: with all three defects present at once, @@ -451,37 +529,6 @@ All notable changes to MeMesh are documented here. independent `readUpdateCheckCache()` path formula is untouched by this — it only reads, and the filename scheme did not change. -### Added - -- **Repeatable owner-run live delivery checks.** `npm run qa:live-journey -- --host codex|claude` - (`scripts/qa/live-journey.mjs`) starts this checkout's router in a throwaway - `MEMESH_DIR`, registers one real host session, sends one exact-session - envelope, and passes only on model-visible proof: the Codex path requires the - model to quote the envelope's `message_id` and `delivery_id` back on its next - turn; the Claude path requires an `intake` receipt written by the interactive - session the operator launched with the printed command. Both then stop the - session and require `recipient_unavailable` while the durable row stays - fetchable and the router still answers `discover`. It refuses to run against - `$HOME/.memesh`, reads no auth files, never runs in CI, and records its - limitations in the JSON report — including that the Codex registration is - harness-driven because `codex exec --ignore-user-config` bypasses the plugin - `SessionStart` hook, and that print-mode Claude is unsupported (#275). - Closes the "repeatable check in the repository" box on #270 and #272. - -- **A write-side reminder hook.** The read side of MeMesh was already automatic - (SessionStart and PreToolUse inject memories) but nothing prompted an agent - to *store* anything, so decisions made mid-session were routinely lost until - the user said "remember this" (#277). A ninth hook, - `scripts/hooks/decision-nudge.js`, runs on `PostToolUse` for `ExitPlanMode` - and `AskUserQuestion` — the two calls where a decision has most likely just - been made — and adds one line of context asking the model to `remember` it - if it is worth keeping. At most once per tool per session, enforced by an - `O_EXCL` flag file under `MEMESH_DIR/decision-nudge-flags/`; the hook never - opens the database, exits 0 on any malformed input, and writes nothing to - the graph itself. Hook inventories in the READMEs (three languages), - `docs/ARCHITECTURE.md`, `AGENTS.md`, `CODEMAP.md` and the skill now list nine - hooks, and `check-codemap-parity` no longer hard-codes the count. - ## [4.8.3] — 2026-08-31 ### Fixed diff --git a/CODEMAP.md b/CODEMAP.md index 281fd08a8..edf4100d0 100644 --- a/CODEMAP.md +++ b/CODEMAP.md @@ -1,6 +1,6 @@ # CODEMAP -**Version**: 4.8.3 +**Version**: 4.8.4 A navigation map for the codebase: *"I want to change X — which file?"* For the design rationale behind these modules see [`docs/ARCHITECTURE.md`](docs/ARCHITECTURE.md); @@ -146,12 +146,21 @@ The same `operations.ts` memory functions run identically from all three transpo - Owner-run live checks (never CI): `scripts/qa/live-journey.mjs` — `npm run qa:live-journey -- --host codex|claude` drives a real Codex thread or an interactive Claude channel session and requires model-visible proof. Its pure half is pinned by `tests/qa/live-journey.test.ts`; the contract is in `docs/platforms/agent-messaging.md`. -- Release gates, in the order a release meets them: `npm run qa:pre-release` - (`scripts/qa/pre-release.mjs`) runs build + `verify:artifact` + `audit:memory` as one door - and prints what it could not check; `verify:artifact` is the same sequence `prepublishOnly` - runs, named once. `scripts/smoke-packed-upgrade.mjs` derives every upgrade path it proves - from `package.json` and the registry (`scripts/lib/upgrade-matrix.mjs`) instead of pinning - a version pair. After publishing, `npm run qa:post-release` (`scripts/qa/post-release.mjs`) - checks registry acceptance, a fresh install from the registry, and whether this machine is - on the release — read-only, printing fixes rather than running them. +- Release gates, in the order a release meets them: `verify:release` (called by `qa:pre-release` + below) now ends with `npm run check:entry-points-start` + (`scripts/check-entry-points-start.mjs`), which spawns every one of the 17 shipped bins and + hooks for real and fails on any unresolved `${...}` left in `.mcp.json`/`hooks/hooks.json`. + `npm run qa:pre-release` (`scripts/qa/pre-release.mjs`) runs build + `verify:artifact` + + `audit:memory` as one door and prints what it could not check; `verify:artifact` is the same + sequence `prepublishOnly` runs, named once. `scripts/smoke-packed-upgrade.mjs` derives every + upgrade path it proves from `package.json` and the registry (`scripts/lib/upgrade-matrix.mjs`) + instead of pinning a version pair. `npm run release:finish` (`scripts/finish-release.mjs`) runs + `qa:pre-release` itself and blocks on its exit code, and requires a `qa:live-journey` receipt + under `.qa/` (named `-report.json` — codex or claude, either satisfies it) that is + `PASS`, clean-tree, and names the exact commit being released + (`scripts/lib/release-preconditions.mjs`'s + `findUsableLiveJourneyReceipt`) — real-credential checks CI cannot run, now required rather + than merely available. After publishing, `npm run qa:post-release` + (`scripts/qa/post-release.mjs`) checks registry acceptance, a fresh install from the registry, + and whether this machine is on the release — read-only, printing fixes rather than running them. - Version anchors that must agree on a bump: `package.json`, both root entries in `package-lock.json`, `.claude-plugin/plugin.json`, `.claude-plugin/marketplace.json`, `herdr-plugin.toml`, `CHANGELOG.md`, `CODEMAP.md`, `docs/ARCHITECTURE.md`, and `docs/api/API_REFERENCE.md`. Run `npm run build` after to regenerate `dist/skills-manifest.json`. diff --git a/dist/skills-manifest.json b/dist/skills-manifest.json index 29e2b3423..55a7f6760 100644 --- a/dist/skills-manifest.json +++ b/dist/skills-manifest.json @@ -8,7 +8,7 @@ }, { "path": ".claude-plugin/plugin.json", - "sha256": "fe68e32aaf8cd1ec56472bd058b1149f8451938524bf945ad27c4ba9cd362136", + "sha256": "2b425a0b6ca6c6a2a549d273616ee0aa418a485960aa5f05ba44a38a6d49e9b5", "bytes": 572 }, { @@ -18,7 +18,7 @@ }, { "path": ".codex-plugin/plugin.json", - "sha256": "4a31bc9ce1ef62f7203ed9b3a9d4a9aa4b9a95a19f37f60cf4cfbdffa0ca95d8", + "sha256": "1ce4c7b5a8f04cfe1dde55adb6b1af580141de3a73eca0ee5a7bbb066d39b725", "bytes": 154 }, { diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index b315abe4d..9bbc7de48 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -1,6 +1,6 @@ # MeMesh Plugin Architecture -**Version**: 4.8.3 +**Version**: 4.8.4 > Looking for "which file do I change for X?" — see [CODEMAP.md](../CODEMAP.md). diff --git a/docs/api/API_REFERENCE.md b/docs/api/API_REFERENCE.md index 53cd1a087..df91315f8 100644 --- a/docs/api/API_REFERENCE.md +++ b/docs/api/API_REFERENCE.md @@ -1,7 +1,7 @@ # MeMesh Plugin -- API Reference **Protocol**: Model Context Protocol (MCP) over stdio -**Version**: 4.8.3 +**Version**: 4.8.4 **Compatibility**: Works with Claude Code plugins, Claude Managed Agents (via MCP connector), and any MCP-compatible client. **Native Integrations**: Beyond MCP, MeMesh integrates as a native memory provider for Hermes Agent (Python `MemoryProvider` plugin) and OpenClaw (TypeScript memory-capability plugin) — same tier as their built-in backends, not HTTP bridges. See [docs/platforms/](../platforms/) for platform-specific guides. diff --git a/docs/platforms/agent-messaging.md b/docs/platforms/agent-messaging.md index 47b363c97..13c5b70b2 100644 --- a/docs/platforms/agent-messaging.md +++ b/docs/platforms/agent-messaging.md @@ -212,10 +212,15 @@ close that gap by requiring evidence that could only have come out of a running model. ```bash -TMPDIR=/private/tmp npm run qa:live-journey -- --host codex --out codex-report.json -TMPDIR=/private/tmp npm run qa:live-journey -- --host claude --out claude-report.json +TMPDIR=/private/tmp npm run qa:live-journey -- --host codex --out .qa/codex-report.json +TMPDIR=/private/tmp npm run qa:live-journey -- --host claude --out .qa/claude-report.json ``` +`.qa/` is where `npm run release:finish` looks for these reports (any ONE +host's PASS, against the exact commit being released, is enough — see +`scripts/lib/release-preconditions.mjs`'s `findUsableLiveJourneyReceipt`). The +directory is gitignored; a report is owner-machine evidence, never shipped. + `TMPDIR` is not decoration on macOS. The router's Unix socket lives beside the database inside the temporary directory, and `AF_UNIX` caps a socket path at 104 bytes; the platform default `os.tmpdir()` spends about half of that before diff --git a/herdr-plugin.toml b/herdr-plugin.toml index 75e4663cb..9969db470 100644 --- a/herdr-plugin.toml +++ b/herdr-plugin.toml @@ -13,7 +13,7 @@ # is the honest floor rather than a copied one. id = "memesh" name = "MeMesh" -version = "4.8.3" +version = "4.8.4" min_herdr_version = "0.7.0" description = "Local SQLite memory shared across coding agents — decisions, lessons, and why the code looks the way it does." platforms = ["linux", "macos", "windows"] diff --git a/package-lock.json b/package-lock.json index c635a696e..20cde7f69 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@pcircle/memesh", - "version": "4.8.3", + "version": "4.8.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@pcircle/memesh", - "version": "4.8.3", + "version": "4.8.4", "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.30.0", diff --git a/package.json b/package.json index 24a7d88fa..15936c7a0 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@pcircle/memesh", - "version": "4.8.3", - "description": "MeMesh \u2014 agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.", + "version": "4.8.4", + "description": "MeMesh — agentic memory for coding agents. Captured from the agent's real work via hooks, recalled when it acts. One SQLite file, zero cloud required.", "main": "dist/index.js", "type": "module", "bin": { @@ -50,7 +50,8 @@ "check:surface-parity": "node scripts/check-readme-tool-parity.mjs && node scripts/check-codemap-parity.mjs", "audit:prod": "node scripts/check-consumer-audit.mjs", "audit:memory": "node scripts/audit/memory-invariants.mjs", - "verify:release": "npm run lint && npm run typecheck && node scripts/check-version-coherence.mjs && node scripts/check-generated-mirror.mjs && node scripts/check-agent-message-sync.mjs && npm run check:surface-parity && node scripts/check-doc-claims.mjs && node scripts/audit/verification-audit.mjs && npm run audit:prod", + "check:entry-points-start": "node scripts/check-entry-points-start.mjs", + "verify:release": "npm run lint && npm run typecheck && node scripts/check-version-coherence.mjs && node scripts/check-generated-mirror.mjs && node scripts/check-agent-message-sync.mjs && npm run check:surface-parity && node scripts/check-doc-claims.mjs && node scripts/audit/verification-audit.mjs && npm run audit:prod && npm run check:entry-points-start", "release:finish": "node scripts/finish-release.mjs", "verify:artifact": "npm run verify:release && npm run test:isolated && npm run test:packaged && npm run test:packaged:upgrade", "prepublishOnly": "npm run build && npm run verify:artifact", diff --git a/scripts/audit/baseline.json b/scripts/audit/baseline.json index ba5a8876e..11c8b3470 100644 --- a/scripts/audit/baseline.json +++ b/scripts/audit/baseline.json @@ -690,6 +690,16 @@ "class": "NOT-A-GATE", "reason": "real-browser (real HTTP server + real Chromium) verification that the dashboard doctor banner renders the mcp-config.placeholder-unresolved (quiet) and mcp-config.missing (translated banner) states correctly across all 11 locales, for evidence-gates output_readback/process_boundary/failure_path evidence -- invoked directly by `eg record`, not by any npm script or CI workflow. Renamed from verify-dashboard-doctor-banner-i18n.mjs during a rebase over PR #293, which already claimed that filename for its own llm-telemetry.silent-failure banner script -- distinct doctor codes, distinct files. A human or eg re-runs it on demand when the dashboard i18n catalogue changes; it does not gate a commit or a release on its own.", "triaged": "2026-09-04" + }, + "C1 tests/upgrade-plugin-cache-sweep.test.ts": { + "class": "SAFE", + "reason": "the one emptiness assertion ('is a no-op on an empty cache root', toEqual([])) is the genuinely-expected outcome of sweeping an empty directory -- there is no vacuity risk to guard against, because the setup creates zero entries on purpose and the test is pinning that the sweep does not fabricate a failure or create anything. Every other test in the file pins real content (toEqual(['4.8.4']), file-existence checks) after seeding non-empty fixtures, so the file is not relying on an empty-by-accident setup anywhere.", + "triaged": "2026-09-05" + }, + "C4 scripts/upgrade-plugin.sh:62": { + "class": "SAFE", + "reason": "set -uo pipefail at file top (line 31) preserves the left command's verdict; printf cannot meaningfully fail on a literal string, so the `if printf ... | grep -qE ...; then` reads grep's own match result -- the intended verdict, not a swallowed one.", + "triaged": "2026-09-05" } } } diff --git a/scripts/check-entry-points-start.mjs b/scripts/check-entry-points-start.mjs new file mode 100644 index 000000000..5c2295fea --- /dev/null +++ b/scripts/check-entry-points-start.mjs @@ -0,0 +1,665 @@ +#!/usr/bin/env node +// +// Entry-point execution gate +// =========================== +// +// Every other gate in this repo asks "does the file exist", "does it parse", +// "is it in the tarball". None of them ever run the thing. That is how +// `.mcp.json` shipped naming a deleted server file — every MCP tool failed +// with `-32000 failed to reconnect`, and no gate said a word (see the +// comment on `mcpEntry()` in scripts/lib/executable-targets.mjs) — and it is +// how a `${CLAUDE_PLUGIN_ROOT}` placeholder that cannot resolve outside a +// plugin loader has shipped in `.mcp.json` since 2026-05-13 without any +// check noticing. +// +// This gate spawns every declared bin (`package.json` `bin`) and every +// declared hook (`hooks/hooks.json`) once, with `process.execPath` — never a +// shell, never PATH — and checks that it starts. "Starts" means something +// different for each kind of entry point; see ASSERTIONS below for the +// per-binary reasoning. +// +// It runs with MEMESH_DIR/MEMESH_DB_PATH pointed at a throwaway directory it +// creates and removes. Nothing it spawns is allowed to touch a real +// ~/.memesh, and nothing it spawns is allowed to make a network call: +// MEMESH_AUTO_UPDATE=0 and MEMESH_AUTO_DETECT_LLM=0 keep it hermetic and +// keep a developer's real OPENAI_API_KEY/ANTHROPIC_API_KEY out of a +// subprocess this gate spawns. +import { spawn } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { binTargets, hookCommands } from './lib/executable-targets.mjs'; + +const here = path.dirname(fileURLToPath(import.meta.url)); +const rootOption = process.argv.indexOf('--root'); +const packageDir = rootOption >= 0 ? path.resolve(process.argv[rootOption + 1] ?? '') : path.resolve(here, '..'); + +const DEFAULT_TIMEOUT_MS = 10_000; +// Measured locally (this session, darwin): a fresh memesh-router socket +// appears in ~1.5s, including opening and migrating a brand-new SQLite +// database. That is not this gate's floor — it runs unskipped on +// ubuntu-latest with no local measurement to lean on, and this repo has +// already paid for the lesson that a timeout sized to the fastest machine +// becomes a flaky red build elsewhere (vitest.config.ts's hookTimeout going +// 10s→30s for the same reason; CLAUDE.md records a degraded CI runner +// measured at 5x slower). This is a poll that returns the instant the +// readiness signal appears, so a generous ceiling costs the happy path +// nothing. +const DAEMON_READY_TIMEOUT_MS = 15_000; +const DAEMON_SHUTDOWN_GRACE_MS = 10_000; + +/** + * Which platforms cannot prove a given entry point starts, and why. + * + * Exported (not inlined) so a test can pin it without a Windows machine: + * `tests/entry-points-start.test.ts` calls this with `'win32'`/`'darwin'` + * directly rather than relying on CI to happen to run on the right OS the + * day the pin needs checking. + * + * Only one entry exists today. `memesh-router` (src/host-runtime/router.ts) + * calls `assertSecureLocalHostRuntimeSupported()` at MODULE SCOPE — line 16, + * before any function boundary — and that call throws + * `SECURE_LOCAL_HOST_RUNTIME_UNSUPPORTED` on win32 with no surrounding + * try/catch anywhere between it and the module's top level. Windows support + * for the secure local host runtime is an explicit, documented design + * decision (config.ts:6-13), not a bug this gate should be reporting as one + * — but the resulting failure is also not a clean "starts and reports + * failure" the gate can assert on with confidence without a Windows machine + * to verify the exact shape against. Skipping it, loudly, is more honest + * than guessing at an assertion this repo cannot verify in this session. + * + * `memesh-host-claude`/`memesh-host-codex`/`memesh-host-acp` are NOT here: + * all three read `MEMESH_HOST_CONFIG`/`--config` and throw "A host config + * file is required…" BEFORE any Windows-specific code path is reached + * (config.ts `readHostConfig` checks `configuredPath` before calling + * `readHostConfigFile`, which is the only place `assertSecureLocalHostRuntimeSupported` + * runs for those three). Their missing-config failure mode is identical on + * every platform, so it is asserted on every platform — see ASSERTIONS + * below, where two of the three currently fail that assertion for an + * unrelated reason (a missing try/catch, not a Windows gap). + * + * @param {NodeJS.Platform} platform + * @returns {{relativePath: string, reason: string}[]} + */ +export function computeSkipList(platform) { + if (platform !== 'win32') return []; + return [ + { + relativePath: 'dist/host-runtime/router.js', + reason: + 'memesh-router calls assertSecureLocalHostRuntimeSupported() at module scope ' + + '(src/host-runtime/router.ts:16) with no try/catch — Windows is explicitly ' + + 'unsupported by design for the secure local host runtime (src/host-runtime/config.ts:6-13).', + }, + ]; +} + +/** + * `.mcp.json`/`hooks/hooks.json` both name their entry point as + * `${CLAUDE_PLUGIN_ROOT}/...`. That placeholder is legitimate in exactly one + * of the two manifests. + * + * `hooks/hooks.json` is reachable ONLY through Claude Code's plugin loader — + * there is no "project-scoped hooks" auto-discovery the way there is for MCP + * servers, so every hook command this repo ships is invoked with + * CLAUDE_PLUGIN_ROOT defined by the harness that read the plugin manifest in + * the first place. `scripts/hooks/_shared.js`'s `resolvePluginRoot()` even + * recomputes the same root independently from `import.meta.url`, which is + * belt-and-braces for a hook script that already knows it will always be + * plugin-loaded — not evidence that the manifest string itself needs to + * resolve some other way. + * + * `.mcp.json` is different: Claude Code ALSO auto-discovers a project-root + * `.mcp.json` as a project-scoped MCP server declaration, independent of + * whether MeMesh is installed as a plugin at all. In that load path nothing + * defines CLAUDE_PLUGIN_ROOT, so `${CLAUDE_PLUGIN_ROOT}/dist/mcp/server.js` + * is not a path — it is the literal seven characters `${CLAUDE_PLUGIN_ROOT}` + * followed by a filename Claude Code will never resolve. This is the + * `-32000 failed to reconnect` defect referenced at the top of this file. + * + * So each manifest gets checked against the substitution environment its + * OWN real load path actually provides — not the pre-stripped path + * `hookCommands()`/`mcpEntry()` in executable-targets.mjs return, which + * unconditionally strips the `${CLAUDE_PLUGIN_ROOT}/` prefix for BOTH + * manifests (that helper answers "what file, once resolved" — a different + * question from "does this manifest's own load path resolve it"). Checking + * the stripped path here would make this whole rule vacuous: it would find + * nothing, in every manifest, forever. + * + * @param {string} rootDir + * @returns {{manifest: string, variable: string, raw: string}[]} + */ +export function findUnresolvedPlaceholders(rootDir) { + const PLACEHOLDER = /\$\{([A-Z0-9_]+)\}/g; + + /** @type {Record>} */ + const substitutionEnvByManifest = { + // Plugin-loader-only: CLAUDE_PLUGIN_ROOT is always defined when this file + // is read. + 'hooks/hooks.json': { CLAUDE_PLUGIN_ROOT: rootDir }, + // Also reachable project-scoped, with no plugin loader in the picture — + // nothing substitutes CLAUDE_PLUGIN_ROOT there. + '.mcp.json': {}, + }; + + const findings = []; + for (const [manifest, substitutionEnv] of Object.entries(substitutionEnvByManifest)) { + const full = path.join(rootDir, manifest); + if (!fs.existsSync(full)) continue; + const raw = fs.readFileSync(full, 'utf8'); + for (const match of raw.matchAll(PLACEHOLDER)) { + const variable = match[1]; + if (!(variable in substitutionEnv)) { + findings.push({ manifest, variable, raw: match[0] }); + } + } + } + return findings; +} + +/** Poll `condition` until it is true or `timeoutMs` elapses. */ +async function waitFor(condition, description, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (condition()) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } + throw new Error(`Timed out waiting for ${description}`); +} + +/** Run a child to completion, capturing stdout/stderr/exit under a bound. */ +function run(absolutePath, args, { input, env, timeoutMs = DEFAULT_TIMEOUT_MS }) { + return new Promise((resolve) => { + const child = spawn(process.execPath, [absolutePath, ...args], { + env, + stdio: ['pipe', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill('SIGKILL'); + resolve({ stdout, stderr, status: null, timedOut: true }); + }, timeoutMs); + + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('exit', (code) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ stdout, stderr, status: code, timedOut: false }); + }); + + if (input !== undefined) child.stdin.end(input); + else child.stdin.end(); + }); +} + +// A minimal but valid top-level shape for a hook's stdout, per the real +// Claude Code contract this repo derived in +// tests/helpers/hook-output-contract.ts. That file is the source of truth — +// it is a full contract test wired into the ordinary vitest suite +// (tests/hooks/hook-output-contract.test.ts) and is NOT re-derived here. +// `scripts/` runs under plain `node`, with no TypeScript loader, so it +// cannot import a `.ts` module; duplicating its full ~200-line contract +// (per-event hookSpecificOutput field allowlists) into this file would be +// the second copy of a list this repo has already been bitten by (see the +// header comment in executable-targets.mjs). This is deliberately a lighter +// subset — enough to catch a hook that starts printing raw text or an +// invalid event name, not a replacement for the real contract test. +const HOOK_TOP_LEVEL_FIELDS = [ + 'continue', 'suppressOutput', 'stopReason', 'decision', 'reason', 'systemMessage', 'hookSpecificOutput', +]; + +function isPlausibleHookOutput(stdout, boundEvent) { + const trimmed = stdout.trim(); + if (trimmed === '') return { ok: true }; + + let parsed; + try { + parsed = JSON.parse(trimmed); + } catch { + return { ok: false, reason: `printed non-JSON, non-empty stdout: ${trimmed.slice(0, 200)}` }; + } + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + return { ok: false, reason: 'stdout JSON is not an object' }; + } + const unknown = Object.keys(parsed).filter((key) => !HOOK_TOP_LEVEL_FIELDS.includes(key)); + if (unknown.length > 0) { + return { ok: false, reason: `unknown top-level field(s): ${unknown.join(', ')}` }; + } + if ('hookSpecificOutput' in parsed) { + const event = parsed.hookSpecificOutput?.hookEventName; + if (event !== undefined && event !== boundEvent) { + return { ok: false, reason: `hookSpecificOutput.hookEventName is "${event}", bound event is "${boundEvent}"` }; + } + } + return { ok: true }; +} + +/** A raw Node uncaught-exception dump has indented `at ...` stack frames. */ +function looksLikeStackTrace(stderr) { + return /^\s{2,}at .+/m.test(stderr); +} + +async function main() { + const gateRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'mep-')); + // Belt-and-suspenders: mkdtemp already defaults to 0700 on every platform + // this gate runs on, but the router's own startup check (agent-router.ts + // `start()`: `(directoryMode & 0o077) !== 0` throws `insecure_socket_directory`) + // is exactly the kind of check this gate exists to actually exercise, not + // assume past. Making it explicit removes any dependency on umask. + fs.chmodSync(gateRoot, 0o700); + const memeshDir = path.join(gateRoot, '.memesh'); + fs.mkdirSync(memeshDir, { recursive: true }); + const memeshDbPath = path.join(memeshDir, 'kg.db'); + // NESTED under gateRoot, not a sibling of it — a sibling's directory is + // `os.tmpdir()` itself, which is the shared, world-writable system temp dir + // on Linux (`/tmp`, mode 1777), not gateRoot's own private (0700) + // directory. That is precisely the failure this router rejects: measured + // on ubuntu-latest CI, `memesh-router` refused to start with + // `insecure_socket_directory: Router socket directory must be private` — + // masked locally on macOS only because `os.tmpdir()` there already + // resolves to a per-user 0700 directory, so the bug was invisible on the + // one platform this gate had been run on before it first reached CI. + // AF_UNIX paths are capped at 103 bytes (agent-router.ts `validateSocketPath`); + // nesting one level costs one path separator — measured 66 bytes total on + // macOS's unusually long tmpdir (`/var/folders/.../T/mep-XXXXXX/r.sock`), + // well inside the limit, and shorter still on Linux's `/tmp`. + const routerSocket = path.join(gateRoot, 'r.sock'); + const routerTokenFile = path.join(gateRoot, 'r.token'); + + const baseEnv = { + ...process.env, + MEMESH_DIR: memeshDir, + MEMESH_DB_PATH: memeshDbPath, + MEMESH_AUTO_UPDATE: '0', + MEMESH_AUTO_DETECT_LLM: '0', + }; + delete baseEnv.MEMESH_HOST_CONFIG; + + // Two distinct kinds of finding, reported and counted separately: an + // entry point that did not start (counted against `checked`) and a + // manifest placeholder finding (not an entry point at all — findings.mjs + // once conflated these into one `failures` list, which made + // `checked - failures.length` under-report the started count by one for + // every placeholder finding and mislabeled the FAIL summary as "entry + // point(s) did not start" when one of them was a manifest string. + const entryFailures = []; + const placeholderFindings = []; + const skipped = []; + let checked = 0; + + const record = (label, promise) => promise.then( + (detail) => { checked += 1; process.stdout.write(` ok ${label}\n`); return detail; }, + (error) => { + checked += 1; + entryFailures.push({ label, message: error.message }); + process.stdout.write(` FAIL ${label} — ${error.message}\n`); + }, + ); + + // --- Assertions, one per entry-point kind ------------------------------ + // + // "Starts" means something different per kind; each function below is the + // cheapest thing that actually proves it, chosen after reading the source + // it exercises (cited in each comment). + + /** `memesh` — a commander CLI. --version exits 0 and prints the version + * without touching the DB or the network; it is the cheapest proof the + * binary parses argv and its command tree loads at all. */ + async function assertCliVersion(absolutePath, env) { + const result = await run(absolutePath, ['--version'], { env }); + if (result.timedOut) throw new Error(`timed out after ${DEFAULT_TIMEOUT_MS}ms`); + if (result.status !== 0) throw new Error(`exited ${result.status}\nstderr: ${result.stderr.slice(0, 500)}`); + if (result.stdout.trim() === '') throw new Error('--version printed nothing'); + } + + /** `memesh-mcp` — an MCP stdio server. Its entire contract is "read + * JSON-RPC frames from stdin until it closes, then exit"; EOF on stdin is + * the exact signal a disconnecting client sends, and is the only thing + * this gate can prove without a live MCP handshake. Verified empirically + * (2026-09-02, this session): closing stdin makes it exit 0 immediately + * rather than hang, so a bound here is a real assertion, not a safety net + * for a process this gate is about to kill regardless. */ + async function assertMcpStdioEof(absolutePath, env) { + const result = await run(absolutePath, [], { input: '', env }); + if (result.timedOut) throw new Error(`did not exit on stdin EOF within ${DEFAULT_TIMEOUT_MS}ms`); + if (result.status !== 0) throw new Error(`exited ${result.status} on stdin EOF\nstderr: ${result.stderr.slice(0, 500)}`); + } + + /** `memesh-http` — a long-lived Express server (src/transports/http/server.ts). + * It never reads stdin, so EOF proves nothing; it prints + * "MeMesh HTTP server running at …" from inside its `listen` callback + * (server.ts:1643) once it has actually bound a socket, which is the + * earliest true "started" signal available without an HTTP round trip. + * MEMESH_HTTP_PORT=0 asks the OS for an ephemeral port so this cannot + * collide with a real server or another gate run in CI. */ + async function assertHttpDaemon(absolutePath, env) { + const child = spawn(process.execPath, [absolutePath], { + env: { ...env, MEMESH_HTTP_PORT: '0' }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let exitInfo = null; + child.stdout.setEncoding('utf8'); + child.stderr.setEncoding('utf8'); + child.stdout.on('data', (chunk) => { stdout += chunk; }); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('exit', (code, signal) => { exitInfo = { code, signal }; }); + try { + await waitFor(() => stdout.includes('running at') || exitInfo !== null, 'memesh-http to report it is listening', DAEMON_READY_TIMEOUT_MS); + if (exitInfo !== null) throw new Error(`exited before listening (${JSON.stringify(exitInfo)})\nstderr: ${stderr.slice(0, 500)}`); + } finally { + if (exitInfo === null) { + child.kill('SIGTERM'); + try { + await waitFor(() => exitInfo !== null, 'memesh-http to exit after SIGTERM', DAEMON_SHUTDOWN_GRACE_MS); + } catch { + child.kill('SIGKILL'); + } + } + } + if (exitInfo !== null && exitInfo.code !== 0 && exitInfo.code !== null) { + throw new Error(`memesh-http did not shut down cleanly on SIGTERM (${JSON.stringify(exitInfo)})`); + } + } + + /** `memesh-router` — a Unix-domain-socket daemon (src/host-runtime/router.ts). + * Like the HTTP server it never reads stdin. It creates the socket file + * inline in module top-level code (no "ready" log line to grep for), so + * the socket's existence on disk IS the started signal — the same proof + * scripts/smoke-packed-artifact.mjs uses for the same reason. */ + async function assertRouterDaemon(absolutePath, env) { + const child = spawn(process.execPath, [absolutePath], { + env: { ...env, MEMESH_ROUTER_SOCKET: routerSocket, MEMESH_ROUTER_TOKEN_FILE: routerTokenFile }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stderr = ''; + let exitInfo = null; + child.stderr.setEncoding('utf8'); + child.stderr.on('data', (chunk) => { stderr += chunk; }); + child.once('exit', (code, signal) => { exitInfo = { code, signal }; }); + try { + await waitFor(() => fs.existsSync(routerSocket) || exitInfo !== null, 'memesh-router to create its socket', DAEMON_READY_TIMEOUT_MS); + if (exitInfo !== null) throw new Error(`exited before creating a socket (${JSON.stringify(exitInfo)})\nstderr: ${stderr.slice(0, 500)}`); + } finally { + if (exitInfo === null) { + child.kill('SIGTERM'); + try { + await waitFor(() => exitInfo !== null, 'memesh-router to exit after SIGTERM', DAEMON_SHUTDOWN_GRACE_MS); + } catch { + child.kill('SIGKILL'); + } + } + for (const f of [routerSocket, routerTokenFile]) { + try { fs.rmSync(f, { force: true }); } catch { /* best effort */ } + } + } + if (exitInfo !== null && exitInfo.code !== 0 && exitInfo.code !== null) { + throw new Error(`memesh-router did not shut down cleanly on SIGTERM (${JSON.stringify(exitInfo)})`); + } + } + + /** `memesh-host-claude` / `memesh-host-codex` / `memesh-host-acp` — each + * needs a host config file (`--config` or MEMESH_HOST_CONFIG) it will not + * find here. All three call `readHostConfig()` (src/host-runtime/config.ts:15), + * which throws "A host config file is required…" before touching stdin or + * any Windows-gated code path. The REQUIRED behaviour (spec, and this + * gate) is: fail closed with a named error and a non-crash exit — not a + * raw Node stack trace. + * + * All three now meet this — `codex.ts`/`acp.ts` originally called + * `await run…Host()` at top level with no try/catch and Node dumped the + * raw exception with stack frames; CHANGELOG.md's "Every host runtime now + * fails closed with the reason, not a stack trace" entry (fix/host-runtimes- + * fail-closed) gave every host runtime one shared `runHostEntry` wrapper + * instead of three hand-copied try/catch blocks, precisely so this + * assertion could stop being written to a spec none of them met yet. */ + async function assertHostRuntimeFailsClosed(absolutePath, env) { + const result = await run(absolutePath, [], { input: '', env }); + if (result.timedOut) throw new Error(`timed out after ${DEFAULT_TIMEOUT_MS}ms instead of failing closed`); + if (result.status === 0) throw new Error('exited 0 with no host config — expected a fail-closed non-zero exit'); + if (result.stderr.trim() === '') throw new Error(`exited ${result.status} with no stderr message — a fail-closed exit must name the error`); + if (looksLikeStackTrace(result.stderr)) { + throw new Error( + `exited ${result.status} but stderr is a raw stack trace, not a named error:\n${result.stderr.slice(0, 800)}`, + ); + } + } + + /** `memesh-host-codex-session` (both the `bin` entry and the async + * SessionStart hook — same file, src/host-runtime/codex-session.ts). + * Unlike the three above, its `main()` checks + * `fs.existsSync(configPath)` FIRST (codex-session.ts:99-100) and returns + * before ever reading stdin when no host config exists — a silent, + * successful no-op is the documented safe behaviour here, not a bug. */ + async function assertCodexSessionOptionalConfig(absolutePath, env, payload) { + const result = await run(absolutePath, [], { input: JSON.stringify(payload), env }); + if (result.timedOut) throw new Error(`timed out after ${DEFAULT_TIMEOUT_MS}ms`); + if (result.status !== 0) throw new Error(`exited ${result.status} with no host config present (expected a quiet no-op)\nstderr: ${result.stderr.slice(0, 500)}`); + } + + /** The eight ordinary hook scripts under scripts/hooks/. Each gets the + * minimal well-formed payload for the event it is bound to (same shapes + * used by tests/hooks/hook-output-contract.test.ts's HOOK_CASES, without + * the memory seeding that test uses to force specific branches — this + * gate only needs to prove the process starts and speaks the contract, + * not exercise every branch). */ + async function assertHookStarts(absolutePath, env, boundEvent, payload) { + const result = await run(absolutePath, [], { + input: JSON.stringify(payload), + env: { ...env, MEMESH_AUTO_UPDATE: '0' }, + timeoutMs: 15_000, + }); + if (result.timedOut) throw new Error('timed out after 15000ms'); + if (result.status !== 0) throw new Error(`exited ${result.status}\nstderr: ${result.stderr.slice(0, 500)}`); + const shape = isPlausibleHookOutput(result.stdout, boundEvent); + if (!shape.ok) throw new Error(shape.reason); + } + + // --- Profiles: which assertion applies to which declared relativePath -- + // + // Keyed by relativePath (unique across the whole derived list, unlike + // basename — dist/mcp/server.js and dist/transports/http/server.js are + // both literally "server.js"). An entry the derivation produces with no + // profile here is NOT silently passed — see the fallback below. + const projectCwd = path.join(gateRoot, 'project'); + const hookPayload = (overrides) => ({ + session_id: 'entry-point-gate', + cwd: projectCwd, + ...overrides, + }); + + const PROFILES = { + 'dist/transports/cli/cli.js': { run: (abs, env) => assertCliVersion(abs, env) }, + 'dist/mcp/server.js': { run: (abs, env) => assertMcpStdioEof(abs, env) }, + 'dist/transports/http/server.js': { run: (abs, env) => assertHttpDaemon(abs, env) }, + 'dist/host-runtime/router.js': { run: (abs, env) => assertRouterDaemon(abs, env) }, + 'dist/host-runtime/claude.js': { run: (abs, env) => assertHostRuntimeFailsClosed(abs, env) }, + 'dist/host-runtime/codex.js': { run: (abs, env) => assertHostRuntimeFailsClosed(abs, env) }, + 'dist/host-runtime/acp.js': { run: (abs, env) => assertHostRuntimeFailsClosed(abs, env) }, + 'dist/host-runtime/codex-session.js': { + run: (abs, env) => assertCodexSessionOptionalConfig(abs, env, hookPayload({ hook_event_name: 'SessionStart', source: 'startup' })), + }, + 'scripts/hooks/pre-edit-recall.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PreToolUse', hookPayload({ + hook_event_name: 'PreToolUse', tool_name: 'Edit', tool_input: { file_path: path.join(projectCwd, 'src/x.ts') }, + })), + }, + 'scripts/hooks/guard-check.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PreToolUse', hookPayload({ + hook_event_name: 'PreToolUse', tool_name: 'Bash', tool_input: { command: 'echo hi' }, + })), + }, + 'scripts/hooks/session-start.js': { + run: (abs, env) => assertHookStarts(abs, env, 'SessionStart', hookPayload({ + hook_event_name: 'SessionStart', source: 'startup', + })), + }, + 'scripts/hooks/post-commit.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PostToolUse', hookPayload({ + hook_event_name: 'PostToolUse', tool_name: 'Bash', + tool_input: { command: 'git commit -m test' }, + tool_response: { stdout: '[main abc1234] test' }, + })), + }, + 'scripts/hooks/decision-nudge.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PostToolUse', hookPayload({ + hook_event_name: 'PostToolUse', tool_name: 'ExitPlanMode', tool_input: { plan: 'entry-point gate smoke' }, + })), + }, + 'scripts/hooks/session-summary.js': { + run: (abs, env) => assertHookStarts(abs, env, 'Stop', hookPayload({ + hook_event_name: 'Stop', transcript_path: '', + })), + }, + 'scripts/hooks/pre-compact.js': { + run: (abs, env) => assertHookStarts(abs, env, 'PreCompact', hookPayload({ + hook_event_name: 'PreCompact', transcript_path: '', reason: 'auto', + })), + }, + 'scripts/hooks/user-prompt-intent.js': { + run: (abs, env) => assertHookStarts(abs, env, 'UserPromptSubmit', hookPayload({ + hook_event_name: 'UserPromptSubmit', prompt: 'hello', + })), + }, + }; + + try { + // --- Requirement 1: the list is derived, never hand-written ---------- + // `binTargets`/`hookCommands` (scripts/lib/executable-targets.mjs) return + // plain relative-path lists — the single derivation every consumer in + // this repo shares (see that file's own header comment for why there is + // exactly one). The bin NAME (e.g. "memesh-mcp") is not part of that + // return value, so it is recovered here, once, from the same + // package.json `bin` map the derivation itself reads — for the log + // label only; PROFILES below is keyed on relativePath, not on this name. + const bins = binTargets(packageDir); + const hooks = hookCommands(packageDir); + const pkg = JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8')); + const binNameByPath = new Map( + Object.entries(pkg.bin ?? {}).map(([name, relativePath]) => [relativePath, name]), + ); + const skipList = computeSkipList(process.platform); + const skippedPaths = new Set(skipList.map((s) => s.relativePath)); + + process.stdout.write(`entry-point-start: ${bins.length} bin(s) + ${hooks.length} hook(s) derived from package.json / hooks/hooks.json\n`); + + for (const skip of skipList) { + skipped.push(skip); + process.stdout.write(` SKIP ${skip.relativePath} — ${skip.reason}\n`); + } + + const entries = [ + ...bins.map((relativePath) => ({ + label: `bin ${binNameByPath.get(relativePath) ?? relativePath} (${relativePath})`, + relativePath, + })), + ...hooks.map((relativePath) => ({ label: `hook (${relativePath})`, relativePath })), + ]; + + for (const entry of entries) { + if (skippedPaths.has(entry.relativePath)) continue; + const absolutePath = path.join(packageDir, entry.relativePath); + if (!fs.existsSync(absolutePath)) { + entryFailures.push({ label: entry.label, message: `${entry.relativePath} does not exist — run \`npm run build\` first` }); + process.stdout.write(` FAIL ${entry.label} — does not exist\n`); + checked += 1; + continue; + } + const profile = PROFILES[entry.relativePath]; + if (!profile) { + // Requirement 1: a new manifest entry must be covered automatically. + // It cannot be covered by a correct assertion nobody wrote yet, so + // it is covered by failing loudly instead of silently passing. + entryFailures.push({ + label: entry.label, + message: `no execution profile defined for ${entry.relativePath} in scripts/check-entry-points-start.mjs — add one to PROFILES`, + }); + process.stdout.write(` FAIL ${entry.label} — no execution profile\n`); + checked += 1; + continue; + } + await record(entry.label, profile.run(absolutePath, baseEnv)); + } + + // --- Requirement 5: unresolved ${...} placeholders are a failure ----- + // Not an entry point, so kept out of `checked`/`entryFailures` — folding + // it in there is what previously made `checked - entryFailures.length` + // under-report the started count and mislabeled the FAIL summary as + // "entry point(s) did not start" for a manifest string. + const placeholders = findUnresolvedPlaceholders(packageDir); + for (const finding of placeholders) { + placeholderFindings.push({ + label: `placeholder in ${finding.manifest}`, + message: `${finding.manifest} still contains the unresolved placeholder ${finding.raw} — nothing substitutes ${finding.variable} in this manifest's real load path`, + }); + process.stdout.write(` FAIL placeholder ${finding.raw} in ${finding.manifest}\n`); + } + } finally { + // Best-effort, not guaranteed: session-start.js always spawns a + // detached, unref'd `memesh status` to refresh its update-check cache + // (scripts/hooks/session-start.js `spawnFreshUpdateCheck` — this is + // unconditional, MEMESH_AUTO_UPDATE only gates the separate auto-INSTALL + // policy, not this cache refresh). That child can still be writing + // `update-check..json` into gateRoot after this hook process + // has already exited, which occasionally beats this rmSync and leaves a + // small directory behind. tests/helpers/temp-dir.ts documents the exact + // same race for the vitest suite (which spawns session-start.js far + // more often, in the same CI job, via `npm test -- --run` right after + // this gate); a leaked temp file is harmless on a CI runner and retrying + // forever cannot outlast a process still writing, so this is a bounded + // best-effort clean, not a correctness requirement. + try { fs.rmSync(gateRoot, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); } catch { /* best effort */ } + for (const f of [routerSocket, routerTokenFile]) { + try { fs.rmSync(f, { force: true }); } catch { /* best effort */ } + } + } + + process.stdout.write( + `entry-point-start: ${checked - entryFailures.length}/${checked} entry point(s) started` + + (skipped.length ? `, ${skipped.length} skipped` : '') + + (placeholderFindings.length ? `, ${placeholderFindings.length} manifest placeholder finding(s)` : '') + + '\n', + ); + + const totalFailures = entryFailures.length + placeholderFindings.length; + if (totalFailures > 0) { + process.stderr.write( + `entry-point-start: FAIL (${entryFailures.length} entry point(s) did not start, ` + + `${placeholderFindings.length} manifest placeholder finding(s))\n`, + ); + for (const f of [...entryFailures, ...placeholderFindings]) process.stderr.write(` - ${f.label}: ${f.message}\n`); + process.exit(1); + } + + process.stdout.write('entry-point-start: PASS\n'); +} + +// Guard `main()` behind an entrypoint check — mirroring the same pattern +// src/host-runtime/{claude,codex,acp,codex-session}.ts already use — so that +// `computeSkipList`/`findUnresolvedPlaceholders` can be imported directly by +// tests (tests/entry-points-start.test.ts) without running the whole gate, +// spawning 17 processes, and calling `process.exit()`, as an import side +// effect inside the vitest worker. +function isMainModule() { + const entrypoint = process.argv[1]; + if (!entrypoint) return false; + try { + return fs.realpathSync(entrypoint) === fs.realpathSync(fileURLToPath(import.meta.url)); + } catch { + return false; + } +} + +if (isMainModule()) { + main(); +} diff --git a/scripts/finish-release.mjs b/scripts/finish-release.mjs index 4c660badc..e2643691f 100644 --- a/scripts/finish-release.mjs +++ b/scripts/finish-release.mjs @@ -36,16 +36,27 @@ // object either: "Protect release and benchmark tags" covers `refs/tags/v*` // with `deletion` and `non_fast_forward` rules only, so creating a new tag is // not what it blocks. +// +// REAL-CREDENTIAL CHECKS, NOT JUST DOCUMENTED ONES +// +// `npm run qa:pre-release` and `npm run qa:live-journey` both existed before +// this file called either — available, but not required, which is a check +// that gets skipped exactly when a release is rushed. This file now runs +// `qa:pre-release` itself and blocks on its real exit code, and requires a +// `qa:live-journey` receipt for THIS exact commit (either host) before it +// will proceed. See the "G4" comment further down for the mechanics. import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { execFileSync } from 'node:child_process'; +import { execFileSync, spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; import { checkReleasePreconditions, shippedPathsFromPackageJson, extractChangelogSection, + findUsableLiveJourneyReceipt, + LIVE_JOURNEY_RECEIPT_PATHS, } from './lib/release-preconditions.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); @@ -100,6 +111,21 @@ const statusOut = capture('git', ['status', '--porcelain']); const isClean = statusOut === null ? null : statusOut === ''; const headSha = capture('git', ['rev-parse', 'HEAD']); +// Fail fast on the single most common mistake — running this from the +// release branch itself, before its PR is merged — before paying for the +// several-minutes-long `qa:pre-release` run below. This duplicates exactly +// ONE condition from `checkReleasePreconditions`, not the sequence itself; +// that function still re-checks it for real once every input is gathered, +// so this early exit can never be the only thing standing between a mistake +// and a tag. +if (branch !== 'main') { + console.error( + `\n✗ refusing to cut ${tag}: not on main (branch: ${branch ?? 'undiscoverable'}) — ` + + 'a release is cut from main after the release PR is merged, never from the branch that raised it' + ); + process.exit(1); +} + const remoteMainLine = capture('git', ['ls-remote', 'origin', 'refs/heads/main']); const remoteHeadSha = remoteMainLine ? remoteMainLine.split(/\s+/)[0] : null; @@ -138,6 +164,44 @@ const shippedFilesChangedSinceBump = bumpCommit && shippedPaths // the three things that must be true before anything is created. const repoSlug = capture('gh', ['repo', 'view', '--json', 'nameWithOwner', '-q', '.nameWithOwner']); +// --- G4: real-credential checks, run HERE rather than merely documented ---- +// +// `npm run qa:pre-release` (build + verify:artifact + audit:memory) and +// `npm run qa:live-journey` (a real Codex thread or a real interactive Claude +// Code session) both exist and both catch what CI structurally cannot — CI +// runs on a fresh checkout with no logins, while the incidents this command +// exists to stop (v4.7.0's ghost publish, v4.8.2's stale plugin cache) lived +// in state only THIS machine has. Before this, both were available but +// optional — `scripts/qa/pre-release.mjs`'s own NOT_CHECKED list named this +// gap. A check nobody has to run is a check that gets skipped exactly when a +// release is rushed, which is when it is needed most. +// +// `qa:pre-release` is fully scriptable, so it is RUN, not merely checked for +// — a receipt can go stale the moment the next commit lands, and re-running a +// few minutes of build+test is cheaper than trusting a stale one. +console.log(`\n--- npm run qa:pre-release (build + verify:artifact + audit:memory; several minutes)`); +const qaPreReleaseResult = spawnSync('npm', ['run', 'qa:pre-release'], { + cwd: repoRoot, + stdio: 'inherit', + shell: process.platform === 'win32', +}); +const qaPreReleaseStatus = qaPreReleaseResult.status; + +// `qa:live-journey` needs a Codex login or a person at an interactive Claude +// Code session — nothing this script can open itself, so this stays +// receipt-based. `--host codex --out .qa/codex-report.json` (or `claude`) +// writes a `memesh-live-journey/v1` report; any ONE of the two hosts is +// accepted; the check is which is on-disk, current-revision and PASS, not a +// fixed host, because only Codex can be driven unattended today. +const liveJourneyCandidates = LIVE_JOURNEY_RECEIPT_PATHS.map(({ host, relativePath }) => { + const receiptPath = path.join(repoRoot, relativePath); + try { + return { host, path: receiptPath, report: JSON.parse(fs.readFileSync(receiptPath, 'utf8')), readError: null }; + } catch (e) { + return { host, path: receiptPath, report: null, readError: e.code === 'ENOENT' ? 'not found' : e.message }; + } +}); + let notes = null; if (notesFile) { try { @@ -164,13 +228,22 @@ const { ok, blockers } = checkReleasePreconditions({ repoSlug, notes, shippedFilesChangedSinceBump, + qaPreReleaseStatus, + liveJourneyCandidates, }); -console.log(`finish-release: ${tag}`); +console.log(`\nfinish-release: ${tag}`); console.log(` repo: ${repoSlug ?? '(gh could not say)'}`); console.log(` branch: ${branch ?? '(undiscoverable)'}`); console.log(` commit: ${headSha ? headSha.slice(0, 8) : '(unknown)'}`); console.log(` notes: ${notesFile ?? `CHANGELOG.md [${pkgVersion}]`} (${notes ? notes.length : 0} chars)`); +console.log(` qa:pre-release: ${qaPreReleaseStatus === 0 ? 'PASS' : `FAIL (exit ${qaPreReleaseStatus ?? '(could not run)'})`}`); +{ + const liveJourney = findUsableLiveJourneyReceipt(liveJourneyCandidates, headSha); + console.log( + ` live-journey: ${liveJourney.ok ? `PASS (${liveJourney.usable.host}, ${liveJourney.usable.path})` : 'no usable receipt — see blockers below if any'}` + ); +} // Print the head of the body BEFORE acting, in both paths. The default source // is the CHANGELOG section, which for 4.6.1 was 26,355 characters — while the diff --git a/scripts/lib/release-preconditions.mjs b/scripts/lib/release-preconditions.mjs index 3106b2889..80686b7b9 100644 --- a/scripts/lib/release-preconditions.mjs +++ b/scripts/lib/release-preconditions.mjs @@ -34,6 +34,8 @@ * @param {string[]|null} input.remoteTags Every `v*` tag on `origin`. * @param {string|null} input.repoSlug `owner/name` as `gh` reports it — proof gh is authenticated. * @param {string|null} input.notes Release body, already resolved. + * @param {number|null} input.qaPreReleaseStatus Exit code of `npm run qa:pre-release`, run fresh by this script; null if it could not run at all. + * @param {LiveJourneyCandidate[]} input.liveJourneyCandidates See `findUsableLiveJourneyReceipt`. * @returns {{ok: boolean, blockers: string[]}} */ export function checkReleasePreconditions({ @@ -47,6 +49,8 @@ export function checkReleasePreconditions({ repoSlug, notes, shippedFilesChangedSinceBump, + qaPreReleaseStatus, + liveJourneyCandidates, }) { const blockers = []; const tag = `v${pkgVersion}`; @@ -150,9 +154,98 @@ export function checkReleasePreconditions({ ); } + // G4: real-credential checks CI cannot run — see finish-release.mjs for why + // each is gathered the way it is. + if (qaPreReleaseStatus !== 0) { + blockers.push( + `\`npm run qa:pre-release\` did not pass (${ + qaPreReleaseStatus === null ? 'could not run it at all' : `exit ${qaPreReleaseStatus}` + }) — fix what it reported, then re-run \`npm run release:finish\`` + ); + } + + const liveJourney = findUsableLiveJourneyReceipt(liveJourneyCandidates, headSha); + if (!liveJourney.ok) { + blockers.push( + 'no usable `npm run qa:live-journey` receipt for this exact commit — run ' + + `\`npm run qa:live-journey -- --host codex --out ${LIVE_JOURNEY_RECEIPT_PATHS[0].relativePath}\` ` + + '(or `--host claude` from an interactive Claude Code session) first. ' + + (Array.isArray(liveJourneyCandidates) && liveJourneyCandidates.length > 0 + ? `Checked: ${liveJourney.reasons.join('; ')}` + : 'No candidates were even checked — this is a caller bug, not a missing receipt.') + ); + } + return { ok: blockers.length === 0, blockers }; } +/** + * @typedef {{host: string, path: string, report: object|null, readError: string|null}} LiveJourneyCandidate + */ + +/** + * Where `finish-release.mjs` looks for a `qa:live-journey` report, relative to + * the repo root. `npm run qa:live-journey -- --host --out ` + * writes exactly this shape. Order does not encode preference — either host + * satisfies the gate — it only fixes which command this file's own messages + * suggest first. + */ +export const LIVE_JOURNEY_RECEIPT_PATHS = [ + { host: 'codex', relativePath: '.qa/codex-report.json' }, + { host: 'claude', relativePath: '.qa/claude-report.json' }, +]; + +/** + * Is any ONE of the candidate `qa:live-journey` reports usable as proof for + * THIS release? Any host qualifies — only the Codex path can be driven + * unattended today, but nothing here prefers it over a Claude-host receipt a + * human actually produced. + * + * A receipt is usable only if it is readable, is the report shape + * `live-journey.mjs` actually emits, passed, was not run against a dirty + * tree (a dirty-tree run does not describe any single commit), and names + * THIS exact commit — an older PASS proves an earlier revision, not this one. + * + * @param {LiveJourneyCandidate[]} candidates + * @param {string|null} headSha + * @returns {{ok: boolean, usable: LiveJourneyCandidate|null, reasons: string[]}} + */ +export function findUsableLiveJourneyReceipt(candidates, headSha) { + const reasons = []; + if (!Array.isArray(candidates) || candidates.length === 0) return { ok: false, usable: null, reasons }; + if (!headSha) { + return { ok: false, usable: null, reasons: ['HEAD sha is unknown, so no receipt could be matched to it'] }; + } + for (const candidate of candidates) { + const label = `${candidate.path} (${candidate.host})`; + if (!candidate.report) { + reasons.push(`${label}: ${candidate.readError === 'not found' ? 'not found' : `unreadable — ${candidate.readError}`}`); + continue; + } + const report = candidate.report; + if (report.schema_version !== 'memesh-live-journey/v1') { + reasons.push(`${label}: not a memesh-live-journey/v1 report`); + continue; + } + if (report.verdict !== 'PASS') { + reasons.push(`${label}: verdict is ${JSON.stringify(report.verdict ?? null)}, not PASS`); + continue; + } + if (report.dirty !== false) { + reasons.push(`${label}: ran against a dirty working tree, so it does not describe one commit`); + continue; + } + if (report.revision !== headSha) { + reasons.push( + `${label}: revision ${String(report.revision ?? '?').slice(0, 8)} does not match HEAD ${headSha.slice(0, 8)}` + ); + continue; + } + return { ok: true, usable: candidate, reasons }; + } + return { ok: false, usable: null, reasons }; +} + /** * The body of `## [X.Y.Z]` in a CHANGELOG, up to the next `## ` heading. * diff --git a/scripts/qa/pre-release.mjs b/scripts/qa/pre-release.mjs index f4bdd8485..2dd1443e3 100644 --- a/scripts/qa/pre-release.mjs +++ b/scripts/qa/pre-release.mjs @@ -45,9 +45,9 @@ export const STEPS = [ * cannot run here, so nobody reads a green verdict as more than it is. */ export const NOT_CHECKED = [ - 'npm run qa:live-journey -- --host claude — needs an interactive Claude Code session a script cannot open.', + 'npm run qa:live-journey -- --host claude — needs an interactive Claude Code session a script cannot open. ' + + 'A --host codex or --host claude receipt is required by `release:finish` instead (see release-preconditions.mjs).', 'npm run qa:post-release — only meaningful after the release is published; run it next.', - 'The entry-point start gate — unreviewed on branch fix/entry-point-execution-gate, deliberately not wired in.', ]; /** diff --git a/scripts/upgrade-plugin.sh b/scripts/upgrade-plugin.sh index 5e8f6459c..9580e428f 100755 --- a/scripts/upgrade-plugin.sh +++ b/scripts/upgrade-plugin.sh @@ -30,6 +30,54 @@ set -uo pipefail +# D9: removes every OTHER stale version directory under $root, not just the +# one an upgrade just swapped out. Before the atomic-swap rename elsewhere in +# this script, an interrupted or pre-this-mechanism upgrade could leave a +# version directory behind with nothing left to remove it later — measured +# on a real machine: 9 old version directories, 1.2 GB, accumulated with no +# bound. Only entries whose full name is exactly `..` +# are touched, so a `.staging-*`/`.previous-*` marker from a genuinely +# concurrent run (should be impossible under $LOCK_DIR, but this check does +# not rely on that) or anything else unexpected under $root is left alone. +# $keep_version is always excluded; an optional $also_keep protects a second +# name — the registry's OWN recorded install path (however it is spelled), +# so the noncanonical-path repair in section 2 below can keep leaving that +# one directory alone for a human to clean up, exactly as it already did +# before this function existed. +# +# Defined this early, and callable on its own, so +# `tests/upgrade-plugin-cache-sweep.test.ts` can source this file with +# MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY=1 (below) and call it directly against a +# throwaway root — testing the shipped function, not a hand-copied +# reimplementation of it — without running the rest of this script, which +# talks to a real Claude Code marketplace checkout. +sweep_stale_cache_versions() { + local root="$1" keep_version="$2" also_keep="${3:-}" entry name + [ -d "$root" ] || return 0 + for entry in "$root"/*; do + [ -d "$entry" ] || continue + name="$(basename "$entry")" + [ "$name" = "$keep_version" ] && continue + [ -n "$also_keep" ] && [ "$name" = "$also_keep" ] && continue + if printf '%s' "$name" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then + rm -rf "$entry" 2>/dev/null + if [ -e "$entry" ]; then + echo "WARNING: could not remove stale cached version at $entry — remove it manually." >&2 + fi + fi + done +} + +# Test-only escape hatch: source this file with this variable set to load +# `sweep_stale_cache_versions` (and any other function defined above this +# guard) without running the rest of the script. `return` exits a sourced +# file without killing the parent shell; `|| exit 0` is the fallback for the +# (unsupported, but harmless to guard) case of someone executing the script +# directly with the variable set. +if [ "${MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY:-0}" = "1" ]; then + return 0 2>/dev/null || exit 0 +fi + if [ -n "${CLAUDE_CONFIG_DIR:-}" ]; then CLAUDE_CONFIG_ROOT="$CLAUDE_CONFIG_DIR" elif [ -n "${HOME:-}" ]; then @@ -282,12 +330,28 @@ ENTRY_SNAPSHOT="$(INSTALL_REGISTRY="$INSTALL_REGISTRY" CACHE_ROOT="$CACHE_ROOT" && path.resolve(entry.installPath) === expectedPath ? 'canonical' : 'noncanonical'; - process.stdout.write(['selected', index, version, sha, registrySha256, installPathState, String(opened.dev), String(opened.ino)].join('\\t')); + // D9's cache sweep must never remove a directory the registry ITSELF + // still points to, canonical or not — this is the one directory the + // 'noncanonical … repairing it' path above (section 2) deliberately + // leaves in place. Reported as a bare basename, and only when that + // basename resolves back under this same cache root with no '..' + // segment, so a crafted absolute installPath from a tampered registry + // cannot smuggle an arbitrary path into the sweep's exclusion list. + const rootDir = path.resolve(process.env.CACHE_ROOT); + let recordedBasename = ''; + if (typeof entry.installPath === 'string' && path.isAbsolute(entry.installPath)) { + const resolved = path.resolve(entry.installPath); + const base = path.basename(resolved); + if (path.join(rootDir, base) === resolved && !/[\\r\\n\\t]/.test(base)) { + recordedBasename = base; + } + } + process.stdout.write(['selected', index, version, sha, registrySha256, installPathState, String(opened.dev), String(opened.ino), recordedBasename].join('\\t')); ")" || { echo "ERROR: could not read the installed memesh entries from $INSTALL_REGISTRY" >&2 exit 1 } -IFS=$'\t' read -r ENTRY_STATE ENTRY_INDEX CURRENT_VERSION INSTALLED_SHA ORIGINAL_REGISTRY_SHA256 INSTALL_PATH_STATE ORIGINAL_REGISTRY_DEV ORIGINAL_REGISTRY_INO <<< "$ENTRY_SNAPSHOT" +IFS=$'\t' read -r ENTRY_STATE ENTRY_INDEX CURRENT_VERSION INSTALLED_SHA ORIGINAL_REGISTRY_SHA256 INSTALL_PATH_STATE ORIGINAL_REGISTRY_DEV ORIGINAL_REGISTRY_INO RECORDED_INSTALL_BASENAME <<< "$ENTRY_SNAPSHOT" case "$ENTRY_STATE" in identity-changed) echo "ERROR: installed_plugins.json changed file identity while this upgrade was reading it — refusing to continue." >&2 @@ -620,6 +684,11 @@ if [ -e "$PREVIOUS_PATH" ] || [ -L "$PREVIOUS_PATH" ]; then echo " Remove it manually when no Claude Code process is using it: rm -rf \"$PREVIOUS_PATH\"" >&2 fi +# D9: sweep every OTHER stale version directory under $CACHE_ROOT, not just +# the one this run just swapped out — see `sweep_stale_cache_versions`'s own +# definition near the top of this file for why. +sweep_stale_cache_versions "$CACHE_ROOT" "$NEW_VERSION" "$RECORDED_INSTALL_BASENAME" + # ─── 7. Done ───────────────────────────────────────────────────────────── echo "" echo "✓ MeMesh upgraded: $CURRENT_VERSION (${INSTALLED_SHA:0:8}) -> $NEW_VERSION (${MARKETPLACE_SHA:0:8})" diff --git a/tests/entry-points-start.test.ts b/tests/entry-points-start.test.ts new file mode 100644 index 000000000..98291e55a --- /dev/null +++ b/tests/entry-points-start.test.ts @@ -0,0 +1,174 @@ +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { binTargets, hookCommands } from '../scripts/lib/executable-targets.mjs'; +import { computeSkipList, findUnresolvedPlaceholders } from '../scripts/check-entry-points-start.mjs'; + +const script = path.resolve('scripts/check-entry-points-start.mjs'); +const dirs: string[] = []; + +afterEach(() => { + for (const dir of dirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +function write(root: string, file: string, value = ''): void { + const target = path.join(root, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, value); +} + +function fixtureRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-entry-gate-fixture-')); + dirs.push(root); + return root; +} + +function run(root: string) { + return spawnSync(process.execPath, [script, '--root', root], { encoding: 'utf8', timeout: 30_000 }); +} + +describe('executable-targets derivation picks up new manifest entries', () => { + // Requirement: "the list is derived, never hand-written... a new entry + // point added to either manifest must be covered automatically, and a + // test must prove that". This proves it at the derivation-function level + // — the layer both the gate and every other executable-targets.mjs + // consumer share (tests/installation.test.ts derives its own expected + // file lists from these same two functions) — rather than only at the + // whole-gate level, so a future third consumer inherits the same + // guarantee. + it('binTargets reflects a bin entry no fixture author hand-listed here', () => { + const root = fixtureRoot(); + write(root, 'package.json', JSON.stringify({ + bin: { memesh: 'dist/cli.js', 'memesh-brand-new-thing': 'dist/brand-new-thing.js' }, + })); + const entries = binTargets(root); + expect(entries).toHaveLength(2); + expect(entries).toContain('dist/brand-new-thing.js'); + }); + + it('hookCommands reflects a hook command no fixture author hand-listed here', () => { + const root = fixtureRoot(); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { + SessionStart: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/session-start.js' }] }], + PreCompact: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/brand-new-hook.js', async: true }] }], + }, + })); + const entries = hookCommands(root); + expect(entries).toHaveLength(2); + expect(entries).toContain('scripts/hooks/brand-new-hook.js'); + }); +}); + +describe('gate fails when a declared entry point does not start', () => { + // A minimal-but-complete fixture: one bin whose real file is broken, and + // one hook whose real file is a trivial no-op — both keyed at the SAME + // relative paths the real repo uses, so the gate's actual PROFILES table + // (keyed by relative path, not by root) applies to them unmodified. This + // exercises the real assertCliVersion/assertHookStarts logic against a + // controlled fixture instead of only ever running against the live repo. + function brokenBinFixture(): string { + const root = fixtureRoot(); + write(root, 'package.json', JSON.stringify({ bin: { memesh: 'dist/transports/cli/cli.js' } })); + write(root, 'dist/transports/cli/cli.js', '#!/usr/bin/env node\nprocess.exit(7);\n'); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pre-edit-recall.js' }] }] }, + })); + write(root, 'scripts/hooks/pre-edit-recall.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + return root; + } + + it('a broken bin fails the gate while an unrelated working hook still passes', () => { + const result = run(brokenBinFixture()); + expect(result.status, `stdout:\n${result.stdout}\nstderr:\n${result.stderr}`).not.toBe(0); + expect(result.stderr).toContain('bin memesh (dist/transports/cli/cli.js)'); + expect(result.stderr).toContain('exited 7'); + expect(result.stdout).toContain('ok hook (scripts/hooks/pre-edit-recall.js)'); + }); + + it('a manifest entry with no execution profile fails loudly, not silently', () => { + const root = fixtureRoot(); + write(root, 'package.json', JSON.stringify({ bin: { 'memesh-unknown-thing': 'dist/unknown-thing.js' } })); + write(root, 'dist/unknown-thing.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pre-edit-recall.js' }] }] }, + })); + write(root, 'scripts/hooks/pre-edit-recall.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + const result = run(root); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('no execution profile defined for dist/unknown-thing.js'); + }); +}); + +describe('unresolved ${...} placeholders fail the gate', () => { + // The manifest's own load path decides which substitutions are real. + // hooks/hooks.json is reachable ONLY through the plugin loader, so + // CLAUDE_PLUGIN_ROOT there is always defined and must NOT be flagged — + // otherwise this rule is vacuous for the one manifest (.mcp.json) it + // exists to catch, which is exactly the trap the header comment on + // findUnresolvedPlaceholders warns the next reader away from. + it('flags an unresolved placeholder in .mcp.json', () => { + const root = fixtureRoot(); + write(root, '.mcp.json', JSON.stringify({ + mcpServers: { memesh: { command: 'node', args: ['${CLAUDE_PLUGIN_ROOT}/dist/mcp/server.js'] } }, + })); + const findings = findUnresolvedPlaceholders(root); + expect(findings).toEqual([{ manifest: '.mcp.json', variable: 'CLAUDE_PLUGIN_ROOT', raw: '${CLAUDE_PLUGIN_ROOT}' }]); + }); + + it('does NOT flag ${CLAUDE_PLUGIN_ROOT} in hooks/hooks.json — plugin-loader-only, always defined there', () => { + const root = fixtureRoot(); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/session-start.js' }] }] }, + })); + expect(findUnresolvedPlaceholders(root)).toEqual([]); + }); + + it('still flags a DIFFERENT unresolved variable in hooks/hooks.json — the exemption is CLAUDE_PLUGIN_ROOT only', () => { + const root = fixtureRoot(); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { SessionStart: [{ hooks: [{ command: '${SOME_OTHER_VAR}/scripts/hooks/session-start.js' }] }] }, + })); + expect(findUnresolvedPlaceholders(root)).toEqual([{ manifest: 'hooks/hooks.json', variable: 'SOME_OTHER_VAR', raw: '${SOME_OTHER_VAR}' }]); + }); + + it('the whole gate fails, not just the helper function, when .mcp.json has an unresolved placeholder', () => { + const root = fixtureRoot(); + write(root, 'package.json', JSON.stringify({ bin: { memesh: 'dist/cli.js' } })); + write(root, 'dist/cli.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + write(root, 'hooks/hooks.json', JSON.stringify({ + hooks: { PreToolUse: [{ hooks: [{ command: '${CLAUDE_PLUGIN_ROOT}/scripts/hooks/pre-edit-recall.js' }] }] }, + })); + write(root, 'scripts/hooks/pre-edit-recall.js', '#!/usr/bin/env node\nprocess.exit(0);\n'); + write(root, '.mcp.json', JSON.stringify({ + mcpServers: { memesh: { command: 'node', args: ['${CLAUDE_PLUGIN_ROOT}/dist/mcp/server.js'] } }, + })); + const result = run(root); + expect(result.status).not.toBe(0); + expect(result.stderr).toContain('.mcp.json still contains the unresolved placeholder ${CLAUDE_PLUGIN_ROOT}'); + }); +}); + +describe('Windows skip list does not grow silently', () => { + // Requirement: "If any binary genuinely cannot be started in CI, do not + // skip it silently... add a test that the skip list is not silently + // growing." Pinned as an exact list (not just a length) so an addition + // must edit this test, not merely bump a number. + it('is empty on POSIX platforms', () => { + expect(computeSkipList('darwin')).toEqual([]); + expect(computeSkipList('linux')).toEqual([]); + }); + + it('is pinned to exactly memesh-router on win32', () => { + const relativePaths = computeSkipList('win32').map((entry) => entry.relativePath); + expect(relativePaths).toEqual(['dist/host-runtime/router.js']); + }); + + it('every skipped entry names a non-empty reason', () => { + for (const entry of computeSkipList('win32')) { + expect(entry.reason.length).toBeGreaterThan(0); + } + }); +}); diff --git a/tests/qa-pre-release.test.ts b/tests/qa-pre-release.test.ts index c9a75eb5f..27aaaaf35 100644 --- a/tests/qa-pre-release.test.ts +++ b/tests/qa-pre-release.test.ts @@ -65,11 +65,28 @@ describe('the plan', () => { expect(scripts.prepublishOnly).not.toContain('test:packaged:upgrade'); }); - it('says what it cannot check, including the two gates that live elsewhere', () => { + it('reaches the entry-point start gate transitively, through verify:release', () => { + // `qa:pre-release` runs `verify:artifact`, which runs `verify:release`. + // Pinned here, not just in verify:release's own test, because THIS is + // the file whose NOT_CHECKED list claims the entry-point gate is covered + // — if `verify:release` ever stopped calling it, this is where that + // claim would go silently false. + const scripts = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')).scripts; + expect(scripts['verify:release']).toContain('check:entry-points-start'); + expect(scripts['check:entry-points-start']).toContain('check-entry-points-start.mjs'); + }); + + it('says what it cannot check, including the one gate that still lives elsewhere', () => { + // The entry-point start gate used to be named here too — it is not + // anymore, because it is wired into `verify:release` (and therefore into + // `verify:artifact`, one of the STEPS above) rather than merely + // documented as missing. This test would go green again if that wiring + // were ever quietly removed while this line was deleted along with it, + // so the negative assertion matters as much as the positive ones. const text = NOT_CHECKED.join('\n'); expect(text).toMatch(/live-journey/); expect(text).toMatch(/qa:post-release/); - expect(text).toMatch(/entry-point/); + expect(text).not.toMatch(/entry-point/); }); it('detects a step whose npm script has been renamed away', () => { diff --git a/tests/release-preconditions.test.ts b/tests/release-preconditions.test.ts index 200fb5877..b09f26741 100644 --- a/tests/release-preconditions.test.ts +++ b/tests/release-preconditions.test.ts @@ -22,23 +22,41 @@ import { checkReleasePreconditions, extractChangelogSection, shippedPathsFromPackageJson, + findUsableLiveJourneyReceipt, + LIVE_JOURNEY_RECEIPT_PATHS, } from '../scripts/lib/release-preconditions.mjs'; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const HEAD = 'a'.repeat(40); + +/** A `qa:live-journey` candidate list with exactly one usable PASS receipt for HEAD. */ +function readyLiveJourney() { + return [ + { + host: 'codex', + path: '.qa/codex-report.json', + report: { schema_version: 'memesh-live-journey/v1', revision: HEAD, dirty: false, verdict: 'PASS', host: 'codex' }, + readError: null, + }, + { host: 'claude', path: '.qa/claude-report.json', report: null, readError: 'not found' }, + ]; +} /** A state where cutting 4.7.0 is exactly the right thing to do. */ function ready(overrides: Record = {}) { return { branch: 'main', isClean: true, - headSha: 'a'.repeat(40), - remoteHeadSha: 'a'.repeat(40), + headSha: HEAD, + remoteHeadSha: HEAD, pkgVersion: '4.7.0', localTags: ['v4.6.0', 'v4.6.1'], remoteTags: ['v4.6.0', 'v4.6.1'], repoSlug: 'PCIRCLE-AI/memesh', notes: '### Added\n\n- something', shippedFilesChangedSinceBump: [], + qaPreReleaseStatus: 0, + liveJourneyCandidates: readyLiveJourney(), ...overrides, }; } @@ -153,6 +171,119 @@ describe('release preconditions', () => { const r = checkReleasePreconditions(ready({ branch: 'topic', isClean: false, notes: null })); expect(r.blockers.length).toBeGreaterThanOrEqual(3); }); + + // G4: real-credential gates. `qa:pre-release` is run fresh by + // finish-release.mjs and its EXIT CODE gates; `qa:live-journey` cannot run + // unattended, so a recorded receipt gates instead — see + // `findUsableLiveJourneyReceipt` below for what makes one usable. + it('refuses when `npm run qa:pre-release` did not pass', () => { + const r = checkReleasePreconditions(ready({ qaPreReleaseStatus: 1 })); + expect(r.ok).toBe(false); + expect(r.blockers.join('\n')).toContain('qa:pre-release'); + }); + + it('refuses when `npm run qa:pre-release` could not even run', () => { + const r = checkReleasePreconditions(ready({ qaPreReleaseStatus: null })); + expect(r.ok).toBe(false); + expect(r.blockers.join('\n')).toContain('could not run it at all'); + }); + + it('refuses when no qa:live-journey receipt is usable', () => { + const r = checkReleasePreconditions(ready({ liveJourneyCandidates: [] })); + expect(r.ok).toBe(false); + expect(r.blockers.join('\n')).toContain('qa:live-journey'); + }); + + it('accepts a Claude-host receipt just as readily as a Codex one', () => { + const claudeOnly = [ + { host: 'codex', path: '.qa/codex-report.json', report: null, readError: 'not found' }, + { + host: 'claude', + path: '.qa/claude-report.json', + report: { schema_version: 'memesh-live-journey/v1', revision: HEAD, dirty: false, verdict: 'PASS', host: 'claude' }, + readError: null, + }, + ]; + const r = checkReleasePreconditions(ready({ liveJourneyCandidates: claudeOnly })); + expect(r.ok).toBe(true); + }); +}); + +describe('findUsableLiveJourneyReceipt', () => { + const pass = { schema_version: 'memesh-live-journey/v1', revision: HEAD, dirty: false, verdict: 'PASS', host: 'codex' }; + + it('accepts a PASS receipt for the exact HEAD revision', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: pass, readError: null }], HEAD); + expect(r.ok).toBe(true); + expect(r.usable?.host).toBe('codex'); + }); + + it('refuses a receipt for a different revision', () => { + const stale = { ...pass, revision: 'b'.repeat(40) }; + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: stale, readError: null }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('does not match HEAD'); + }); + + it('refuses a FAIL verdict', () => { + const failed = { ...pass, verdict: 'FAIL' }; + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: failed, readError: null }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('not PASS'); + }); + + it('refuses a receipt recorded against a dirty working tree', () => { + const dirty = { ...pass, dirty: true }; + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: dirty, readError: null }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('dirty'); + }); + + it('refuses a missing file rather than treating absence as pass', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: null, readError: 'not found' }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('not found'); + }); + + it('refuses an unreadable file distinctly from a missing one', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: null, readError: 'Unexpected token' }], HEAD); + expect(r.reasons.join('\n')).toContain('unreadable — Unexpected token'); + }); + + it('refuses a report of the wrong shape (not a memesh-live-journey/v1 report)', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: { foo: 'bar' }, readError: null }], HEAD); + expect(r.ok).toBe(false); + expect(r.reasons.join('\n')).toContain('not a memesh-live-journey/v1 report'); + }); + + it('falls through to the second candidate when the first is unusable', () => { + const r = findUsableLiveJourneyReceipt( + [ + { host: 'codex', path: '.qa/codex-report.json', report: null, readError: 'not found' }, + { host: 'claude', path: '.qa/claude-report.json', report: pass, readError: null }, + ], + HEAD, + ); + expect(r.ok).toBe(true); + expect(r.usable?.host).toBe('claude'); + }); + + it('refuses with no candidates rather than vacuously passing', () => { + expect(findUsableLiveJourneyReceipt([], HEAD).ok).toBe(false); + }); + + it('refuses when headSha itself is unknown, rather than matching against null', () => { + const r = findUsableLiveJourneyReceipt([{ host: 'codex', path: '.qa/codex-report.json', report: pass, readError: null }], null); + expect(r.ok).toBe(false); + }); + + it('LIVE_JOURNEY_RECEIPT_PATHS names both hosts under the gitignored .qa/ directory', () => { + const hosts = LIVE_JOURNEY_RECEIPT_PATHS.map((c: { host: string }) => c.host).sort(); + expect(hosts).toEqual(['claude', 'codex']); + for (const c of LIVE_JOURNEY_RECEIPT_PATHS) { + expect((c as { relativePath: string }).relativePath.startsWith('.qa/')).toBe(true); + } + }); }); describe('release notes come from the CHANGELOG section', () => { @@ -272,6 +403,28 @@ describe('finish-release cuts the release in one call', () => { const pkg = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); expect(pkg.scripts['release:finish']).toContain('scripts/finish-release.mjs'); }); + + it('runs `npm run qa:pre-release` itself, gating on its real exit code (G4)', () => { + expect(code).toMatch(/'run',\s*'qa:pre-release'/); + expect(code).toMatch(/qaPreReleaseStatus/); + }); + + it('checks for a qa:live-journey receipt for THIS commit, not merely that the command exists (G4)', () => { + expect(code).toMatch(/findUsableLiveJourneyReceipt/); + expect(code).toMatch(/LIVE_JOURNEY_RECEIPT_PATHS/); + }); + + it('fails fast on the wrong branch before spending minutes on qa:pre-release', () => { + // The wrong-branch exit and the expensive spawn are both pinned, and in + // that order — deleting the fast-fail (or moving it after the spawn) + // would satisfy every other test in this file while reintroducing the + // multi-minute wait on a one-line mistake this check exists to avoid. + const branchGuardAt = code.indexOf("branch !== 'main'"); + const qaSpawnAt = code.indexOf("'run', 'qa:pre-release'"); + expect(branchGuardAt, 'no fast-fail branch guard found').toBeGreaterThan(-1); + expect(qaSpawnAt, 'the qa:pre-release spawn is not there — this file is not what it was').toBeGreaterThan(-1); + expect(branchGuardAt).toBeLessThan(qaSpawnAt); + }); }); describe('shipped paths come from package.json, not a hand-kept list', () => { diff --git a/tests/upgrade-plugin-cache-sweep.test.ts b/tests/upgrade-plugin-cache-sweep.test.ts new file mode 100644 index 000000000..072e9d539 --- /dev/null +++ b/tests/upgrade-plugin-cache-sweep.test.ts @@ -0,0 +1,135 @@ +/** + * `sweep_stale_cache_versions` in scripts/upgrade-plugin.sh (D9). + * + * Measured on a real machine: 9 stale version directories under + * `~/.claude/plugins/cache/pcircle-memesh/memesh/`, 1.2 GB, with nothing + * ever removing them — the script's own swap logic only ever deletes the + * ONE version it just replaced, so a directory left behind by an + * interrupted or pre-swap-mechanism upgrade had no path back to zero. + * + * The test sources the real script with `MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY=1` + * (a guard defined at the top of the script, before anything that touches a + * real Claude Code marketplace checkout) and calls the function directly — + * exercising the shipped code, not a hand-copied reimplementation of it. + * POSIX-only: the script is bash and the Windows CI runner has no bash + * shell in the sense this script needs. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execFileSync } from 'child_process'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +const scriptPath = path.resolve(__dirname, '../scripts/upgrade-plugin.sh'); +const posixOnly = it.skipIf(process.platform === 'win32'); + +let root: string; + +beforeEach(() => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'memesh-cache-sweep-')); +}); + +afterEach(() => { + fs.rmSync(root, { recursive: true, force: true }); +}); + +function sweep(keepVersion: string, alsoKeep?: string): void { + execFileSync( + 'bash', + ['-c', `source "${scriptPath}"; sweep_stale_cache_versions "$1" "$2" "$3"`, '--', root, keepVersion, alsoKeep ?? ''], + { env: { ...process.env, MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY: '1' } }, + ); +} + +function makeDir(name: string, withFile = false): void { + const dir = path.join(root, name); + fs.mkdirSync(dir, { recursive: true }); + if (withFile) fs.writeFileSync(path.join(dir, 'marker'), 'x'); +} + +describe('sweep_stale_cache_versions (D9)', () => { + posixOnly('removes every semver-named directory except the one being kept', () => { + makeDir('4.8.1', true); + makeDir('4.8.2', true); + makeDir('4.8.3', true); + makeDir('4.8.4', true); + + sweep('4.8.4'); + + const remaining = fs.readdirSync(root).sort(); + expect(remaining).toEqual(['4.8.4']); + expect(fs.existsSync(path.join(root, '4.8.4', 'marker'))).toBe(true); + }); + + posixOnly('leaves a non-semver entry alone', () => { + makeDir('4.8.3'); + makeDir('4.8.4'); + makeDir('not-a-version'); + + sweep('4.8.4'); + + expect(fs.readdirSync(root).sort()).toEqual(['4.8.4', 'not-a-version']); + }); + + posixOnly('leaves an in-flight .staging-*/.previous-* marker alone', () => { + makeDir('4.8.3'); + makeDir('4.8.4'); + makeDir('.staging-4.8.5-12345'); + makeDir('.previous-4.8.5-12345'); + + sweep('4.8.4'); + + const remaining = fs.readdirSync(root, { withFileTypes: true }) + .map((e) => e.name) + .sort(); + expect(remaining).toEqual(['.previous-4.8.5-12345', '.staging-4.8.5-12345', '4.8.4']); + }); + + posixOnly('does nothing, without error, when the cache root does not exist yet', () => { + const missing = path.join(root, 'does-not-exist'); + expect(() => + execFileSync( + 'bash', + ['-c', `source "${scriptPath}"; sweep_stale_cache_versions "$1" "$2"`, '--', missing, '4.8.4'], + { env: { ...process.env, MEMESH_UPGRADE_PLUGIN_SOURCE_ONLY: '1' } }, + ), + ).not.toThrow(); + expect(fs.existsSync(missing)).toBe(false); + }); + + posixOnly('is a no-op on an empty cache root', () => { + sweep('4.8.4'); + expect(fs.readdirSync(root)).toEqual([]); + }); + + posixOnly('keeps a version that happens to equal the only entry present', () => { + makeDir('4.8.4', true); + sweep('4.8.4'); + expect(fs.readdirSync(root)).toEqual(['4.8.4']); + expect(fs.existsSync(path.join(root, '4.8.4', 'marker'))).toBe(true); + }); + + posixOnly('the optional third argument protects a second, non-semver-shaped name too', () => { + // The registry's own recorded install path — upgrade-plugin.sh's + // "noncanonical … repairing it" path deliberately leaves this exact + // directory alone for a human to inspect, whatever it is named. + makeDir('4.7.9', true); + makeDir('4.8.3'); + makeDir('4.8.4', true); + + sweep('4.8.4', '4.7.9'); + + const remaining = fs.readdirSync(root).sort(); + expect(remaining).toEqual(['4.7.9', '4.8.4']); + expect(fs.existsSync(path.join(root, '4.7.9', 'marker'))).toBe(true); + }); + + posixOnly('an empty third argument protects nothing extra', () => { + makeDir('4.8.3'); + makeDir('4.8.4'); + + sweep('4.8.4', ''); + + expect(fs.readdirSync(root)).toEqual(['4.8.4']); + }); +});