Files
ECC/tests/pi/pi-extension-adapter.test.js
T
wellkiloandhaelyra a50ee13cac feat: execute bounded local Hookify rules in lifecycle hooks
Carry forward PR #3055 at 92f53c4c onto ef648e01, preserving contributor attribution and current main. Focused maintainer fixes reuse shared input ceilings, preserve late root context through outer bootstrap, bound frontmatter before immutable parsing, and assert real oversized input in the limits workflow.
2026-10-09 15:59:20 -04:00

1619 lines
72 KiB
JavaScript

/**
* Tests for the ECC <-> Pi coding agent thin adapter (.pi/extensions/index.ts).
*
* This adapter was rejected once already (PR #2352) for four defects:
* (a) resolving hook scripts from `process.cwd()` instead of the installed
* ECC package root, which breaks global installs;
* (b) running hooks through an interpolated shell string
* (`exec(\`node ${scriptPath}\`)`), which breaks on paths with spaces
* and is a shell-injection risk;
* (c) using the undocumented `app.events` bus instead of the documented
* `pi.on(...)` lifecycle API;
* (d) shipping with no compatibility tests at all.
*
* Group 1 below reads `.pi/extensions/index.ts` as text and asserts the
* source contract that keeps those defects from coming back. The file is
* TypeScript loaded by Pi through jiti at runtime, so it cannot be
* `require()`d or `import()`ed from a plain Node test — source inspection is
* the only option available without adding a build step or a new dependency.
*
* Group 2 exercises ECC's real hook runner (`scripts/hooks/run-with-flags.js`)
* with the exact argv/env shape the adapter builds, so the fix is proven by
* behavior, not just by grep.
*
* Group 3 covers three fixes a code review added on top of the above: EPIPE
* isolation on `child.stdin`, clearing stale `pendingContext` at session
* start, and reading companion-package installs from Pi's own settings files
* instead of `require.resolve`. Each fix gets a source-text assertion (so a
* regression is caught even if the behavioral mirror still passes) plus a
* real behavioral test wherever the fix is about runtime behavior rather
* than pure control flow.
*
* Group 4 covers the adapter's injection of ECC's canonical engineering rules
* into Pi's system prompt (`PORTABLE_RULE_FILES`, `loadPortableRules`,
* `isDisabledByEnv`, and the expanded `before_agent_start` handler). The core
* constraint under test is that rules are read at RUNTIME from the canonical
* `rules/common/` directory of the installed package — nothing is copied or
* generated into `.pi/`. Each test pairs a source-text assertion (so a
* regression in the real adapter fails even if a behavioral mirror still
* passes) with either a real-filesystem check against this repo's actual
* `rules/common/` files or a hand-copied mirror of the adapter's own logic.
*/
const assert = require("assert")
const fs = require("fs")
const os = require("os")
const path = require("path")
const { spawnSync, execFile } = require("child_process")
const { resolveHookRuntime } = require(
path.join(__dirname, "..", "..", ".pi", "extensions", "hook-runtime.js")
)
/** Run a single adapter test and report the result. */
async function runTest(name, fn) {
try {
await fn()
console.log(` ✓ ${name}`)
return true
} catch (error) {
console.log(` ✗ ${name}`)
console.error(` ${error.message}`)
return false
}
}
/**
* Strips `/* ... *\/` and `// ...` comments so the "never resolves from
* process.cwd()" check tests real behavior, not a doc comment. The adapter's
* own header comment explains the anti-pattern by naming it in backticks
* (`"never `process.cwd()`, so a global pi install works..."`), which is
* correct documentation, not a regression — the check must look past it.
*/
function stripComments(source) {
return source.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/.*$/gm, "")
}
/**
* Invokes ECC's hook runner like the adapter (`runEccHook` in
* .pi/extensions/index.ts): it uses the test host's Node executable with the
* same argv shape and JSON payload on stdin. No shell is used.
*/
function runHookRunner(eccRoot, hookId, relScript, profiles, payload, extraEnv, cwd) {
const runner = path.join(eccRoot, "scripts", "hooks", "run-with-flags.js")
return spawnSync(process.execPath, [runner, hookId, relScript, profiles], {
input: JSON.stringify(payload),
encoding: "utf8",
cwd: cwd || eccRoot,
timeout: 30000,
env: { ...process.env, CLAUDE_PLUGIN_ROOT: eccRoot, ECC_PLUGIN_ROOT: eccRoot, ...extraEnv },
})
}
/**
* Builds a minimal, standalone ECC package skeleton under a fresh temp
* directory so tests 8/9 can simulate a global install without touching the
* real repo. Only the files `run-with-flags.js` -> `session-end-marker.js`
* actually `require()` at runtime are copied.
*/
function buildEccSkeleton(repoRoot) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "ecc pi test-"))
const hooksDir = path.join(root, "scripts", "hooks")
fs.mkdirSync(hooksDir, { recursive: true })
for (const name of [
"hook-input.js",
"hook-input-limits.js",
"run-with-flags.js",
"session-end-marker.js",
"pretooluse-visible-output.js",
]) {
fs.cpSync(path.join(repoRoot, "scripts", "hooks", name), path.join(hooksDir, name))
}
fs.cpSync(path.join(repoRoot, "scripts", "lib"), path.join(root, "scripts", "lib"), { recursive: true })
return root
}
/**
* Mirror of the adapter's `extractAdditionalContext` (same file, same six
* lines of logic) so the parsing contract can be exercised directly without
* importing the TypeScript source. This copy proves the *behavior* below is
* correct, but a copy cannot detect the real adapter's guards drifting out
* from under it. The source-text assertions in the "additionalContext
* extraction tolerates non-JSON hook passthrough" test below read the real
* `extractAdditionalContext` out of `.pi/extensions/index.ts` and pin its
* guards directly, so that kind of drift fails the test instead of passing
* silently against this mirror.
*/
function extractAdditionalContext(stdout) {
const trimmed = stdout.trim()
if (!trimmed.startsWith("{")) {
return undefined
}
try {
const parsed = JSON.parse(trimmed)
const context = parsed.hookSpecificOutput && parsed.hookSpecificOutput.additionalContext
return typeof context === "string" && context.trim() ? context : undefined
} catch {
return undefined
}
}
/**
* Mirror of the adapter's `normalizePiPackageName` (same file, same handful of
* lines) so the object-form unwrapping and the trailing-@version stripping can
* be exercised directly without importing the TypeScript source. This copy
* proves the *behavior* below is correct, but a copy cannot detect the real
* adapter's guards drifting out from under it. The source-text assertions in
* the "companion package detection reads Pi's package list" test below read
* the real `normalizePiPackageName` text out of `.pi/extensions/index.ts` and
* pin its actual guards directly, so that kind of drift fails the test instead
* of passing silently against this mirror.
*/
function normalizePiPackageName(entry) {
const source = entry && typeof entry === "object" ? entry.source : entry
if (typeof source !== "string" || !source.startsWith("npm:")) {
return undefined
}
const spec = source.slice("npm:".length)
// Strip a trailing @version without breaking the leading @ of a scoped name.
const versionAt = spec.lastIndexOf("@")
return versionAt > 0 ? spec.slice(0, versionAt) : spec
}
/**
* Mirror of the per-file body of the adapter's `listInstalledPiPackages`
* (same file, same read-parse-normalize-collect loop), applied to a single
* settings file so the "npm entries only, missing/malformed settings degrade
* to empty" contract can be exercised against a real temp file without
* importing the TypeScript source or touching a real `~/.pi/agent`
* directory. This copy proves the *behavior* below is correct, but a copy
* cannot detect the real adapter's guards drifting out from under it. The
* source-text assertions in the "companion package detection reads Pi's
* package list" test above read the real `listInstalledPiPackages` /
* `normalizePiPackageName` text out of `.pi/extensions/index.ts` and pin its
* actual guards directly, so that kind of drift fails the test instead of
* passing silently against this mirror.
*/
function readInstalledPackageNames(settingsFile) {
const names = new Set()
try {
const parsed = JSON.parse(fs.readFileSync(settingsFile, "utf8"))
if (!Array.isArray(parsed.packages)) {
return names
}
for (const entry of parsed.packages) {
const name = normalizePiPackageName(entry)
if (name) {
names.add(name)
}
}
} catch {
// Missing or unreadable settings are simply "nothing installed here".
}
return names
}
/**
* Mirror of the adapter's `findInstalledCompanion` (same file, same matching
* rule) so the exact-match and unscoped-satisfied-by-scoped cases can be
* exercised directly without importing the TypeScript source. This copy
* proves the *behavior* below is correct, but a copy cannot detect the real
* adapter's rule drifting out from under it. The source-text assertions in
* the "companion package detection tolerates a scoped fork" test below read
* the real `findInstalledCompanion` text out of `.pi/extensions/index.ts` and
* pin its actual guards directly.
*/
function findInstalledCompanion(companion, installed) {
if (installed.has(companion)) {
return companion
}
if (companion.startsWith("@")) {
return undefined
}
const scopedSuffix = `/${companion}`
for (const name of installed) {
if (name.startsWith("@") && name.endsWith(scopedSuffix)) {
return name
}
}
return undefined
}
/**
* Parses the `PORTABLE_RULE_FILES` array literal out of `.pi/extensions/index.ts`
* by text, so the real-filesystem-existence test and the `loadPortableRules`
* behavioral mirror below follow the constant instead of hardcoding the file
* list and silently drifting from it.
*/
function parsePortableRuleFiles(source) {
const constStart = source.indexOf("const PORTABLE_RULE_FILES")
if (constStart === -1) {
return []
}
const constEnd = source.indexOf("]", constStart)
if (constEnd === -1) {
return []
}
const constBody = source.slice(constStart, constEnd + 1)
return Array.from(constBody.matchAll(/["'`]([\w.-]+\.md)["'`]/g)).map(match => match[1])
}
/**
* Parses the numeric value of `MAX_RULES_BYTES` (e.g. `32 * 1024`) out of
* `.pi/extensions/index.ts`, so the cap assertion in the `loadPortableRules`
* behavioral mirror below follows the real constant instead of a hardcoded
* number. The captured expression is validated against a digits/operators
* whitelist before evaluation, so this never executes arbitrary source text.
*/
function parseMaxRulesBytes(source) {
const match = source.match(/const\s+MAX_RULES_BYTES\s*=\s*([0-9_ \t*/+-]+)/)
if (!match) {
return undefined
}
const expression = match[1].trim()
if (!expression || !/^[0-9_ \t*+]+$/.test(expression)) {
return undefined
}
// Evaluate sums of products directly instead of through Function(): the
// constant is only ever a literal like `32 * 1024`, and a test helper has no
// business compiling code at runtime.
const total = expression
.replace(/_/g, "")
.split("+")
.reduce((sum, term) => {
const product = term.split("*").reduce((acc, factor) => acc * Number(factor.trim()), 1)
return sum + product
}, 0)
return Number.isFinite(total) ? total : undefined
}
/**
* Mirror of the adapter's `loadPortableRules` (same file, same
* read-trim-skip-cap-join loop over `rules/common/<file>`, same
* `"\n\n---\n\n"` join). Deliberately omits the `ECC_PI_RULES` disable check
* and the `cachedRules` memoization, which are exercised separately (the
* disable check via `isDisabledByEnv` below; memoization is pure control
* flow with no behavior to mirror). This copy proves the *behavior* below is
* correct, but a copy cannot detect the real adapter's guards drifting out
* from under it. The source-text assertions in the "PORTABLE_RULE_FILES ..."
* and "engineering rules are read from rules/common ..." tests below read the
* real constant, the real `rules/common` path, and the real `MAX_RULES_BYTES`
* value out of `.pi/extensions/index.ts` and pin them directly, so that kind
* of drift fails those tests instead of passing silently against this mirror.
*/
function loadPortableRulesMirror(rootDir, ruleFiles, maxBytes) {
const sections = []
let total = 0
for (const file of ruleFiles) {
let text
try {
text = fs.readFileSync(path.join(rootDir, "rules", "common", file), "utf8").trim()
} catch {
continue
}
if (!text) {
continue
}
if (total + text.length > maxBytes) {
break
}
total += text.length
sections.push(text)
}
return sections.length > 0 ? sections.join("\n\n---\n\n") : null
}
/**
* Mirror of the adapter's `isDisabledByEnv` (same `DISABLED_VALUES` set, same
* trim + lowercase normalization). This copy proves the *behavior* below is
* correct, but a copy cannot detect the real adapter's guard drifting out
* from under it. The source-text assertion in the "isDisabledByEnv ..." test
* below reads the real function and the real `ECC_PI_RULES` env var name out
* of `.pi/extensions/index.ts` and pins them directly.
*/
const DISABLED_VALUES_MIRROR = new Set(["0", "false", "off", "none", "disabled"])
function isDisabledByEnvMirror(value) {
return typeof value === "string" && DISABLED_VALUES_MIRROR.has(value.trim().toLowerCase())
}
/** Run the Pi adapter regression suite. */
async function main() {
console.log("\n=== Testing .pi/extensions/index.ts (Pi thin adapter) ===\n")
let passed = 0
let failed = 0
const repoRoot = path.join(__dirname, "..", "..")
const extensionPath = path.join(repoRoot, ".pi", "extensions", "index.ts")
const extensionSource = fs.readFileSync(extensionPath, "utf8")
const tests = [
// ---- Group 1: source contract -------------------------------------
["resolves the ECC package root from __dirname, never from process.cwd()", () => {
assert.ok(
extensionSource.includes("path.resolve(__dirname"),
"expected the adapter to derive its package root with path.resolve(__dirname, ...); " +
"resolving from __dirname is what makes a globally installed ECC find its own hooks " +
"regardless of which project the user opened Pi in"
)
const withoutComments = stripComments(extensionSource)
assert.ok(
!withoutComments.includes("process.cwd()"),
"found process.cwd() used as executable code in .pi/extensions/index.ts; " +
"resolving hook scripts from the working directory breaks global installs " +
"because it looks for ECC's hooks inside the user's project instead of the " +
"installed ECC package (this is the exact defect PR #2352 was rejected for)"
)
}],
["executes hooks via execFile with no shell, so paths with spaces or metacharacters are safe", () => {
assert.ok(
extensionSource.includes("execFile("),
"expected the adapter to invoke hooks via child_process.execFile(...)"
)
assert.ok(
extensionSource.includes("resolveHookRuntime"),
"expected the adapter to select a hook runtime before execFile(...)"
)
const shellExecPattern = /(?<!execFile)\bexec\s*\(/
assert.ok(
!shellExecPattern.test(extensionSource),
"found a shell-invoking exec(...) call in .pi/extensions/index.ts distinct from " +
"execFile(...); running hooks through an interpolated shell string " +
"(exec(`node ${scriptPath}`)) breaks on paths containing spaces and is a " +
"shell-injection risk (the exact defect PR #2352 was rejected for)"
)
assert.ok(
!extensionSource.includes("execSync("),
"found execSync(...) in .pi/extensions/index.ts; execSync runs through a shell " +
"by default and reintroduces the same path-with-spaces / injection risk"
)
assert.ok(
!extensionSource.includes("shell: true"),
"found `shell: true` in .pi/extensions/index.ts; opting into a shell reintroduces " +
"the path-with-spaces / injection risk execFile(...) with no shell was meant to avoid"
)
}],
["selects a real Node runtime instead of compiled OMP's masquerading process.execPath", () => {
const runtimeSource = fs.readFileSync(
path.join(repoRoot, ".pi", "extensions", "hook-runtime.js"),
"utf8"
)
const runHookStart = extensionSource.indexOf("function runEccHook")
const runHookEnd = extensionSource.indexOf("function resolveHookCwd")
const runHookSource = extensionSource.slice(runHookStart, runHookEnd)
const beforeRunHookSource = extensionSource.slice(0, runHookStart)
assert.ok(
extensionSource.includes('from "./hook-runtime.js"'),
"expected the adapter to import the shared hook runtime selector"
)
assert.ok(
!beforeRunHookSource.includes("resolveHookRuntime()") &&
/try\s*\{\s*hookRuntime = resolveHookRuntime\(\)\s*\}\s*catch/.test(runHookSource) &&
/execFile\(\s*hookRuntime,/.test(runHookSource),
"expected runEccHook to resolve its runtime inside the guarded hook path rather than " +
"during module initialization"
)
assert.ok(
runtimeSource.includes("process.versions?.bun") &&
runtimeSource.includes("path.basename(execPath)") &&
runtimeSource.includes("path.isAbsolute(overridePath)") &&
runtimeSource.includes(
'throw new Error("ECC_HOOK_NODE must be an absolute path: " + overridePath)'
),
"expected the selector to reject Bun/OMP runtimes, require absolute overrides, and " +
"fall back to PATH node"
)
}],
["resolves hook runtimes across Node, compiled OMP, and explicit override cases", () => {
assert.strictEqual(
resolveHookRuntime({ execPath: "/usr/bin/node", override: "" }),
"/usr/bin/node"
)
assert.strictEqual(
resolveHookRuntime({ execPath: "/usr/bin/nodejs", override: "" }),
"/usr/bin/nodejs"
)
assert.strictEqual(
resolveHookRuntime({
execPath: "/usr/bin/node",
bunVersion: "1.4.0",
override: "",
}),
"node"
)
assert.strictEqual(
resolveHookRuntime({
execPath: "/usr/bin/node",
releaseName: "bun",
override: "",
}),
"node"
)
assert.strictEqual(
resolveHookRuntime({
execPath: "/home/user/.omp/bin/omp",
releaseName: "node",
override: "",
}),
"node"
)
assert.throws(
() =>
resolveHookRuntime({
execPath: "/usr/bin/node",
override: "./node",
}),
/ECC_HOOK_NODE must be an absolute path: \.\/node/
)
assert.strictEqual(
resolveHookRuntime({
execPath: "/usr/bin/node",
override: " /opt/node/bin/node ",
}),
"/opt/node/bin/node"
)
assert.strictEqual(
resolveHookRuntime({
execPath: "/home/user/.omp/bin/omp",
bunVersion: "1.4.0",
override: " /opt/node/bin/node ",
}),
"/opt/node/bin/node"
)
}],
["registers Pi's documented pi.on(...) lifecycle, not the undocumented app.events bus", () => {
assert.ok(
extensionSource.includes(`pi.on("session_start"`),
"expected the adapter to register a session_start handler via pi.on(...)"
)
assert.ok(
extensionSource.includes(`pi.on("session_shutdown"`),
"expected the adapter to register a session_shutdown handler via pi.on(...)"
)
assert.ok(
extensionSource.includes(`pi.on("before_agent_start"`),
"expected the adapter to register a before_agent_start handler via pi.on(...)"
)
assert.ok(
!extensionSource.includes("app.events"),
"found app.events in .pi/extensions/index.ts; app.events is an undocumented " +
"event-bus API that is not part of Pi's supported extension contract and can " +
"change or disappear without notice"
)
assert.ok(
!extensionSource.includes(".events.on("),
"found a .events.on(...) subscription in .pi/extensions/index.ts; subscribing " +
"through an undocumented event bus instead of the documented pi.on(...) " +
"lifecycle is not part of Pi's supported extension contract"
)
}],
["registers the ecc-doctor diagnostics command", () => {
assert.ok(
extensionSource.includes(`registerCommand("ecc-doctor"`),
"expected the adapter to register an 'ecc-doctor' command via pi.registerCommand(...) " +
"so users have an install-diagnostics entry point"
)
}],
["bounds hook execution with a timeout and a maxBuffer", () => {
assert.ok(
extensionSource.includes("timeout"),
"expected the execFile(...) call options to include a timeout; an unbounded hook " +
"process can hang the Pi session forever on a stuck or misbehaving hook"
)
assert.ok(
extensionSource.includes("maxBuffer"),
"expected the execFile(...) call options to include a maxBuffer; without it a " +
"runaway hook writing unbounded stdout can crash the adapter process"
)
}],
["exports a default extension factory function", () => {
assert.ok(
extensionSource.includes("export default function"),
"expected .pi/extensions/index.ts to `export default function`, matching the " +
"shape Pi's extension loader expects"
)
}],
["propagates the ECC package root to hooks via CLAUDE_PLUGIN_ROOT and ECC_PLUGIN_ROOT", () => {
assert.ok(
extensionSource.includes("CLAUDE_PLUGIN_ROOT"),
"expected the adapter to set CLAUDE_PLUGIN_ROOT in the hook environment; ECC's " +
"shared hook scripts read this to locate the package root"
)
assert.ok(
extensionSource.includes("ECC_PLUGIN_ROOT"),
"expected the adapter to set ECC_PLUGIN_ROOT in the hook environment; this is " +
"the ECC-specific fallback the same hook scripts also read"
)
}],
// ---- Group 2: real hook-runner behavior ---------------------------
["GLOBAL INSTALL + SPACE IN PATH: hook execution succeeds from a package root whose path contains a space", () => {
const skeletonRoot = buildEccSkeleton(repoRoot)
try {
assert.ok(
skeletonRoot.includes(" "),
"test setup bug: the temp skeleton directory must contain a space to reproduce " +
"a global-install path (e.g. 'Application Support') — got: " + skeletonRoot
)
const result = runHookRunner(
skeletonRoot,
"session:end:marker",
"scripts/hooks/session-end-marker.js",
"minimal,standard,strict",
{ hook_event_name: "SessionEnd", reason: "quit", cwd: skeletonRoot, session_id: "pi-adapter-test" }
)
assert.strictEqual(
result.error,
undefined,
"hook runner failed to spawn from a package root containing a space " +
`(${skeletonRoot}); this is exactly the shell-interpolation regression ` +
`PR #2352 was rejected for (error: ${result.error && result.error.message})`
)
assert.strictEqual(
result.status,
0,
"hook runner exited non-zero when invoked from a package root containing a " +
`space (${skeletonRoot}); a path with a space broke hook execution ` +
`(stderr: ${result.stderr})`
)
} finally {
fs.rmSync(skeletonRoot, { recursive: true, force: true })
}
}],
["hook resolution is package-relative, not cwd-relative: still succeeds when cwd points elsewhere", () => {
const skeletonRoot = buildEccSkeleton(repoRoot)
try {
const result = runHookRunner(
skeletonRoot,
"session:end:marker",
"scripts/hooks/session-end-marker.js",
"minimal,standard,strict",
{ hook_event_name: "SessionEnd", reason: "quit", cwd: os.tmpdir(), session_id: "pi-adapter-test" },
{},
os.tmpdir()
)
assert.strictEqual(
result.error,
undefined,
"hook runner failed to spawn when cwd pointed away from the ECC package root; " +
"a globally installed ECC must resolve its own hooks regardless of which " +
`project directory the user is in (error: ${result.error && result.error.message})`
)
assert.strictEqual(
result.status,
0,
"hook runner exited non-zero when cwd pointed away from the ECC package root " +
`(cwd=${os.tmpdir()}, CLAUDE_PLUGIN_ROOT=${skeletonRoot}); this means hook ` +
"resolution is leaking cwd-dependence instead of being package-relative " +
`(stderr: ${result.stderr})`
)
} finally {
fs.rmSync(skeletonRoot, { recursive: true, force: true })
}
}],
["profile gating is honored: a disabled hook and a restrictive profile both degrade cleanly", () => {
// Uses the same isolated skeleton as tests 8/9 (not repoRoot) so that
// session-end-marker.js never executes against the real checkout: a
// real run can leave marker artifacts behind and would make this
// test's outcome depend on whatever state the repo happens to be in.
const skeletonRoot = buildEccSkeleton(repoRoot)
try {
const disabledResult = runHookRunner(
skeletonRoot,
"session:end:marker",
"scripts/hooks/session-end-marker.js",
"minimal,standard,strict",
{ hook_event_name: "SessionEnd", reason: "quit", cwd: skeletonRoot, session_id: "pi-adapter-test" },
{ ECC_DISABLED_HOOKS: "session:end:marker" }
)
assert.strictEqual(
disabledResult.error,
undefined,
"hook runner failed to spawn when session:end:marker was listed in " +
`ECC_DISABLED_HOOKS (error: ${disabledResult.error && disabledResult.error.message})`
)
assert.strictEqual(
disabledResult.status,
0,
"hook runner exited non-zero for a hook disabled via ECC_DISABLED_HOOKS; a " +
"disabled hook must be skipped cleanly rather than crashing the Pi session " +
`(stderr: ${disabledResult.stderr})`
)
const minimalResult = runHookRunner(
skeletonRoot,
"session:end:marker",
"scripts/hooks/session-end-marker.js",
"minimal,standard,strict",
{ hook_event_name: "SessionEnd", reason: "quit", cwd: skeletonRoot, session_id: "pi-adapter-test" },
{ ECC_HOOK_PROFILE: "minimal" }
)
assert.strictEqual(
minimalResult.error,
undefined,
"hook runner failed to spawn under ECC_HOOK_PROFILE=minimal " +
`(error: ${minimalResult.error && minimalResult.error.message})`
)
assert.strictEqual(
minimalResult.status,
0,
"hook runner exited non-zero under ECC_HOOK_PROFILE=minimal; hook-profile " +
`gating must degrade cleanly, not crash the session (stderr: ${minimalResult.stderr})`
)
} finally {
fs.rmSync(skeletonRoot, { recursive: true, force: true })
}
}],
["additionalContext extraction tolerates non-JSON hook passthrough", () => {
// ---- Behavioral assertions on the LOCAL MIRROR --------------------
// extractAdditionalContext (defined above) is a hand-copied mirror of
// the real function in .pi/extensions/index.ts, kept because that file
// is TypeScript loaded via jiti and cannot be require()'d from a plain
// Node test. These assertions prove the mirror's behavior; they do NOT
// by themselves prove the shipped adapter still behaves this way. The
// source-text assertions further below read the real function's text
// out of .pi/extensions/index.ts and pin its actual guards, so that a
// real adapter regression fails here even though the mirror (and the
// assertions run against it) would keep passing unchanged.
assert.strictEqual(
extractAdditionalContext('{"hookSpecificOutput":{"additionalContext":"hello"}}'),
"hello",
"expected additionalContext to be extracted from a well-formed hook envelope"
)
assert.strictEqual(
extractAdditionalContext("plain non-JSON stdout from a disabled hook"),
undefined,
"expected non-JSON stdout (the pass-through case for a disabled hook) to yield " +
"undefined instead of throwing or crashing the session_start handler"
)
assert.strictEqual(
extractAdditionalContext('{"hookSpecificOutput": malformed'),
undefined,
"expected malformed JSON to yield undefined instead of throwing"
)
assert.strictEqual(
extractAdditionalContext('{"unrelated":true}'),
undefined,
"expected valid JSON with no hookSpecificOutput.additionalContext field to yield undefined"
)
assert.strictEqual(
extractAdditionalContext('{"hookSpecificOutput":{"additionalContext":""}}'),
undefined,
"expected an empty-string additionalContext to yield undefined rather than an " +
"empty <ecc-session-context> block being spliced into the system prompt"
)
// ---- Source-text assertions on the REAL adapter -------------------
// Isolate the real extractAdditionalContext function's text out of
// .pi/extensions/index.ts (up to the next top-level function
// declaration) and pin its actual guards. If the adapter's real
// startsWith("{") check, try/catch, hookSpecificOutput?.additionalContext
// read, or non-empty-string requirement ever changes, these fail
// regardless of what the mirror above still does.
const functionStart = extensionSource.indexOf("function extractAdditionalContext")
assert.ok(
functionStart !== -1,
"expected .pi/extensions/index.ts to define a function named extractAdditionalContext"
)
const nextFunctionStart = extensionSource.indexOf("\nfunction ", functionStart + 1)
const extractContextSource =
nextFunctionStart === -1
? extensionSource.slice(functionStart)
: extensionSource.slice(functionStart, nextFunctionStart)
assert.ok(
/if\s*\(\s*!\s*trimmed\.startsWith\(\s*["'`]\{["'`]\s*\)\s*\)\s*\{\s*return undefined/.test(
extractContextSource
),
"expected extractAdditionalContext in .pi/extensions/index.ts to early-return " +
"undefined unless the trimmed stdout starts with '{'; this is what makes " +
"non-JSON stdout from a disabled hook a safe pass-through instead of a crash"
)
assert.ok(
/try\s*\{[\s\S]*?JSON\.parse\(/.test(extractContextSource),
"expected extractAdditionalContext in .pi/extensions/index.ts to parse the " +
"trimmed stdout via JSON.parse(...) inside a try block"
)
assert.ok(
/catch[^{]*\{\s*return undefined/.test(extractContextSource),
"expected extractAdditionalContext in .pi/extensions/index.ts to catch a " +
"JSON.parse failure and return undefined instead of throwing"
)
assert.ok(
/hookSpecificOutput\?\.\s*additionalContext/.test(extractContextSource),
"expected extractAdditionalContext in .pi/extensions/index.ts to read " +
"hookSpecificOutput?.additionalContext from the parsed envelope"
)
assert.ok(
/typeof\s+context\s*===\s*["'`]string["'`]\s*&&\s*context\.trim\(\)/.test(extractContextSource),
"expected extractAdditionalContext in .pi/extensions/index.ts to require a " +
'non-empty string (typeof context === "string" && context.trim()) before ' +
"returning it, rejecting an empty-string additionalContext"
)
}],
// ---- Group 3: code-review fixes -----------------------------------
["EPIPE isolation (source contract): child.stdin has an error listener, and the catch around child.stdin?.end(...) resolves rather than rethrows", () => {
const withoutComments = stripComments(extensionSource)
assert.ok(
withoutComments.includes('child.stdin?.on("error"'),
"expected runEccHook in .pi/extensions/index.ts to register an error listener on " +
'child.stdin via child.stdin?.on("error", ...) as real code, not just described ' +
"in a comment; stdin.end() writes asynchronously, so a hook that exits before " +
"reading its payload raises an EPIPE `error` event that a try/catch around " +
"child.stdin?.end(...) cannot see, and an unhandled `error` event on a stream " +
"crashes the whole Pi session"
)
const runEccHookStart = extensionSource.indexOf("function runEccHook")
assert.ok(
runEccHookStart !== -1,
"expected .pi/extensions/index.ts to define a function named runEccHook"
)
const nextFunctionStart = extensionSource.indexOf("\nfunction ", runEccHookStart + 1)
const runEccHookSource =
nextFunctionStart === -1
? extensionSource.slice(runEccHookStart)
: extensionSource.slice(runEccHookStart, nextFunctionStart)
const catchMatch = runEccHookSource.match(
/try\s*\{\s*child\.stdin\?\.end\([\s\S]*?\)\)\s*\}\s*catch\s*\(error\)\s*\{([\s\S]*?)\n\s*\}\n/
)
assert.ok(
catchMatch,
"expected runEccHook in .pi/extensions/index.ts to wrap child.stdin?.end(...) in " +
"a try { ... } catch (error) { ... } block"
)
const catchBody = catchMatch[1]
assert.ok(
/resolve\(/.test(catchBody),
"expected the catch around child.stdin?.end(...) in .pi/extensions/index.ts to " +
"call resolve(...); if it rethrows instead, a hook payload write failure " +
"escapes the Promise executor as an unhandled exception instead of degrading " +
"to a warning"
)
assert.ok(
!/\bthrow\b/.test(catchBody),
"found a rethrow inside the catch around child.stdin?.end(...) in " +
".pi/extensions/index.ts; this is the exact EPIPE-crashes-the-session " +
"regression the surrounding error handling exists to prevent"
)
}],
["EPIPE isolation (real behavioral proof): a large stdin write to a child that exits without reading it survives as an `error` event or a clean resolution, never an uncaught exception", async () => {
// Mirrors the exact pattern in runEccHook: execFile + process.execPath, an
// `error` listener on child.stdin, and a try/catch around child.stdin.end(...).
// The child below exits immediately without ever reading stdin, so a payload
// larger than the OS pipe buffer (2MB) cannot be written synchronously and
// reliably reproduces the EPIPE this pattern exists to isolate.
const largePayload = "x".repeat(2 * 1024 * 1024)
const uncaughtExceptions = []
const onUncaughtException = error => uncaughtExceptions.push(error)
process.on("uncaughtException", onUncaughtException)
let outcome
try {
outcome = await new Promise((resolve, reject) => {
let stdinErrorSeen = false
let childErrorSeen = false
let writeThrew = false
// Safety net only, not a polling race: the assertions below depend on the
// uncaughtException listener, which fires synchronously with the offending
// event if it happens. This just stops the suite from hanging forever if
// the execFile callback never fires for an unrelated reason.
const safetyNet = setTimeout(
() => reject(new Error("execFile callback never fired within the 5.5s safety window")),
5500
)
const child = execFile(
process.execPath,
["-e", "process.exit(0)"],
{ timeout: 5000, maxBuffer: 1024 * 1024 },
() => {
clearTimeout(safetyNet)
resolve({ stdinErrorSeen, childErrorSeen, writeThrew })
}
)
child.on("error", () => {
childErrorSeen = true
})
child.stdin.on("error", () => {
stdinErrorSeen = true
})
try {
child.stdin.end(largePayload)
} catch {
writeThrew = true
}
})
} finally {
process.off("uncaughtException", onUncaughtException)
}
assert.strictEqual(
uncaughtExceptions.length,
0,
"expected writing a 2MB payload to a child that exits before reading stdin to " +
"never raise an uncaughtException; this is exactly the " +
'EPIPE-crashes-the-Pi-session regression the child.stdin?.on("error", ...) ' +
"listener in runEccHook exists to prevent"
)
assert.ok(
outcome !== undefined,
"expected the execFile callback to fire and the parent process to survive " +
"writing to a child that never reads its stdin, instead of hanging or crashing"
)
}],
["stale context is cleared at session_start before awaiting the hook, and again after injection in before_agent_start", () => {
const sessionStartIdx = extensionSource.indexOf('pi.on("session_start"')
assert.ok(
sessionStartIdx !== -1,
"expected .pi/extensions/index.ts to register a session_start handler via pi.on(...)"
)
const beforeAgentStartIdx = extensionSource.indexOf('pi.on("before_agent_start"', sessionStartIdx)
assert.ok(
beforeAgentStartIdx !== -1 && beforeAgentStartIdx > sessionStartIdx,
"expected a before_agent_start handler registered after session_start in .pi/extensions/index.ts"
)
const sessionShutdownIdx = extensionSource.indexOf('pi.on("session_shutdown"', beforeAgentStartIdx)
assert.ok(
sessionShutdownIdx !== -1 && sessionShutdownIdx > beforeAgentStartIdx,
"expected a session_shutdown handler registered after before_agent_start in .pi/extensions/index.ts"
)
const sessionStartSource = stripComments(extensionSource.slice(sessionStartIdx, beforeAgentStartIdx))
const clearIdx = sessionStartSource.indexOf("pendingContext = undefined")
const hookCallIdx = sessionStartSource.indexOf("await runEccHook(")
assert.ok(
clearIdx !== -1,
"expected the session_start handler in .pi/extensions/index.ts to clear " +
"pendingContext = undefined; without this, a new session start can replay " +
"context captured for a previous session"
)
assert.ok(
hookCallIdx !== -1,
"expected the session_start handler in .pi/extensions/index.ts to await runEccHook(...)"
)
assert.ok(
clearIdx < hookCallIdx,
"expected pendingContext = undefined to run BEFORE `await runEccHook(...)` in " +
"the session_start handler; if the clear happens after (or is skipped when " +
"the hook fails), a new session start begun while a previous SessionStart " +
"hook is still running -- or one whose hook later fails -- can replay stale " +
"context captured for the wrong project state"
)
const beforeAgentStartSource = stripComments(
extensionSource.slice(beforeAgentStartIdx, sessionShutdownIdx)
)
// Pin the guarantee (read the value, then clear it, then return) rather
// than one particular spelling of it. The handler injects the context
// inline inside its <ecc-session-context> block instead of copying it to
// a local first; both orders are equivalent in a synchronous handler.
const captureIdx = beforeAgentStartSource.indexOf("<ecc-session-context>")
const clearIdx2 = beforeAgentStartSource.indexOf("pendingContext = undefined")
const returnIdx = beforeAgentStartSource.indexOf("return {")
assert.ok(
captureIdx !== -1,
"expected the before_agent_start handler in .pi/extensions/index.ts to read " +
"pendingContext into an <ecc-session-context> block before clearing it"
)
assert.ok(
clearIdx2 !== -1,
"expected the before_agent_start handler in .pi/extensions/index.ts to still " +
"clear pendingContext = undefined after reading it for injection; without " +
"this, an already-injected context value would be replayed into a later agent turn"
)
assert.ok(
returnIdx !== -1,
"expected the before_agent_start handler in .pi/extensions/index.ts to return " +
"an object with an injected systemPrompt"
)
assert.ok(
captureIdx < clearIdx2,
"expected pendingContext to be read into the injected block BEFORE being " +
"cleared in before_agent_start; clearing first would lose the value before " +
"it can be injected into the system prompt"
)
assert.ok(
clearIdx2 < returnIdx,
"expected pendingContext = undefined to run BEFORE the return statement in " +
"before_agent_start; if the clear is removed or moved past the return it " +
"never executes, and a later agent turn would replay the same context again"
)
}],
["companion package detection reads Pi's package list (source contract): require.resolve is gone, PI_CODING_AGENT_DIR is honored, and normalizePiPackageName's version-stripping guard is pinned", () => {
// require.resolve is legitimately named in the doc comment above
// listInstalledPiPackages to explain why it was replaced (the same
// "documentation, not a regression" case stripComments exists for --
// see its own jsdoc above). Strip comments first so this checks real
// code, not prose.
const withoutComments = stripComments(extensionSource)
assert.ok(
!withoutComments.includes("require.resolve"),
"found require.resolve(...) used as executable code in .pi/extensions/index.ts; " +
"Pi installs companion packages under its own config directory " +
"(~/.pi/agent/npm, overridable via PI_CODING_AGENT_DIR), which is not on " +
"Node's module resolution path from this file, so require.resolve reports " +
"every companion as missing no matter what the user actually installed -- " +
"this is the exact defect listInstalledPiPackages was introduced to replace"
)
assert.ok(
extensionSource.includes("PI_CODING_AGENT_DIR"),
"expected .pi/extensions/index.ts to honor the documented PI_CODING_AGENT_DIR " +
"override when locating Pi's config directory"
)
const normalizeStart = extensionSource.indexOf("function normalizePiPackageName")
assert.ok(
normalizeStart !== -1,
"expected .pi/extensions/index.ts to define a function named normalizePiPackageName"
)
const nextFunctionStart = extensionSource.indexOf("\nfunction ", normalizeStart + 1)
const normalizeSource =
nextFunctionStart === -1
? extensionSource.slice(normalizeStart)
: extensionSource.slice(normalizeStart, nextFunctionStart)
assert.ok(
/typeof\s+entry\s*===\s*["'`]object["'`]\s*\?\s*\(entry\s+as\s*\{\s*source\?:\s*unknown\s*\}\)\.source/.test(
normalizeSource
),
"expected normalizePiPackageName in .pi/extensions/index.ts to read `source` off " +
"an object entry before normalizing; Pi's settings accept both a bare source " +
'string and an object carrying it ({ source: "npm:x", skills: [] }), and a ' +
"package filtered that way is just as installed as a plain one -- treating the " +
"object form as unrecognized makes /ecc-doctor report an installed companion as " +
"missing"
)
assert.ok(
/typeof\s+source\s*!==\s*["'`]string["'`]\s*\|\|\s*!\s*source\.startsWith\(\s*["'`]npm:["'`]\s*\)/.test(
normalizeSource
),
"expected normalizePiPackageName in .pi/extensions/index.ts to return undefined " +
"for any source that is not a string starting with 'npm:' (git sources and " +
"filesystem paths carry no comparable package name)"
)
assert.ok(
/spec\s*=\s*source\.slice\(\s*["'`]npm:["'`]\.length\)/.test(normalizeSource),
'expected normalizePiPackageName in .pi/extensions/index.ts to strip the "npm:" ' +
'prefix via source.slice("npm:".length)'
)
assert.ok(
/versionAt\s*=\s*spec\.lastIndexOf\(\s*["'`]@["'`]\s*\)/.test(normalizeSource),
"expected normalizePiPackageName in .pi/extensions/index.ts to locate a " +
'trailing @version with spec.lastIndexOf("@")'
)
assert.ok(
/versionAt\s*>\s*0\s*\?\s*spec\.slice\(0,\s*versionAt\)\s*:\s*spec/.test(normalizeSource),
"expected normalizePiPackageName in .pi/extensions/index.ts to only strip at " +
"versionAt when it is greater than 0 (versionAt > 0 ? ... : spec); a scoped " +
"package's leading '@' sits at index 0, so this is what keeps " +
"'@juicesharp/rpiv-todo@1.4.2' from being mangled into an empty name the way " +
'a naive split("@")[0] would'
)
}],
["companion package name normalization (behavioral mirror): strips a trailing version without breaking a scoped package name", () => {
assert.strictEqual(
normalizePiPackageName("npm:pi-subagents"),
"pi-subagents",
"expected a plain npm entry with no version to normalize to its bare package name"
)
assert.strictEqual(
normalizePiPackageName("npm:pi-subagents@1.2.3"),
"pi-subagents",
"expected a plain npm entry with a version to have the version stripped"
)
assert.strictEqual(
normalizePiPackageName("npm:@juicesharp/rpiv-todo"),
"@juicesharp/rpiv-todo",
"expected a versionless scoped npm entry to normalize to its full scoped name"
)
assert.strictEqual(
normalizePiPackageName("npm:@juicesharp/rpiv-todo@1.4.2"),
"@juicesharp/rpiv-todo",
"expected a scoped npm entry WITH a version to strip only the trailing version " +
'and keep the scope; a naive split("@")[0] gets this exact case wrong (it ' +
"would return an empty string because the scoped name's leading '@' is not " +
"the version separator)"
)
assert.strictEqual(
normalizePiPackageName("git:https://github.com/example/pi-plugin.git"),
undefined,
"expected a git source to normalize to undefined; it carries no comparable npm package name"
)
assert.strictEqual(
normalizePiPackageName("/Users/example/local-pi-plugin"),
undefined,
"expected a filesystem path entry to normalize to undefined"
)
assert.strictEqual(
normalizePiPackageName(42),
undefined,
"expected a non-string entry to normalize to undefined instead of throwing"
)
assert.strictEqual(
normalizePiPackageName(""),
undefined,
"expected an empty entry to normalize to undefined"
)
}],
["companion package name normalization (behavioral mirror): an object entry with resource filters resolves to the same name as the bare source string", () => {
assert.strictEqual(
normalizePiPackageName({ source: "npm:pi-subagents", skills: [] }),
"pi-subagents",
"expected the object form Pi documents for filtered packages to resolve to the " +
"same name as the bare string; a user who narrows which resources pi-subagents " +
"contributes still has it installed, and /ecc-doctor exists to report exactly that"
)
assert.strictEqual(
normalizePiPackageName({ source: "npm:@juicesharp/rpiv-todo@1.4.2", prompts: ["prompts/review.md"] }),
"@juicesharp/rpiv-todo",
"expected an object entry to go through the same version-stripping path as a " +
"string entry, scope intact"
)
assert.strictEqual(
normalizePiPackageName({ source: "git:github.com/example/pi-plugin@v1" }),
undefined,
"expected an object entry wrapping a git source to stay unrecognized; the source " +
"type decides, not the entry shape"
)
assert.strictEqual(
normalizePiPackageName({ extensions: ["extensions/*.ts"] }),
undefined,
"expected an object entry with no source field to normalize to undefined instead " +
"of throwing"
)
assert.strictEqual(
normalizePiPackageName({ source: 42 }),
undefined,
"expected a non-string source to normalize to undefined instead of throwing"
)
assert.strictEqual(
normalizePiPackageName(null),
undefined,
"expected a null entry to normalize to undefined; typeof null is \"object\", so " +
"this is the case an unguarded object branch would throw on"
)
}],
["companion package detection reads Pi's settings.json (real filesystem): npm entries are recognized, path/git entries are ignored, missing/malformed settings degrade to an empty set", () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-config-dir-test-"))
try {
const settingsFile = path.join(tmpDir, "settings.json")
fs.writeFileSync(
settingsFile,
JSON.stringify({
packages: [
"npm:pi-subagents@2.0.0",
"npm:@juicesharp/rpiv-todo@1.4.2",
"/Users/example/local-pi-plugin",
"git:https://github.com/example/pi-plugin.git",
],
})
)
const installed = readInstalledPackageNames(settingsFile)
assert.strictEqual(
installed.size,
2,
"expected only the two npm: entries to be recognized out of a mixed packages " +
`list (got: ${[...installed].join(", ")})`
)
assert.ok(
installed.has("pi-subagents"),
"expected the plain npm entry with a version to be recognized as pi-subagents"
)
assert.ok(
installed.has("@juicesharp/rpiv-todo"),
"expected the scoped npm entry with a version to be recognized as @juicesharp/rpiv-todo"
)
assert.ok(
!installed.has("/Users/example/local-pi-plugin"),
"expected the filesystem path entry to be ignored, not reported as an installed package"
)
assert.ok(
![...installed].some(name => name.startsWith("git:")),
"expected the git: source entry to be ignored, not reported as an installed package"
)
const missingFile = path.join(tmpDir, "does-not-exist.json")
assert.deepStrictEqual(
readInstalledPackageNames(missingFile),
new Set(),
"expected a missing settings.json to yield an empty set instead of throwing"
)
const malformedFile = path.join(tmpDir, "malformed.json")
fs.writeFileSync(malformedFile, "{ this is not valid json")
assert.deepStrictEqual(
readInstalledPackageNames(malformedFile),
new Set(),
"expected a malformed settings.json to yield an empty set instead of throwing"
)
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true })
}
}],
["companion package detection tolerates a scoped fork (source contract): the unscoped-entry fallback exists, scoped entries stay exact, and the doctor loop reports what satisfied the entry", () => {
const matchStart = extensionSource.indexOf("function findInstalledCompanion")
assert.ok(
matchStart !== -1,
"expected .pi/extensions/index.ts to define a function named findInstalledCompanion; " +
"a bare installed.has(name) check reports an installed scoped fork such as " +
"@tintinweb/pi-subagents as missing, and then tells the user to run " +
"`pi install npm:pi-subagents`, which would put a SECOND extension registering " +
"the same tool names into their session"
)
const nextFunctionStart = extensionSource.indexOf("\nfunction ", matchStart + 1)
const matchSource =
nextFunctionStart === -1
? extensionSource.slice(matchStart)
: extensionSource.slice(matchStart, nextFunctionStart)
assert.ok(
/if\s*\(\s*installed\.has\(\s*companion\s*\)\s*\)/.test(matchSource),
"expected findInstalledCompanion in .pi/extensions/index.ts to check the exact name " +
"first; an exact install is the ordinary case and must not be routed through the " +
"scoped-fork scan"
)
assert.ok(
/if\s*\(\s*companion\.startsWith\(\s*["'`]@["'`]\s*\)\s*\)\s*\{\s*return undefined/.test(
matchSource
),
"expected findInstalledCompanion in .pi/extensions/index.ts to bail out for a SCOPED " +
"companion entry before the fallback; for an entry like " +
"@juicesharp/rpiv-todo the scope is part of the identity ECC is naming, so some " +
"other publisher's rpiv-todo must not silently satisfy it"
)
assert.ok(
/name\.startsWith\(\s*["'`]@["'`]\s*\)\s*&&\s*name\.endsWith\(\s*scopedSuffix\s*\)/.test(
matchSource
),
"expected findInstalledCompanion in .pi/extensions/index.ts to match an installed " +
"scoped package by the '@scope/' + exact bare name shape; matching on endsWith " +
"alone would let a package named my-pi-subagents satisfy the pi-subagents entry"
)
const withoutComments = stripComments(extensionSource)
assert.ok(
!/installed\.has\(name\)/.test(withoutComments),
"found a bare installed.has(name) still used as executable code in " +
".pi/extensions/index.ts; the /ecc-doctor companion loop must go through " +
"findInstalledCompanion so a scoped fork is not reported as missing"
)
assert.ok(
/satisfied by/.test(extensionSource),
"expected the /ecc-doctor companion loop in .pi/extensions/index.ts to name the " +
"package that satisfied an entry when it is not an exact match; reporting a " +
"bare 'installed' for @tintinweb/pi-subagents under the pi-subagents line hides " +
"which implementation is actually loaded, which is the first thing to know when " +
"its behavior differs from the unscoped package's"
)
}],
["companion package matching (behavioral mirror): an unscoped entry is satisfied by a scoped fork, a scoped entry is matched exactly", () => {
assert.strictEqual(
findInstalledCompanion("pi-subagents", new Set(["pi-subagents"])),
"pi-subagents",
"expected an exactly-installed companion to be reported as itself"
)
assert.strictEqual(
findInstalledCompanion("pi-subagents", new Set(["@tintinweb/pi-subagents"])),
"@tintinweb/pi-subagents",
"expected a scoped fork to satisfy the unscoped pi-subagents entry; the subagents " +
"capability is published under that bare name by more than one maintainer, and a " +
"user running the scoped one has working Agent/SubagentWorkflow tools in session " +
"while /ecc-doctor was calling it missing"
)
assert.strictEqual(
findInstalledCompanion("pi-subagents", new Set(["pi-subagents", "@tintinweb/pi-subagents"])),
"pi-subagents",
"expected the exact match to win when both are installed, so the reported name is " +
"stable rather than depending on Set iteration order"
)
assert.strictEqual(
findInstalledCompanion("pi-subagents", new Set(["my-pi-subagents"])),
undefined,
"expected an unscoped package that merely ENDS WITH the companion name to not " +
"satisfy it; only a @scope/ prefix counts"
)
assert.strictEqual(
findInstalledCompanion("pi-subagents", new Set(["@acme/my-pi-subagents"])),
undefined,
"expected a scoped package whose bare name merely ends with the companion name to " +
"not satisfy it; the segment after the scope must equal the companion name"
)
assert.strictEqual(
findInstalledCompanion("@juicesharp/rpiv-todo", new Set(["@juicesharp/rpiv-todo"])),
"@juicesharp/rpiv-todo",
"expected an exactly-installed scoped companion to be reported as itself"
)
assert.strictEqual(
findInstalledCompanion("@juicesharp/rpiv-todo", new Set(["@someoneelse/rpiv-todo"])),
undefined,
"expected a DIFFERENT scope to not satisfy a scoped companion entry; ECC names that " +
"scope deliberately, so relaxing this direction would report an unrelated " +
"publisher's package as the one ECC documents"
)
assert.strictEqual(
findInstalledCompanion("@juicesharp/rpiv-todo", new Set(["rpiv-todo"])),
undefined,
"expected an unscoped package to not satisfy a scoped companion entry"
)
assert.strictEqual(
findInstalledCompanion("pi-subagents", new Set()),
undefined,
"expected an empty install set to satisfy nothing"
)
}],
["every COMPANION_PACKAGES entry this repo ships is still resolvable by the matcher it is checked with", () => {
const constStart = extensionSource.indexOf("const COMPANION_PACKAGES")
assert.ok(
constStart !== -1,
"expected to find a COMPANION_PACKAGES array literal in .pi/extensions/index.ts"
)
const constEnd = extensionSource.indexOf("]", constStart)
const companions = Array.from(
extensionSource.slice(constStart, constEnd + 1).matchAll(/["'`](@?[\w./-]+)["'`]/g)
).map(match => match[1])
assert.ok(
companions.length > 0,
"expected to parse at least one companion package name out of COMPANION_PACKAGES"
)
for (const companion of companions) {
assert.strictEqual(
findInstalledCompanion(companion, new Set([companion])),
companion,
`expected the companion entry ${companion} to be recognized when it is installed ` +
"under exactly its own name; an entry the matcher cannot resolve would be " +
"reported as permanently missing no matter what the user installs"
)
}
}],
// ---- Group 4: engineering-rules injection -------------------------
["PORTABLE_RULE_FILES lists exactly ECC's 7 Pi-portable rule files and excludes the 3 Claude-Code-only ones", () => {
const ruleFiles = parsePortableRuleFiles(extensionSource)
assert.ok(
ruleFiles.length > 0,
"expected to find and parse a PORTABLE_RULE_FILES array literal in .pi/extensions/index.ts"
)
assert.deepStrictEqual(
ruleFiles,
[
"coding-style.md",
"testing.md",
"security.md",
"git-workflow.md",
"patterns.md",
"development-workflow.md",
"code-review.md",
],
"expected PORTABLE_RULE_FILES in .pi/extensions/index.ts to contain exactly these " +
`7 files (got: ${ruleFiles.join(", ")}); a drift here silently changes which ECC ` +
"engineering rules get injected into Pi's system prompt"
)
for (const excluded of ["agents.md", "hooks.md", "performance.md"]) {
assert.ok(
!ruleFiles.includes(excluded),
`found ${excluded} in PORTABLE_RULE_FILES in .pi/extensions/index.ts; ${excluded} ` +
"describes Claude Code primitives Pi does not have (Task/TodoWrite delegation, " +
"Claude Code hook event types, or thinking-budget toggles like Option+T), so " +
"injecting it into Pi's system prompt would instruct the model to use tools " +
"and behaviors that do not exist in Pi"
)
}
}],
["engineering rules are read from rules/common/ joined onto the package root at runtime, and nothing is copied into .pi/", () => {
const withoutComments = stripComments(extensionSource)
assert.ok(
/path\.join\(\s*ECC_ROOT\s*,\s*["'`]rules["'`]\s*,\s*["'`]common["'`]/.test(withoutComments),
"expected .pi/extensions/index.ts to build the rules directory via " +
'path.join(ECC_ROOT, "rules", "common", ...); rules must be read at runtime from ' +
"the canonical rules/common/ directory of the installed ECC package, which is " +
"the entire point of this adapter feature, not from a path baked in some other way"
)
const piDir = path.join(repoRoot, ".pi")
assert.ok(
fs.existsSync(piDir),
`expected a .pi/ directory to exist at ${piDir} for this check to be meaningful`
)
const piRulesDir = path.join(piDir, "rules")
assert.ok(
!fs.existsSync(piRulesDir),
`found ${piRulesDir} on disk; ECC's engineering rules must be read at runtime from ` +
"the canonical rules/common/ directory and never copied or generated into .pi/ -- " +
"a rules/ directory under .pi/ means that core constraint has been violated"
)
}],
["every file named in PORTABLE_RULE_FILES actually exists under rules/common/ in this repo", () => {
const ruleFiles = parsePortableRuleFiles(extensionSource)
assert.ok(
ruleFiles.length > 0,
"expected to find and parse a PORTABLE_RULE_FILES array literal in .pi/extensions/index.ts"
)
const rulesCommonDir = path.join(repoRoot, "rules", "common")
for (const file of ruleFiles) {
const fullPath = path.join(rulesCommonDir, file)
assert.ok(
fs.existsSync(fullPath),
`expected ${fullPath} to exist because it is listed in PORTABLE_RULE_FILES; a ` +
"missing rule file makes loadPortableRules() silently skip it via its " +
"try/catch, so the adapter would inject less engineering-rule coverage into " +
"Pi's system prompt than intended, with no error or warning to notice it by"
)
}
}],
["loadPortableRules() behavioral mirror: concatenates the real rules/common/ files, stays under the cap, and contains markers from several rule files", () => {
// Mirror of loadPortableRules() (read-trim-skip-cap-join loop), guarded by the
// source-text assertions in the two tests above (PORTABLE_RULE_FILES contents
// and the rules/common path) and by the parsed MAX_RULES_BYTES cap below, so a
// drift in the real function's shape fails those tests even if this mirror,
// run here against this repo's actual rule files, still looks correct.
const ruleFiles = parsePortableRuleFiles(extensionSource)
const maxRulesBytes = parseMaxRulesBytes(extensionSource)
assert.ok(
typeof maxRulesBytes === "number" && maxRulesBytes > 0,
"expected to parse a positive numeric MAX_RULES_BYTES constant out of .pi/extensions/index.ts"
)
const result = loadPortableRulesMirror(repoRoot, ruleFiles, maxRulesBytes)
assert.ok(
typeof result === "string" && result.length > 0,
"expected loadPortableRules() to return a non-empty string when run against this " +
"repo's real rules/common/ files; an empty result means the <ecc-engineering-rules> " +
"block would be silently omitted from Pi's system prompt on every turn"
)
assert.ok(
result.length < maxRulesBytes,
`expected the concatenated rules text (${result.length} chars) to stay under ` +
`MAX_RULES_BYTES (${maxRulesBytes} bytes); exceeding the cap means the ` +
"concatenation loop's stop-before-exceeding-cap guard is not doing its job, and a " +
"large rule-file edit could flood Pi's system prompt"
)
for (const marker of ["Immutability", "Minimum Test Coverage", "Secret Management"]) {
assert.ok(
result.includes(marker),
`expected the concatenated rules text to contain "${marker}" (a marker from one ` +
"of the real rules/common/ files); its absence means that file was skipped " +
"(missing, empty, or cut off by the cap) or its content changed in a way that " +
"dropped the section entirely"
)
}
}],
["leakage guard: the text loadPortableRules() would inject contains no Claude-Code-only primitives Pi cannot use", () => {
const ruleFiles = parsePortableRuleFiles(extensionSource)
const maxRulesBytes = parseMaxRulesBytes(extensionSource)
const result = loadPortableRulesMirror(repoRoot, ruleFiles, maxRulesBytes)
assert.ok(
typeof result === "string" && result.length > 0,
"expected a non-empty mirrored rules result for this leakage check to be meaningful"
)
for (const leaked of ["TodoWrite", "Option+T", "PostToolUse", "alwaysThinkingEnabled"]) {
assert.ok(
!result.includes(leaked),
`found "${leaked}" in the text loadPortableRules() would inject into Pi's system ` +
"prompt; this is a Claude-Code-only primitive (a tool, hook event type, or " +
"thinking-budget toggle) that would instruct Pi's model to use something that " +
"does not exist in Pi -- exactly the leakage excluding agents.md/hooks.md/" +
"performance.md from PORTABLE_RULE_FILES exists to prevent"
)
}
}],
["/ecc-doctor reports rule files actually loaded, not the allowlist length (source contract)", () => {
assert.ok(
/let\s+cachedRuleFileCount\s*=\s*0/.test(extensionSource),
"expected .pi/extensions/index.ts to track how many rule files actually loaded in a " +
"cachedRuleFileCount counter alongside cachedRules"
)
assert.ok(
/cachedRuleFileCount\s*=\s*sections\.length/.test(extensionSource),
"expected loadPortableRules in .pi/extensions/index.ts to set cachedRuleFileCount " +
"from sections.length, which is what survived the read failures, the empty-file " +
"skip, and the MAX_RULES_BYTES break"
)
const disabledBranch = extensionSource.slice(
extensionSource.indexOf("isDisabledByEnv(process.env.ECC_PI_RULES)"),
extensionSource.indexOf("const sections: string[] = []")
)
assert.ok(
/cachedRuleFileCount\s*=\s*0/.test(disabledBranch),
"expected the ECC_PI_RULES disable branch of loadPortableRules in " +
".pi/extensions/index.ts to reset cachedRuleFileCount to 0, so the counter can " +
"never survive from a prior load into a disabled session"
)
const statusStart = extensionSource.indexOf("function describeRulesStatus")
assert.ok(
statusStart !== -1,
"expected .pi/extensions/index.ts to define a function named describeRulesStatus"
)
const nextFunctionStart = extensionSource.indexOf("\nfunction ", statusStart + 1)
const statusSource =
nextFunctionStart === -1
? extensionSource.slice(statusStart)
: extensionSource.slice(statusStart, nextFunctionStart)
assert.ok(
/\$\{cachedRuleFileCount\}\/\$\{PORTABLE_RULE_FILES\.length\}\s+rule file/.test(statusSource),
"expected describeRulesStatus in .pi/extensions/index.ts to report the loaded count " +
"over the allowlist length (`${cachedRuleFileCount}/${PORTABLE_RULE_FILES.length} " +
"rule file(s)`); loadPortableRules silently skips unreadable and empty files and " +
"breaks out of the loop at MAX_RULES_BYTES, so reporting the allowlist length " +
"alone makes an install that loaded 3 of 7 report 7 -- and /ecc-doctor is the one " +
"place a user looks to find a partial install"
)
}],
["isDisabledByEnv() behavioral mirror: recognizes 0/false/off/none/disabled case- and whitespace-insensitively, and the real function reads ECC_PI_RULES", () => {
for (const disabledValue of ["0", "false", "off", "none", "disabled"]) {
assert.strictEqual(
isDisabledByEnvMirror(disabledValue),
true,
`expected isDisabledByEnv("${disabledValue}") to be true`
)
assert.strictEqual(
isDisabledByEnvMirror(disabledValue.toUpperCase()),
true,
`expected isDisabledByEnv to be case-insensitive for "${disabledValue.toUpperCase()}"`
)
assert.strictEqual(
isDisabledByEnvMirror(` ${disabledValue} `),
true,
`expected isDisabledByEnv to ignore surrounding whitespace for " ${disabledValue} "`
)
}
assert.strictEqual(
isDisabledByEnvMirror(" OFF "),
true,
'expected isDisabledByEnv(" OFF ") to be true (mixed case AND surrounding whitespace ' +
"at once); a user pasting ECC_PI_RULES=\" OFF \" into a shell profile must still " +
"disable injection"
)
for (const enabledValue of [undefined, "", "1", "true", "on", "yes", "TRUE ISH"]) {
assert.strictEqual(
isDisabledByEnvMirror(enabledValue),
false,
`expected isDisabledByEnv(${JSON.stringify(enabledValue)}) to be false; treating an ` +
"unrecognized value as disabled would silently turn off rule injection for anyone " +
"who sets ECC_PI_RULES to something other than the 5 documented off-values"
)
}
const withoutComments = stripComments(extensionSource)
assert.ok(
withoutComments.includes("process.env.ECC_PI_RULES"),
"expected .pi/extensions/index.ts to read process.env.ECC_PI_RULES as the env var " +
"that turns rule injection off; a different or renamed env var would silently break " +
"anyone's existing ECC_PI_RULES=off configuration"
)
}],
["before_agent_start wraps rules and context in their tags, consumes pendingContext but never the rules, and returns early with no override when there is nothing to add", () => {
const beforeAgentStartIdx = extensionSource.indexOf('pi.on("before_agent_start"')
assert.ok(
beforeAgentStartIdx !== -1,
"expected .pi/extensions/index.ts to register a before_agent_start handler via pi.on(...)"
)
const sessionShutdownIdx = extensionSource.indexOf('pi.on("session_shutdown"', beforeAgentStartIdx)
assert.ok(
sessionShutdownIdx !== -1 && sessionShutdownIdx > beforeAgentStartIdx,
"expected a session_shutdown handler registered after before_agent_start in .pi/extensions/index.ts"
)
const handlerSource = stripComments(extensionSource.slice(beforeAgentStartIdx, sessionShutdownIdx))
assert.ok(
handlerSource.includes("<ecc-engineering-rules>"),
"expected the before_agent_start handler in .pi/extensions/index.ts to wrap " +
"injected rules in an <ecc-engineering-rules> tag"
)
assert.ok(
handlerSource.includes("<ecc-session-context>"),
"expected the before_agent_start handler in .pi/extensions/index.ts to wrap the " +
"session context in an <ecc-session-context> tag"
)
const contextPushIdx = handlerSource.indexOf("<ecc-session-context>")
const clearIdx = handlerSource.indexOf("pendingContext = undefined", contextPushIdx)
assert.ok(
contextPushIdx !== -1 && clearIdx !== -1 && clearIdx > contextPushIdx,
"expected before_agent_start to clear pendingContext = undefined after using it to " +
"build the <ecc-session-context> block; without this, the same one-shot session " +
"context would be replayed into every later agent turn instead of being consumed once"
)
assert.ok(
!/\bcachedRules\s*=\s*(undefined|null)/.test(handlerSource) &&
!/\brules\s*=\s*(undefined|null)/.test(handlerSource),
"found code in the before_agent_start handler that resets the loaded rules value; " +
"engineering rules describe standing policy and must be re-applied on EVERY turn " +
"(unlike the one-shot pendingContext), so nothing in this handler may consume or " +
"clear them the way pendingContext is consumed"
)
assert.ok(
/if\s*\(\s*additions\.length\s*===\s*0\s*\)\s*\{\s*return\s*\}/.test(handlerSource),
"expected before_agent_start to return early with a bare `return` (no systemPrompt " +
"override) when there is nothing to add; without this guard, a turn with no rules " +
"and no pending context would still return a rebuilt systemPrompt instead of " +
"leaving Pi's original systemPrompt untouched"
)
const earlyReturnIdx = handlerSource.indexOf("if (additions.length === 0)")
const overrideReturnIdx = handlerSource.indexOf("return { systemPrompt")
assert.ok(
earlyReturnIdx !== -1 && overrideReturnIdx !== -1 && earlyReturnIdx < overrideReturnIdx,
"expected the early-return-when-nothing-to-add guard to appear before the " +
"systemPrompt-override return in before_agent_start"
)
}],
]
for (const [name, fn] of tests) {
if (await runTest(name, fn)) {
passed += 1
} else {
failed += 1
}
}
console.log(`\nPassed: ${passed}`)
console.log(`Failed: ${failed}`)
process.exit(failed > 0 ? 1 : 0)
}
main()