From 790f9d15869c68613eeb404393c60bd6173dfd3a Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Wed, 7 Oct 2026 15:10:56 +0530 Subject: [PATCH 01/14] feat(setup): add zero-token setup drift check Claude Code mod (#1497) --- .../magpie-setup/hooks/drift-check.test.ts | 248 ++++++++++++++++++ plugins/magpie-setup/hooks/drift-check.ts | 238 +++++++++++++++++ plugins/magpie-setup/hooks/hooks.json | 7 + 3 files changed, 493 insertions(+) create mode 100644 plugins/magpie-setup/hooks/drift-check.test.ts create mode 100644 plugins/magpie-setup/hooks/drift-check.ts create mode 100644 plugins/magpie-setup/hooks/hooks.json diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts new file mode 100644 index 000000000..26f182185 --- /dev/null +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -0,0 +1,248 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +import { describe, it, beforeEach, afterEach } from 'node:test'; +import * as assert from 'node:assert'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + checkSetupDrift, + resolvePluginVersion, + register, +} from './drift-check.ts'; + +describe('drift-check mod (Pilot 1)', () => { + let tmpDir: string; + let workspaceDir: string; + let pluginDir: string; + + beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'magpie-mod-test-')); + workspaceDir = path.join(tmpDir, 'workspace'); + pluginDir = path.join(tmpDir, 'plugin'); + + fs.mkdirSync(workspaceDir, { recursive: true }); + fs.mkdirSync(path.join(pluginDir, '.claude-plugin'), { recursive: true }); + + // Mock plugin manifest with version 0.9.0 + fs.writeFileSync( + path.join(pluginDir, '.claude-plugin', 'plugin.json'), + JSON.stringify({ + name: 'magpie-setup', + version: '0.9.0', + }) + ); + }); + + afterEach(() => { + try { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + it('returns not adopted when .apache-magpie.lock does not exist', () => { + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, false); + assert.strictEqual(result.hasDrift, false); + assert.strictEqual(result.message, undefined); + }); + + it('detects drift when lockfile is completely empty', () => { + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), ''); + + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, true); + assert.ok(result.message?.includes('Empty `.apache-magpie.lock`')); + }); + + it('returns no drift when lockfile version matches current plugin version', () => { + fs.writeFileSync( + path.join(workspaceDir, '.apache-magpie.lock'), + JSON.stringify({ + version: '0.9.0', + ref: '0.9.0', + snapshot_commit: 'abc1234', + }) + ); + + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, false); + assert.strictEqual(result.lockVersion, '0.9.0'); + assert.strictEqual(result.currentVersion, '0.9.0'); + assert.strictEqual(result.message, undefined); + }); + + it('detects drift when lockfile version differs from installed plugin version', () => { + fs.writeFileSync( + path.join(workspaceDir, '.apache-magpie.lock'), + JSON.stringify({ + version: '0.8.0', + ref: '0.8.0', + snapshot_commit: 'oldcommit123', + }) + ); + + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, true); + assert.strictEqual(result.lockVersion, '0.8.0'); + assert.strictEqual(result.currentVersion, '0.9.0'); + assert.ok(result.message?.includes('0.8.0 -> 0.9.0')); + assert.ok(result.message?.includes('/magpie-setup upgrade')); + }); + + it('detects drift when lockfile contains invalid / malformed JSON', () => { + fs.writeFileSync( + path.join(workspaceDir, '.apache-magpie.lock'), + 'invalid json content {{{' + ); + + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, true); + assert.ok(result.message?.includes('Malformed `.apache-magpie.lock`')); + }); + + it('resolves plugin version correctly from candidate paths', () => { + const version = resolvePluginVersion(pluginDir); + assert.strictEqual(version, '0.9.0'); + }); + + it('registers session.start and ui.render hooks and renders Box/Text view', async () => { + fs.writeFileSync( + path.join(workspaceDir, '.apache-magpie.lock'), + JSON.stringify({ + version: '0.7.5', + }) + ); + + const registeredHooks = new Map(); + + const mockOn = (event: string, ...args: any[]) => { + const handler = args[args.length - 1]; + registeredHooks.set(event, handler); + }; + + register(mockOn); + + assert.ok(registeredHooks.has('session.start')); + assert.ok(registeredHooks.has('ui.render')); + + let nextCalled = false; + const mockNext = async (e: any) => { + nextCalled = true; + return e; + }; + + let uiInvalidated = false; + const mock$ = { + session: { + root: () => workspaceDir, + cwd: () => workspaceDir, + }, + plugin: { + root: pluginDir, + }, + ui: { + invalidate: (target: string) => { + if (target === 'ui.render') { + uiInvalidated = true; + } + }, + resolve: (_e: any) => ({ + Box: (props: any) => ({ type: 'Box', ...props }), + Text: (props: any) => ({ type: 'Text', ...props }), + }), + }, + }; + + // 1. Fire session.start + const sessionStartHandler = registeredHooks.get('session.start')!; + await sessionStartHandler(mock$, {}, mockNext); + + assert.strictEqual(nextCalled, true); + assert.strictEqual(uiInvalidated, true); + + // 2. Fire ui.render for AbovePrompt + let renderedResult: any = null; + const mockRenderNext = async (e: any) => { + renderedResult = e; + return e; + }; + + const uiRenderHandler = registeredHooks.get('ui.render')!; + await uiRenderHandler(mock$, {}, mockRenderNext); + + assert.strictEqual(renderedResult?.view?.type, 'Box'); + const textChild = renderedResult?.view?.children?.[0]; + assert.strictEqual(textChild?.type, 'Text'); + assert.ok(textChild?.text?.includes('0.7.5 -> 0.9.0')); + assert.ok(textChild?.text?.includes('/magpie-setup upgrade')); + }); + + it('remains silent when session starts without drift', async () => { + fs.writeFileSync( + path.join(workspaceDir, '.apache-magpie.lock'), + JSON.stringify({ + version: '0.9.0', + }) + ); + + const registeredHooks = new Map(); + register((event: string, ...args: any[]) => { + registeredHooks.set(event, args[args.length - 1]); + }); + + let uiInvalidated = false; + const mock$ = { + session: { + root: () => workspaceDir, + cwd: () => workspaceDir, + }, + plugin: { + root: pluginDir, + }, + ui: { + invalidate: () => { + uiInvalidated = true; + }, + resolve: () => ({ + Box: (props: any) => props, + Text: (props: any) => props, + }), + }, + }; + + const sessionStartHandler = registeredHooks.get('session.start')!; + await sessionStartHandler(mock$, {}, (e: any) => e); + assert.strictEqual(uiInvalidated, false); + + let renderedResult: any = null; + const uiRenderHandler = registeredHooks.get('ui.render')!; + await uiRenderHandler(mock$, {}, (e: any) => { + renderedResult = e; + return e; + }); + + assert.strictEqual(renderedResult?.view, undefined); + }); +}); diff --git a/plugins/magpie-setup/hooks/drift-check.ts b/plugins/magpie-setup/hooks/drift-check.ts new file mode 100644 index 000000000..3e5d48b48 --- /dev/null +++ b/plugins/magpie-setup/hooks/drift-check.ts @@ -0,0 +1,238 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +/** + * Apache Magpie — Setup Drift Check Mod (Claude Code). + * + * Hooks `session.start` to deterministically verify whether the current + * workspace has drifted from the installed Magpie plugin version or gitignored + * snapshot, without burning prompt tokens on every session turn. + * + * Implements Pilot 1 of RFC-AI-0004 Claude Code Mods (Issue #1497). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +export interface LockfileData { + version?: string; + ref?: string; + snapshot_commit?: string; + snapshot_hash?: string; + [key: string]: unknown; +} + +export interface DriftCheckResult { + isAdopted: boolean; + hasDrift: boolean; + lockVersion?: string; + currentVersion?: string; + message?: string; +} + +/** + * Resolve the plugin version from available plugin manifests. + */ +export function resolvePluginVersion(pluginDir?: string): string | undefined { + const candidates: string[] = []; + + if (pluginDir) { + candidates.push( + path.join(pluginDir, '.claude-plugin', 'plugin.json'), + path.join(pluginDir, 'plugin.json'), + path.join(pluginDir, '..', '.claude-plugin', 'plugin.json'), + path.join(pluginDir, '..', 'plugin.json') + ); + } + + // Check standard environment paths + const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || process.env.PLUGIN_ROOT; + if (pluginRoot) { + candidates.push( + path.join(pluginRoot, '.claude-plugin', 'plugin.json'), + path.join(pluginRoot, 'plugin.json') + ); + } + + for (const candidate of candidates) { + try { + if (fs.existsSync(candidate)) { + const raw = fs.readFileSync(candidate, 'utf-8'); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed.version === 'string' && parsed.version.trim()) { + return parsed.version.trim(); + } + } + } catch { + // Continue searching next candidate + } + } + + return undefined; +} + +/** + * Core deterministic drift check between workspace lockfile and installed plugin. + */ +export function checkSetupDrift( + workspaceDir: string, + pluginDir?: string +): DriftCheckResult { + try { + const lockPath = path.join(workspaceDir, '.apache-magpie.lock'); + + // If no lockfile is present, this is not an adopted workspace + if (!fs.existsSync(lockPath)) { + return { + isAdopted: false, + hasDrift: false, + }; + } + + const content = fs.readFileSync(lockPath, 'utf-8').trim(); + if (!content) { + return { + isAdopted: true, + hasDrift: true, + message: + 'Apache Magpie: Empty `.apache-magpie.lock` detected. Run `/magpie-setup upgrade` to populate.', + }; + } + + let lockData: LockfileData = {}; + try { + lockData = JSON.parse(content); + } catch { + // Malformed lockfile counts as drift needing reconciliation + return { + isAdopted: true, + hasDrift: true, + message: + 'Apache Magpie: Malformed `.apache-magpie.lock` detected. Run `/magpie-setup upgrade` to repair.', + }; + } + + const currentVersion = resolvePluginVersion(pluginDir); + const lockVersion = lockData.version || lockData.ref || undefined; + + // If both versions are known and differ, drift is detected + if (currentVersion && lockVersion && currentVersion !== lockVersion) { + return { + isAdopted: true, + hasDrift: true, + lockVersion, + currentVersion, + message: `Apache Magpie: Snapshot drift detected (${lockVersion} -> ${currentVersion}). Run \`/magpie-setup upgrade\` to reconcile the snapshot and overrides.`, + }; + } + + return { + isAdopted: true, + hasDrift: false, + lockVersion, + currentVersion, + }; + } catch { + // Fail closed/silently on unhandled errors to avoid crashing session start + return { + isAdopted: false, + hasDrift: false, + }; + } +} + +/** + * Event hook registration entry point for Claude Code. + */ +export function register(on: any, options?: any): void { + if (typeof on !== 'function') { + return; + } + + let driftNotice: string | null = null; + + // 1. Hook session.start to check for lockfile drift + on('session.start', async ($: any, e: any, next?: any) => { + try { + // Check session.cwd, session.root (worktree root), or process.cwd() + const workspaceDir = + (typeof $?.session?.root === 'function' ? $.session.root() : undefined) || + (typeof $?.session?.cwd === 'function' ? $.session.cwd() : undefined) || + $?.workspacePath || + $?.cwd || + process.cwd(); + + const pluginDir = + $?.plugin?.root || + $?.pluginPath || + process.env.CLAUDE_PLUGIN_ROOT || + path.resolve(__dirname, '..'); + + const result = checkSetupDrift(workspaceDir, pluginDir); + + if (result.hasDrift && result.message) { + driftNotice = result.message; + + if (typeof $?.ui?.invalidate === 'function') { + $.ui.invalidate('ui.render'); + } else if (typeof $?.ui?.toast === 'function') { + $.ui.toast(result.message, { level: 'info' }); + } else if (typeof $?.ui?.banner === 'function') { + $.ui.banner(result.message); + } + } + } catch { + // Safe no-op on exception + } + + if (typeof next === 'function') { + return next(e); + } + }); + + // 2. Hook ui.render to display AbovePrompt drift banner if detected + on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next?: any) => { + if (driftNotice && typeof $?.ui?.resolve === 'function') { + try { + const { Box, Text } = $.ui.resolve(e); + if (Box && Text) { + return next({ + ...e, + view: Box({ + padding: 0, + children: [ + Text({ + text: `⚠ ${driftNotice}`, + color: 'yellow', + bold: true, + }), + ], + }), + }); + } + } catch { + // Fallback cleanly to next + } + } + + if (typeof next === 'function') { + return next(e); + } + }); +} + +export default register; diff --git a/plugins/magpie-setup/hooks/hooks.json b/plugins/magpie-setup/hooks/hooks.json new file mode 100644 index 000000000..1b10751d8 --- /dev/null +++ b/plugins/magpie-setup/hooks/hooks.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://code.claude.com/schemas/hooks.json", + "description": "Apache Magpie — Claude Code mods for zero-token setup drift detection and workspace verification.", + "modules": [ + "./drift-check.ts" + ] +} From 996132a6f07fbd5e0b4fc0c370652502dcbf66c3 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Wed, 7 Oct 2026 19:04:31 +0530 Subject: [PATCH 02/14] fix(setup): align drift-check mod with YAML lockfile parser, floor semantics, and local lock split (#1497) --- .../magpie-setup/.claude-plugin/plugin.json | 16 +- .../magpie-setup/hooks/drift-check.test.ts | 474 ++++++++++++------ plugins/magpie-setup/hooks/drift-check.ts | 399 ++++++++++++++- 3 files changed, 684 insertions(+), 205 deletions(-) diff --git a/plugins/magpie-setup/.claude-plugin/plugin.json b/plugins/magpie-setup/.claude-plugin/plugin.json index 44af6fe40..002de849b 100644 --- a/plugins/magpie-setup/.claude-plugin/plugin.json +++ b/plugins/magpie-setup/.claude-plugin/plugin.json @@ -9,19 +9,5 @@ "homepage": "https://magpie.apache.org/", "repository": "https://github.com/apache/magpie", "license": "Apache-2.0", - "skills": "./skills", - "hooks": { - "SessionStart": [ - { - "matcher": "startup", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh", - "timeout": 10 - } - ] - } - ] - } + "skills": "./skills" } diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index 26f182185..b0821ac7d 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -23,6 +23,10 @@ import * as os from 'node:os'; import { checkSetupDrift, resolvePluginVersion, + parseLockfile, + parseLocalLockfile, + parsePep440, + comparePep440, register, } from './drift-check.ts'; @@ -57,192 +61,328 @@ describe('drift-check mod (Pilot 1)', () => { } }); - it('returns not adopted when .apache-magpie.lock does not exist', () => { - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, false); - assert.strictEqual(result.hasDrift, false); - assert.strictEqual(result.message, undefined); - }); + describe('PEP 440 parsing and comparison', () => { + it('correctly compares numeric release segments (0.10.0 > 0.9.0)', () => { + assert.ok(comparePep440('0.10.0', '0.9.0') > 0); + assert.ok(comparePep440('0.9.0', '0.10.0') < 0); + assert.strictEqual(comparePep440('0.9.0', '0.9.0'), 0); + }); + + it('correctly orders development releases (0.2.0 > 0.2.0.dev202609110041)', () => { + assert.ok(comparePep440('0.2.0', '0.2.0.dev202609110041') > 0); + assert.ok(comparePep440('0.2.0.dev202609110041', '0.2.0') < 0); + assert.ok(comparePep440('0.9.0.dev202609262258', '0.2.0') > 0); + }); - it('detects drift when lockfile is completely empty', () => { - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), ''); + it('correctly orders pre-releases, final releases, and post-releases', () => { + assert.ok(comparePep440('1.0.0a1', '1.0.0b1') < 0); + assert.ok(comparePep440('1.0.0b1', '1.0.0rc1') < 0); + assert.ok(comparePep440('1.0.0rc1', '1.0.0') < 0); + assert.ok(comparePep440('1.0.0.post1', '1.0.0') > 0); + }); - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, true); - assert.ok(result.message?.includes('Empty `.apache-magpie.lock`')); + it('parses epochs and complex versions correctly', () => { + assert.ok(comparePep440('1!0.1.0', '0.9.0') > 0); + assert.strictEqual(parsePep440('invalid-version'), null); + }); }); - it('returns no drift when lockfile version matches current plugin version', () => { - fs.writeFileSync( - path.join(workspaceDir, '.apache-magpie.lock'), - JSON.stringify({ - version: '0.9.0', - ref: '0.9.0', - snapshot_commit: 'abc1234', - }) - ); + describe('YAML scalar lockfile parsing', () => { + it('parses standard marketplace YAML lockfile with comments and list', () => { + const yaml = `# .apache-magpie.lock — committed; the project's floor. +method: marketplace +url: apache/magpie +min_version: 0.2.0 + +plugins: + - magpie-setup + - magpie-utilities +`; + const lock = parseLockfile(yaml); + assert.strictEqual(lock.method, 'marketplace'); + assert.strictEqual(lock.url, 'apache/magpie'); + assert.strictEqual(lock.min_version, '0.2.0'); + assert.deepStrictEqual(lock.plugins, ['magpie-setup', 'magpie-utilities']); + }); - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, false); - assert.strictEqual(result.lockVersion, '0.9.0'); - assert.strictEqual(result.currentVersion, '0.9.0'); - assert.strictEqual(result.message, undefined); - }); + it('parses git-tag snapshot YAML lockfile', () => { + const yaml = `method: git-tag +url: https://github.com/apache/magpie.git +ref: v1.0.0 +commit: abc1234 +`; + const lock = parseLockfile(yaml); + assert.strictEqual(lock.method, 'git-tag'); + assert.strictEqual(lock.ref, 'v1.0.0'); + assert.strictEqual(lock.commit, 'abc1234'); + }); - it('detects drift when lockfile version differs from installed plugin version', () => { - fs.writeFileSync( - path.join(workspaceDir, '.apache-magpie.lock'), - JSON.stringify({ - version: '0.8.0', - ref: '0.8.0', - snapshot_commit: 'oldcommit123', - }) - ); + it('parses local fingerprint lockfile (.apache-magpie.local.lock)', () => { + const yaml = `# .apache-magpie.local.lock +source_method: git-tag +source_url: https://github.com/apache/magpie.git +source_ref: v1.0.0 +fetched_commit: abc1234 +fetched_at: 2026-10-07T00:00:00Z +`; + const local = parseLocalLockfile(yaml); + assert.strictEqual(local.source_method, 'git-tag'); + assert.strictEqual(local.source_ref, 'v1.0.0'); + assert.strictEqual(local.fetched_commit, 'abc1234'); + }); - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, true); - assert.strictEqual(result.lockVersion, '0.8.0'); - assert.strictEqual(result.currentVersion, '0.9.0'); - assert.ok(result.message?.includes('0.8.0 -> 0.9.0')); - assert.ok(result.message?.includes('/magpie-setup upgrade')); + it('throws on malformed YAML syntax', () => { + assert.throws(() => parseLockfile('invalid line without colon'), /not a key: value line/); + assert.throws(() => parseLockfile('unknown_key: value'), /unknown key/); + assert.throws(() => parseLocalLockfile('unknown_local_key: value'), /unknown key/); + }); }); - it('detects drift when lockfile contains invalid / malformed JSON', () => { - fs.writeFileSync( - path.join(workspaceDir, '.apache-magpie.lock'), - 'invalid json content {{{' - ); + describe('checkSetupDrift workflow verification', () => { + it('returns not adopted when .apache-magpie.lock does not exist', () => { + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, false); + assert.strictEqual(result.hasDrift, false); + assert.strictEqual(result.message, undefined); + }); - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, true); - assert.ok(result.message?.includes('Malformed `.apache-magpie.lock`')); - }); + it('detects drift when lockfile is completely empty', () => { + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), ''); - it('resolves plugin version correctly from candidate paths', () => { - const version = resolvePluginVersion(pluginDir); - assert.strictEqual(version, '0.9.0'); - }); + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, true); + assert.ok(result.message?.includes('Empty `.apache-magpie.lock`')); + }); - it('registers session.start and ui.render hooks and renders Box/Text view', async () => { - fs.writeFileSync( - path.join(workspaceDir, '.apache-magpie.lock'), - JSON.stringify({ - version: '0.7.5', - }) - ); + it('detects drift when lockfile contains invalid / malformed YAML', () => { + fs.writeFileSync( + path.join(workspaceDir, '.apache-magpie.lock'), + 'invalid yaml content {{{ no colons' + ); - const registeredHooks = new Map(); - - const mockOn = (event: string, ...args: any[]) => { - const handler = args[args.length - 1]; - registeredHooks.set(event, handler); - }; - - register(mockOn); - - assert.ok(registeredHooks.has('session.start')); - assert.ok(registeredHooks.has('ui.render')); - - let nextCalled = false; - const mockNext = async (e: any) => { - nextCalled = true; - return e; - }; - - let uiInvalidated = false; - const mock$ = { - session: { - root: () => workspaceDir, - cwd: () => workspaceDir, - }, - plugin: { - root: pluginDir, - }, - ui: { - invalidate: (target: string) => { - if (target === 'ui.render') { - uiInvalidated = true; - } - }, - resolve: (_e: any) => ({ - Box: (props: any) => ({ type: 'Box', ...props }), - Text: (props: any) => ({ type: 'Text', ...props }), - }), - }, - }; - - // 1. Fire session.start - const sessionStartHandler = registeredHooks.get('session.start')!; - await sessionStartHandler(mock$, {}, mockNext); - - assert.strictEqual(nextCalled, true); - assert.strictEqual(uiInvalidated, true); - - // 2. Fire ui.render for AbovePrompt - let renderedResult: any = null; - const mockRenderNext = async (e: any) => { - renderedResult = e; - return e; - }; - - const uiRenderHandler = registeredHooks.get('ui.render')!; - await uiRenderHandler(mock$, {}, mockRenderNext); - - assert.strictEqual(renderedResult?.view?.type, 'Box'); - const textChild = renderedResult?.view?.children?.[0]; - assert.strictEqual(textChild?.type, 'Text'); - assert.ok(textChild?.text?.includes('0.7.5 -> 0.9.0')); - assert.ok(textChild?.text?.includes('/magpie-setup upgrade')); + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, true); + assert.ok(result.message?.includes('Malformed `.apache-magpie.lock`')); + }); + + it('satisfies marketplace floor when installed version meets or exceeds min_version', () => { + // Lock floor: 0.2.0, Installed: 0.9.0 + const lockContent = `method: marketplace +url: apache/magpie +min_version: 0.2.0 + +plugins: + - magpie-setup +`; + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, false); + assert.strictEqual(result.lockVersion, '0.2.0'); + assert.strictEqual(result.currentVersion, '0.9.0'); + assert.strictEqual(result.message, undefined); + }); + + it('detects drift when installed plugin is below marketplace adoption floor', () => { + // Lock floor: 1.0.0, Installed: 0.9.0 + const lockContent = `method: marketplace +url: apache/magpie +min_version: 1.0.0 + +plugins: + - magpie-setup +`; + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, true); + assert.strictEqual(result.lockVersion, '1.0.0'); + assert.strictEqual(result.currentVersion, '0.9.0'); + assert.ok(result.message?.includes('below adoption floor (1.0.0)')); + }); + + it('detects missing local snapshot lockfile for git-tag method', () => { + const lockContent = `method: git-tag +url: https://github.com/apache/magpie.git +ref: v1.0.0 +`; + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, true); + assert.ok(result.message?.includes('Local snapshot lockfile missing')); + }); + + it('returns no drift when git-tag snapshot matches .apache-magpie.local.lock', () => { + const lockContent = `method: git-tag +url: https://github.com/apache/magpie.git +ref: v1.0.0 +`; + const localContent = `source_method: git-tag +source_url: https://github.com/apache/magpie.git +source_ref: v1.0.0 +`; + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.local.lock'), localContent); + + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, false); + assert.strictEqual(result.lockVersion, 'v1.0.0'); + assert.strictEqual(result.message, undefined); + }); + + it('detects snapshot drift when .apache-magpie.local.lock ref differs from committed pin', () => { + const lockContent = `method: git-tag +url: https://github.com/apache/magpie.git +ref: v1.1.0 +`; + const localContent = `source_method: git-tag +source_url: https://github.com/apache/magpie.git +source_ref: v1.0.0 +`; + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.local.lock'), localContent); + + const result = checkSetupDrift(workspaceDir, pluginDir); + assert.strictEqual(result.isAdopted, true); + assert.strictEqual(result.hasDrift, true); + assert.ok(result.message?.includes('Snapshot drift detected (v1.0.0 -> v1.1.0)')); + }); + + it('resolves plugin version correctly from candidate paths', () => { + const version = resolvePluginVersion(pluginDir); + assert.strictEqual(version, '0.9.0'); + }); }); - it('remains silent when session starts without drift', async () => { - fs.writeFileSync( - path.join(workspaceDir, '.apache-magpie.lock'), - JSON.stringify({ - version: '0.9.0', - }) - ); + describe('Claude Code hook registration and AbovePrompt UI', () => { + it('registers session.start and ui.render hooks and renders Box/Text view on drift', async () => { + // Marketplace floor 1.0.0 vs installed 0.9.0 triggers drift + const lockContent = `method: marketplace +url: apache/magpie +min_version: 1.0.0 + +plugins: + - magpie-setup +`; + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + + const registeredHooks = new Map(); + const mockOn = (event: string, ...args: any[]) => { + const handler = args[args.length - 1]; + registeredHooks.set(event, handler); + }; + + register(mockOn); + + assert.ok(registeredHooks.has('session.start')); + assert.ok(registeredHooks.has('ui.render')); + + let nextCalled = false; + const mockNext = async (e: any) => { + nextCalled = true; + return e; + }; + + let uiInvalidated = false; + const mock$ = { + session: { + root: () => workspaceDir, + cwd: () => workspaceDir, + }, + plugin: { + root: pluginDir, + }, + ui: { + invalidate: (target: string) => { + if (target === 'ui.render') { + uiInvalidated = true; + } + }, + resolve: (_e: any) => ({ + Box: (props: any) => ({ type: 'Box', ...props }), + Text: (props: any) => ({ type: 'Text', ...props }), + }), + }, + }; + + // 1. Fire session.start + const sessionStartHandler = registeredHooks.get('session.start')!; + await sessionStartHandler(mock$, {}, mockNext); + + assert.strictEqual(nextCalled, true); + assert.strictEqual(uiInvalidated, true); + + // 2. Fire ui.render for AbovePrompt + let renderedResult: any = null; + const mockRenderNext = async (e: any) => { + renderedResult = e; + return e; + }; + + const uiRenderHandler = registeredHooks.get('ui.render')!; + await uiRenderHandler(mock$, {}, mockRenderNext); + + assert.strictEqual(renderedResult?.view?.type, 'Box'); + const textChild = renderedResult?.view?.children?.[0]; + assert.strictEqual(textChild?.type, 'Text'); + assert.ok(textChild?.text?.includes('below adoption floor (1.0.0)')); + assert.ok(textChild?.text?.includes('/magpie-setup upgrade')); + }); - const registeredHooks = new Map(); - register((event: string, ...args: any[]) => { - registeredHooks.set(event, args[args.length - 1]); - }); - - let uiInvalidated = false; - const mock$ = { - session: { - root: () => workspaceDir, - cwd: () => workspaceDir, - }, - plugin: { - root: pluginDir, - }, - ui: { - invalidate: () => { - uiInvalidated = true; + it('remains silent when session starts without drift (installed 0.9.0 >= floor 0.2.0)', async () => { + const lockContent = `method: marketplace +url: apache/magpie +min_version: 0.2.0 + +plugins: + - magpie-setup +`; + fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + + const registeredHooks = new Map(); + register((event: string, ...args: any[]) => { + registeredHooks.set(event, args[args.length - 1]); + }); + + let uiInvalidated = false; + const mock$ = { + session: { + root: () => workspaceDir, + cwd: () => workspaceDir, }, - resolve: () => ({ - Box: (props: any) => props, - Text: (props: any) => props, - }), - }, - }; + plugin: { + root: pluginDir, + }, + ui: { + invalidate: () => { + uiInvalidated = true; + }, + resolve: () => ({ + Box: (props: any) => props, + Text: (props: any) => props, + }), + }, + }; - const sessionStartHandler = registeredHooks.get('session.start')!; - await sessionStartHandler(mock$, {}, (e: any) => e); - assert.strictEqual(uiInvalidated, false); + const sessionStartHandler = registeredHooks.get('session.start')!; + await sessionStartHandler(mock$, {}, (e: any) => e); + assert.strictEqual(uiInvalidated, false); - let renderedResult: any = null; - const uiRenderHandler = registeredHooks.get('ui.render')!; - await uiRenderHandler(mock$, {}, (e: any) => { - renderedResult = e; - return e; - }); + let renderedResult: any = null; + const uiRenderHandler = registeredHooks.get('ui.render')!; + await uiRenderHandler(mock$, {}, (e: any) => { + renderedResult = e; + return e; + }); - assert.strictEqual(renderedResult?.view, undefined); + assert.strictEqual(renderedResult?.view, undefined); + }); }); }); diff --git a/plugins/magpie-setup/hooks/drift-check.ts b/plugins/magpie-setup/hooks/drift-check.ts index 3e5d48b48..fc4a136aa 100644 --- a/plugins/magpie-setup/hooks/drift-check.ts +++ b/plugins/magpie-setup/hooks/drift-check.ts @@ -29,11 +29,27 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; export interface LockfileData { - version?: string; + method?: string; + url?: string; + min_version?: string; ref?: string; - snapshot_commit?: string; - snapshot_hash?: string; - [key: string]: unknown; + commit?: string; + sha512?: string; + source?: string; + plugins?: string[]; + reconciled?: { + version?: string; + at?: string; + skills?: Record; + }; +} + +export interface LocalLockfileData { + source_method?: string; + source_url?: string; + source_ref?: string; + fetched_commit?: string; + fetched_at?: string; } export interface DriftCheckResult { @@ -44,6 +60,240 @@ export interface DriftCheckResult { message?: string; } +export interface Pep440Version { + epoch: number; + release: number[]; + pre?: { type: string; num: number }; + post?: number; + dev?: number; +} + +/** + * Parse a PEP 440 version string. + * Supports epochs (N!), release segments (N.N.N), pre-releases (a/b/rc), + * post-releases (.postN), and dev-releases (.devN). + */ +export function parsePep440(v: string): Pep440Version | null { + if (!v || typeof v !== 'string') return null; + let s = v.trim().replace(/^v/i, ''); + if (!s) return null; + + let epoch = 0; + const epochMatch = s.match(/^(\d+)!/); + if (epochMatch) { + epoch = parseInt(epochMatch[1], 10); + s = s.slice(epochMatch[0].length); + } + + // Look for .devN + let dev: number | undefined = undefined; + const devMatch = s.match(/[.-]?dev(\d+)?$/i); + if (devMatch) { + dev = devMatch[1] !== undefined ? parseInt(devMatch[1], 10) : 0; + s = s.slice(0, devMatch.index); + } + + // Look for .postN + let post: number | undefined = undefined; + const postMatch = s.match(/[.-]?(post|r|rev)(\d+)?$/i); + if (postMatch) { + post = postMatch[2] !== undefined ? parseInt(postMatch[2], 10) : 0; + s = s.slice(0, postMatch.index); + } + + // Look for pre-release (a|b|rc|alpha|beta|preview|c) + let pre: { type: string; num: number } | undefined = undefined; + const preMatch = s.match(/[.-]?(a|alpha|b|beta|rc|c|preview)(\d+)?$/i); + if (preMatch) { + let type = preMatch[1].toLowerCase(); + if (type === 'alpha') type = 'a'; + if (type === 'beta') type = 'b'; + if (type === 'c' || type === 'preview') type = 'rc'; + const num = preMatch[2] !== undefined ? parseInt(preMatch[2], 10) : 0; + pre = { type, num }; + s = s.slice(0, preMatch.index); + } + + const parts = s.split('.'); + const release: number[] = []; + for (const part of parts) { + if (!part || !/^\d+$/.test(part)) return null; + release.push(parseInt(part, 10)); + } + if (release.length === 0) return null; + + return { epoch, release, pre, post, dev }; +} + +/** + * Compare two PEP 440 version strings. + * Returns < 0 if a < b, 0 if a == b, > 0 if a > b. + */ +export function comparePep440(aStr: string, bStr: string): number { + const a = parsePep440(aStr); + const b = parsePep440(bStr); + + if (!a || !b) { + return aStr.localeCompare(bStr); + } + + if (a.epoch !== b.epoch) { + return a.epoch - b.epoch; + } + + const maxLen = Math.max(a.release.length, b.release.length); + for (let i = 0; i < maxLen; i++) { + const aVal = a.release[i] ?? 0; + const bVal = b.release[i] ?? 0; + if (aVal !== bVal) { + return aVal - bVal; + } + } + + const getPreRank = (v: Pep440Version) => { + if (!v.pre) return 3; + if (v.pre.type === 'a') return 0; + if (v.pre.type === 'b') return 1; + if (v.pre.type === 'rc') return 2; + return -1; + }; + + const aPreRank = getPreRank(a); + const bPreRank = getPreRank(b); + + if (aPreRank !== bPreRank) { + return aPreRank - bPreRank; + } + + if (a.pre && b.pre && a.pre.num !== b.pre.num) { + return a.pre.num - b.pre.num; + } + + const aPost = a.post ?? -1; + const bPost = b.post ?? -1; + if (aPost !== bPost) { + return aPost - bPost; + } + + const aDev = a.dev !== undefined ? a.dev : Infinity; + const bDev = b.dev !== undefined ? b.dev : Infinity; + if (aDev !== bDev) { + return aDev - bDev; + } + + return 0; +} + +/** + * Line-oriented scalar parser for `.apache-magpie.lock` mirroring setup_preflight/lockfile.py. + */ +export function parseLockfile(text: string): LockfileData { + const lock: LockfileData = { plugins: [] }; + let section: string | null = null; + + for (const raw of text.split(/\r?\n/)) { + const line = raw.split('#')[0].trimEnd(); + if (!line.trim()) continue; + + const indent = line.length - line.trimStart().length; + const body = line.trim(); + + if (indent === 0) { + section = null; + if (body.endsWith(':') && !body.slice(0, -1).includes(':')) { + const key = body.slice(0, -1).trim(); + if (key === 'plugins') { + section = 'plugins'; + } else if (key === 'reconciled') { + section = 'reconciled'; + lock.reconciled = { skills: {} }; + } else { + throw new Error(`unknown block: ${key}`); + } + continue; + } + if (!body.includes(':')) { + throw new Error(`not a key: value line: ${raw}`); + } + const [key, ...rest] = body.split(':'); + const val = rest.join(':').trim(); + const trimmedKey = key.trim(); + if (['method', 'url', 'min_version', 'ref', 'commit', 'sha512', 'source'].includes(trimmedKey)) { + (lock as any)[trimmedKey] = val; + } else { + throw new Error(`unknown key: ${trimmedKey}`); + } + continue; + } + + if (section === 'plugins') { + if (!body.startsWith('- ')) { + throw new Error(`not a plugins entry: ${raw}`); + } + lock.plugins!.push(body.slice(2).trim()); + continue; + } + + if (section === 'reconciled') { + if (body === 'skills:') { + section = 'reconciled.skills'; + continue; + } + const [key, ...rest] = body.split(':'); + const val = rest.join(':').trim(); + const trimmedKey = key.trim(); + if (trimmedKey === 'version' || trimmedKey === 'at') { + (lock.reconciled as any)[trimmedKey] = val; + continue; + } + throw new Error(`unknown reconciled key: ${trimmedKey}`); + } + + if (section === 'reconciled.skills') { + const [key, ...rest] = body.split(':'); + const val = rest.join(':').trim(); + if (!val) { + throw new Error(`skill entry without a hash: ${raw}`); + } + lock.reconciled!.skills![key.trim()] = val; + continue; + } + + throw new Error(`indented line outside any block: ${raw}`); + } + + return lock; +} + +/** + * Line-oriented scalar parser for `.apache-magpie.local.lock`. + */ +export function parseLocalLockfile(text: string): LocalLockfileData { + const local: LocalLockfileData = {}; + const validKeys = new Set(['source_method', 'source_url', 'source_ref', 'fetched_commit', 'fetched_at']); + + for (const raw of text.split(/\r?\n/)) { + const line = raw.split('#')[0].trimEnd(); + if (!line.trim()) continue; + + if (line.startsWith(' ') || !line.includes(':')) { + throw new Error(`not a key: value line: ${raw}`); + } + + const [key, ...rest] = line.split(':'); + const trimmedKey = key.trim(); + const val = rest.join(':').trim(); + + if (!validKeys.has(trimmedKey)) { + throw new Error(`unknown key: ${trimmedKey}`); + } + + (local as any)[trimmedKey] = val; + } + + return local; +} + /** * Resolve the plugin version from available plugin manifests. */ @@ -59,7 +309,6 @@ export function resolvePluginVersion(pluginDir?: string): string | undefined { ); } - // Check standard environment paths const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || process.env.PLUGIN_ROOT; if (pluginRoot) { candidates.push( @@ -86,7 +335,7 @@ export function resolvePluginVersion(pluginDir?: string): string | undefined { } /** - * Core deterministic drift check between workspace lockfile and installed plugin. + * Core deterministic drift check between workspace lockfile and installed plugin/snapshot. */ export function checkSetupDrift( workspaceDir: string, @@ -95,7 +344,6 @@ export function checkSetupDrift( try { const lockPath = path.join(workspaceDir, '.apache-magpie.lock'); - // If no lockfile is present, this is not an adopted workspace if (!fs.existsSync(lockPath)) { return { isAdopted: false, @@ -113,11 +361,10 @@ export function checkSetupDrift( }; } - let lockData: LockfileData = {}; + let lockData: LockfileData; try { - lockData = JSON.parse(content); + lockData = parseLockfile(content); } catch { - // Malformed lockfile counts as drift needing reconciliation return { isAdopted: true, hasDrift: true, @@ -126,28 +373,135 @@ export function checkSetupDrift( }; } - const currentVersion = resolvePluginVersion(pluginDir); - const lockVersion = lockData.version || lockData.ref || undefined; + const method = lockData.method; + + // 1. Method: marketplace (Adoption Floor semantics) + if (method === 'marketplace') { + const minVersion = lockData.min_version; + if (!minVersion) { + return { + isAdopted: true, + hasDrift: true, + message: + 'Apache Magpie: Malformed `.apache-magpie.lock` (missing min_version). Run `/magpie-setup upgrade` to repair.', + }; + } + + const currentVersion = resolvePluginVersion(pluginDir); + if (currentVersion) { + // Floor semantics: drift only occurs when installed < min_version + if (comparePep440(currentVersion, minVersion) < 0) { + return { + isAdopted: true, + hasDrift: true, + lockVersion: minVersion, + currentVersion, + message: `Apache Magpie: Installed plugin version (${currentVersion}) is below adoption floor (${minVersion}). Run \`/magpie-setup upgrade\` to reconcile.`, + }; + } + + return { + isAdopted: true, + hasDrift: false, + lockVersion: minVersion, + currentVersion, + }; + } - // If both versions are known and differ, drift is detected - if (currentVersion && lockVersion && currentVersion !== lockVersion) { return { isAdopted: true, - hasDrift: true, - lockVersion, - currentVersion, - message: `Apache Magpie: Snapshot drift detected (${lockVersion} -> ${currentVersion}). Run \`/magpie-setup upgrade\` to reconcile the snapshot and overrides.`, + hasDrift: false, + lockVersion: minVersion, }; } + // 2. Snapshot methods (git-tag, git-branch, svn-zip) + if (method === 'git-tag' || method === 'git-branch' || method === 'svn-zip') { + const localLockPath = path.join(workspaceDir, '.apache-magpie.local.lock'); + const committedPin = lockData.ref || lockData.commit; + + if (!fs.existsSync(localLockPath)) { + return { + isAdopted: true, + hasDrift: true, + lockVersion: committedPin, + message: + 'Apache Magpie: Local snapshot lockfile missing (`.apache-magpie.local.lock`). Run `/magpie-setup upgrade` to fetch snapshot.', + }; + } + + const localContent = fs.readFileSync(localLockPath, 'utf-8').trim(); + if (!localContent) { + return { + isAdopted: true, + hasDrift: true, + lockVersion: committedPin, + message: + 'Apache Magpie: Empty `.apache-magpie.local.lock` detected. Run `/magpie-setup upgrade` to refresh.', + }; + } + + let localData: LocalLockfileData; + try { + localData = parseLocalLockfile(localContent); + } catch { + return { + isAdopted: true, + hasDrift: true, + lockVersion: committedPin, + message: + 'Apache Magpie: Malformed `.apache-magpie.local.lock` detected. Run `/magpie-setup upgrade` to repair.', + }; + } + + if (method === 'git-tag' || method === 'git-branch') { + const refMismatch = Boolean(lockData.ref && localData.source_ref && lockData.ref !== localData.source_ref); + const commitMismatch = Boolean( + lockData.commit && localData.fetched_commit && lockData.commit !== localData.fetched_commit + ); + + if (refMismatch || commitMismatch) { + const localPin = localData.source_ref || localData.fetched_commit; + return { + isAdopted: true, + hasDrift: true, + lockVersion: committedPin, + message: `Apache Magpie: Snapshot drift detected (${localPin} -> ${committedPin}). Run \`/magpie-setup upgrade\` to reconcile snapshot and overrides.`, + }; + } + + return { + isAdopted: true, + hasDrift: false, + lockVersion: committedPin, + }; + } + + if (method === 'svn-zip') { + if (lockData.ref && localData.source_ref && lockData.ref !== localData.source_ref) { + return { + isAdopted: true, + hasDrift: true, + lockVersion: lockData.ref, + message: `Apache Magpie: Snapshot drift detected (${localData.source_ref} -> ${lockData.ref}). Run \`/magpie-setup upgrade\` to reconcile snapshot and overrides.`, + }; + } + + return { + isAdopted: true, + hasDrift: false, + lockVersion: lockData.ref, + }; + } + } + return { isAdopted: true, - hasDrift: false, - lockVersion, - currentVersion, + hasDrift: true, + message: + 'Apache Magpie: Malformed `.apache-magpie.lock` (unknown or missing method). Run `/magpie-setup upgrade` to repair.', }; } catch { - // Fail closed/silently on unhandled errors to avoid crashing session start return { isAdopted: false, hasDrift: false, @@ -168,7 +522,6 @@ export function register(on: any, options?: any): void { // 1. Hook session.start to check for lockfile drift on('session.start', async ($: any, e: any, next?: any) => { try { - // Check session.cwd, session.root (worktree root), or process.cwd() const workspaceDir = (typeof $?.session?.root === 'function' ? $.session.root() : undefined) || (typeof $?.session?.cwd === 'function' ? $.session.cwd() : undefined) || From eba2288b6aa481a77b421e1e074b6e7758847f4c Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Wed, 7 Oct 2026 19:11:19 +0530 Subject: [PATCH 03/14] fix(setup): retain SessionStart hook in plugin.json for family plugin validation (#1497) --- plugins/magpie-setup/.claude-plugin/plugin.json | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/magpie-setup/.claude-plugin/plugin.json b/plugins/magpie-setup/.claude-plugin/plugin.json index 002de849b..44af6fe40 100644 --- a/plugins/magpie-setup/.claude-plugin/plugin.json +++ b/plugins/magpie-setup/.claude-plugin/plugin.json @@ -9,5 +9,19 @@ "homepage": "https://magpie.apache.org/", "repository": "https://github.com/apache/magpie", "license": "Apache-2.0", - "skills": "./skills" + "skills": "./skills", + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh", + "timeout": 10 + } + ] + } + ] + } } From ba0683ed149bf996e0a70fb7aa466d5cbf548166 Mon Sep 17 00:00:00 2001 From: Arnav Date: Thu, 8 Oct 2026 23:54:04 +0530 Subject: [PATCH 04/14] fix(setup): reconcile hooks.json mod manifest and eliminate duplicate inline hook (#1497) - Update tools/dev/check-family-plugins.py to recognize hooks/hooks.json for magpie-setup and enforce that inline 'hooks' is dropped from .claude-plugin/plugin.json when hooks.json is present, preventing Claude Code duplicate hooks load collisions. - Remove duplicate inline 'hooks' from plugins/magpie-setup/.claude-plugin/plugin.json. Co-authored-by: Vardhman Gupta --- .../magpie-setup/.claude-plugin/plugin.json | 16 +--------------- tools/dev/check-family-plugins.py | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 18 deletions(-) diff --git a/plugins/magpie-setup/.claude-plugin/plugin.json b/plugins/magpie-setup/.claude-plugin/plugin.json index 44af6fe40..002de849b 100644 --- a/plugins/magpie-setup/.claude-plugin/plugin.json +++ b/plugins/magpie-setup/.claude-plugin/plugin.json @@ -9,19 +9,5 @@ "homepage": "https://magpie.apache.org/", "repository": "https://github.com/apache/magpie", "license": "Apache-2.0", - "skills": "./skills", - "hooks": { - "SessionStart": [ - { - "matcher": "startup", - "hooks": [ - { - "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh", - "timeout": 10 - } - ] - } - ] - } + "skills": "./skills" } diff --git a/tools/dev/check-family-plugins.py b/tools/dev/check-family-plugins.py index 43fdec759..b39fbac2f 100644 --- a/tools/dev/check-family-plugins.py +++ b/tools/dev/check-family-plugins.py @@ -749,9 +749,22 @@ def check(fam: dict[str, set[str]]) -> list[str]: if not UPGRADE_HOOK.is_file(): errors.append(f"{UPGRADE_HOOK}: missing (magpie-setup's SessionStart upgrade check)") setup_manifest = PLUGINS / "magpie-setup" / ".claude-plugin" / "plugin.json" + setup_hooks_manifest = PLUGINS / "magpie-setup" / "hooks" / "hooks.json" setup_data, _setup_err = load_json(setup_manifest) - if setup_data is not None and "check-upgrade.sh" not in json.dumps(setup_data.get("hooks", {})): - errors.append(f"{setup_manifest}: SessionStart hook does not reference check-upgrade.sh") + if setup_hooks_manifest.is_file(): + hooks_data, hooks_err = load_json(setup_hooks_manifest) + if hooks_err: + errors.append(f"{setup_hooks_manifest}: {hooks_err}") + elif not isinstance(hooks_data.get("modules"), list) or not hooks_data.get("modules"): + errors.append(f"{setup_hooks_manifest}: 'modules' is missing or not a non-empty list") + if setup_data is not None and "hooks" in setup_data: + errors.append( + f"{setup_manifest}: declares inline 'hooks' while {setup_hooks_manifest} is present; " + "drop inline 'hooks' to avoid duplicate hooks collision in Claude Code" + ) + else: + if setup_data is not None and "check-upgrade.sh" not in json.dumps(setup_data.get("hooks", {})): + errors.append(f"{setup_manifest}: SessionStart hook does not reference check-upgrade.sh") errors += check_setup_preflight_package() errors += check_setup_templates() @@ -973,7 +986,7 @@ def fix(fam: dict[str, set[str]]) -> int: **shared, "skills": "./skills", } - if name == "magpie-setup": + if name == "magpie-setup" and not (pdir / "hooks" / "hooks.json").is_file(): manifest["hooks"] = SETUP_HOOKS (pdir / ".claude-plugin" / "plugin.json").write_text( json.dumps(manifest, indent=2) + "\n", encoding="utf-8" From ee098d8edb5a8d45f70ee91d9d24023b13bebfdb Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Fri, 9 Oct 2026 15:27:01 +0530 Subject: [PATCH 05/14] fix(setup): align drift-check mod with Claude Code sandbox API and test runner (#1497) - Remove node:fs and node:path imports to satisfy claude plugin validate --strict - Accept harness-provided $.fs interface in checkSetupDrift and resolvePluginVersion - Add zero-dependency joinPath helper for path resolution - Import from claude-code/testing in test suite and use in-memory MockFs --- .../magpie-setup/hooks/drift-check.test.ts | 287 ++++++++++-------- plugins/magpie-setup/hooks/drift-check.ts | 96 +++--- 2 files changed, 226 insertions(+), 157 deletions(-) diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index b0821ac7d..45cc0bdec 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -15,11 +15,7 @@ // specific language governing permissions and limitations // under the License. -import { describe, it, beforeEach, afterEach } from 'node:test'; -import * as assert from 'node:assert'; -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; +import { describe, it } from 'claude-code/testing'; import { checkSetupDrift, resolvePluginVersion, @@ -27,63 +23,106 @@ import { parseLocalLockfile, parsePep440, comparePep440, + joinPath, register, + type FsApi, } from './drift-check.ts'; -describe('drift-check mod (Pilot 1)', () => { - let tmpDir: string; - let workspaceDir: string; - let pluginDir: string; +/** + * In-memory Mock filesystem fulfilling FsApi without Node built-in imports. + */ +class MockFs implements FsApi { + private files = new Map(); + + set(filePath: string, content: string): void { + this.files.set(filePath.replace(/\\/g, '/'), content); + } + + existsSync(filePath: string): boolean { + return this.files.has(filePath.replace(/\\/g, '/')); + } - beforeEach(() => { - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'magpie-mod-test-')); - workspaceDir = path.join(tmpDir, 'workspace'); - pluginDir = path.join(tmpDir, 'plugin'); + readFileSync(filePath: string, _encoding?: string): string { + const val = this.files.get(filePath.replace(/\\/g, '/')); + if (val === undefined) { + throw new Error(`ENOENT: no such file: ${filePath}`); + } + return val; + } +} + +function assert(condition: any, message?: string): void { + if (!condition) { + throw new Error(message || 'Assertion failed'); + } +} + +function assertStrictEqual(actual: any, expected: any, message?: string): void { + if (actual !== expected) { + throw new Error(message || `Expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}`); + } +} + +function assertDeepStrictEqual(actual: any, expected: any): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Expected deep equality: ${JSON.stringify(expected)} !== ${JSON.stringify(actual)}`); + } +} + +function assertThrows(fn: () => void, pattern?: RegExp): void { + let threw = false; + try { + fn(); + } catch (err: any) { + threw = true; + if (pattern && !pattern.test(err?.message || '')) { + throw new Error(`Expected error matching ${pattern} but got: ${err?.message}`); + } + } + if (!threw) { + throw new Error('Expected function to throw an error'); + } +} - fs.mkdirSync(workspaceDir, { recursive: true }); - fs.mkdirSync(path.join(pluginDir, '.claude-plugin'), { recursive: true }); +describe('drift-check mod (Pilot 1)', () => { + const workspaceDir = '/test/workspace'; + const pluginDir = '/test/plugin'; - // Mock plugin manifest with version 0.9.0 - fs.writeFileSync( - path.join(pluginDir, '.claude-plugin', 'plugin.json'), + function createTestEnv(): MockFs { + const fs = new MockFs(); + fs.set( + joinPath(pluginDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ name: 'magpie-setup', version: '0.9.0', }) ); - }); - - afterEach(() => { - try { - fs.rmSync(tmpDir, { recursive: true, force: true }); - } catch { - // Ignore cleanup errors - } - }); + return fs; + } describe('PEP 440 parsing and comparison', () => { it('correctly compares numeric release segments (0.10.0 > 0.9.0)', () => { - assert.ok(comparePep440('0.10.0', '0.9.0') > 0); - assert.ok(comparePep440('0.9.0', '0.10.0') < 0); - assert.strictEqual(comparePep440('0.9.0', '0.9.0'), 0); + assert(comparePep440('0.10.0', '0.9.0') > 0); + assert(comparePep440('0.9.0', '0.10.0') < 0); + assertStrictEqual(comparePep440('0.9.0', '0.9.0'), 0); }); it('correctly orders development releases (0.2.0 > 0.2.0.dev202609110041)', () => { - assert.ok(comparePep440('0.2.0', '0.2.0.dev202609110041') > 0); - assert.ok(comparePep440('0.2.0.dev202609110041', '0.2.0') < 0); - assert.ok(comparePep440('0.9.0.dev202609262258', '0.2.0') > 0); + assert(comparePep440('0.2.0', '0.2.0.dev202609110041') > 0); + assert(comparePep440('0.2.0.dev202609110041', '0.2.0') < 0); + assert(comparePep440('0.9.0.dev202609262258', '0.2.0') > 0); }); it('correctly orders pre-releases, final releases, and post-releases', () => { - assert.ok(comparePep440('1.0.0a1', '1.0.0b1') < 0); - assert.ok(comparePep440('1.0.0b1', '1.0.0rc1') < 0); - assert.ok(comparePep440('1.0.0rc1', '1.0.0') < 0); - assert.ok(comparePep440('1.0.0.post1', '1.0.0') > 0); + assert(comparePep440('1.0.0a1', '1.0.0b1') < 0); + assert(comparePep440('1.0.0b1', '1.0.0rc1') < 0); + assert(comparePep440('1.0.0rc1', '1.0.0') < 0); + assert(comparePep440('1.0.0.post1', '1.0.0') > 0); }); it('parses epochs and complex versions correctly', () => { - assert.ok(comparePep440('1!0.1.0', '0.9.0') > 0); - assert.strictEqual(parsePep440('invalid-version'), null); + assert(comparePep440('1!0.1.0', '0.9.0') > 0); + assertStrictEqual(parsePep440('invalid-version'), null); }); }); @@ -99,10 +138,10 @@ plugins: - magpie-utilities `; const lock = parseLockfile(yaml); - assert.strictEqual(lock.method, 'marketplace'); - assert.strictEqual(lock.url, 'apache/magpie'); - assert.strictEqual(lock.min_version, '0.2.0'); - assert.deepStrictEqual(lock.plugins, ['magpie-setup', 'magpie-utilities']); + assertStrictEqual(lock.method, 'marketplace'); + assertStrictEqual(lock.url, 'apache/magpie'); + assertStrictEqual(lock.min_version, '0.2.0'); + assertDeepStrictEqual(lock.plugins, ['magpie-setup', 'magpie-utilities']); }); it('parses git-tag snapshot YAML lockfile', () => { @@ -112,9 +151,9 @@ ref: v1.0.0 commit: abc1234 `; const lock = parseLockfile(yaml); - assert.strictEqual(lock.method, 'git-tag'); - assert.strictEqual(lock.ref, 'v1.0.0'); - assert.strictEqual(lock.commit, 'abc1234'); + assertStrictEqual(lock.method, 'git-tag'); + assertStrictEqual(lock.ref, 'v1.0.0'); + assertStrictEqual(lock.commit, 'abc1234'); }); it('parses local fingerprint lockfile (.apache-magpie.local.lock)', () => { @@ -126,49 +165,52 @@ fetched_commit: abc1234 fetched_at: 2026-10-07T00:00:00Z `; const local = parseLocalLockfile(yaml); - assert.strictEqual(local.source_method, 'git-tag'); - assert.strictEqual(local.source_ref, 'v1.0.0'); - assert.strictEqual(local.fetched_commit, 'abc1234'); + assertStrictEqual(local.source_method, 'git-tag'); + assertStrictEqual(local.source_ref, 'v1.0.0'); + assertStrictEqual(local.fetched_commit, 'abc1234'); }); it('throws on malformed YAML syntax', () => { - assert.throws(() => parseLockfile('invalid line without colon'), /not a key: value line/); - assert.throws(() => parseLockfile('unknown_key: value'), /unknown key/); - assert.throws(() => parseLocalLockfile('unknown_local_key: value'), /unknown key/); + assertThrows(() => parseLockfile('invalid line without colon'), /not a key: value line/); + assertThrows(() => parseLockfile('unknown_key: value'), /unknown key/); + assertThrows(() => parseLocalLockfile('unknown_local_key: value'), /unknown key/); }); }); describe('checkSetupDrift workflow verification', () => { it('returns not adopted when .apache-magpie.lock does not exist', () => { - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, false); - assert.strictEqual(result.hasDrift, false); - assert.strictEqual(result.message, undefined); + const fs = createTestEnv(); + const result = checkSetupDrift(fs, workspaceDir, pluginDir); + assertStrictEqual(result.isAdopted, false); + assertStrictEqual(result.hasDrift, false); + assertStrictEqual(result.message, undefined); }); it('detects drift when lockfile is completely empty', () => { - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), ''); + const fs = createTestEnv(); + fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), ''); - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, true); - assert.ok(result.message?.includes('Empty `.apache-magpie.lock`')); + const result = checkSetupDrift(fs, workspaceDir, pluginDir); + assertStrictEqual(result.isAdopted, true); + assertStrictEqual(result.hasDrift, true); + assert(result.message?.includes('Empty `.apache-magpie.lock`')); }); it('detects drift when lockfile contains invalid / malformed YAML', () => { - fs.writeFileSync( - path.join(workspaceDir, '.apache-magpie.lock'), + const fs = createTestEnv(); + fs.set( + joinPath(workspaceDir, '.apache-magpie.lock'), 'invalid yaml content {{{ no colons' ); - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, true); - assert.ok(result.message?.includes('Malformed `.apache-magpie.lock`')); + const result = checkSetupDrift(fs, workspaceDir, pluginDir); + assertStrictEqual(result.isAdopted, true); + assertStrictEqual(result.hasDrift, true); + assert(result.message?.includes('Malformed `.apache-magpie.lock`')); }); it('satisfies marketplace floor when installed version meets or exceeds min_version', () => { - // Lock floor: 0.2.0, Installed: 0.9.0 + const fs = createTestEnv(); const lockContent = `method: marketplace url: apache/magpie min_version: 0.2.0 @@ -176,18 +218,18 @@ min_version: 0.2.0 plugins: - magpie-setup `; - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); - - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, false); - assert.strictEqual(result.lockVersion, '0.2.0'); - assert.strictEqual(result.currentVersion, '0.9.0'); - assert.strictEqual(result.message, undefined); + fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); + + const result = checkSetupDrift(fs, workspaceDir, pluginDir); + assertStrictEqual(result.isAdopted, true); + assertStrictEqual(result.hasDrift, false); + assertStrictEqual(result.lockVersion, '0.2.0'); + assertStrictEqual(result.currentVersion, '0.9.0'); + assertStrictEqual(result.message, undefined); }); it('detects drift when installed plugin is below marketplace adoption floor', () => { - // Lock floor: 1.0.0, Installed: 0.9.0 + const fs = createTestEnv(); const lockContent = `method: marketplace url: apache/magpie min_version: 1.0.0 @@ -195,30 +237,32 @@ min_version: 1.0.0 plugins: - magpie-setup `; - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); - - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, true); - assert.strictEqual(result.lockVersion, '1.0.0'); - assert.strictEqual(result.currentVersion, '0.9.0'); - assert.ok(result.message?.includes('below adoption floor (1.0.0)')); + fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); + + const result = checkSetupDrift(fs, workspaceDir, pluginDir); + assertStrictEqual(result.isAdopted, true); + assertStrictEqual(result.hasDrift, true); + assertStrictEqual(result.lockVersion, '1.0.0'); + assertStrictEqual(result.currentVersion, '0.9.0'); + assert(result.message?.includes('below adoption floor (1.0.0)')); }); it('detects missing local snapshot lockfile for git-tag method', () => { + const fs = createTestEnv(); const lockContent = `method: git-tag url: https://github.com/apache/magpie.git ref: v1.0.0 `; - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, true); - assert.ok(result.message?.includes('Local snapshot lockfile missing')); + const result = checkSetupDrift(fs, workspaceDir, pluginDir); + assertStrictEqual(result.isAdopted, true); + assertStrictEqual(result.hasDrift, true); + assert(result.message?.includes('Local snapshot lockfile missing')); }); it('returns no drift when git-tag snapshot matches .apache-magpie.local.lock', () => { + const fs = createTestEnv(); const lockContent = `method: git-tag url: https://github.com/apache/magpie.git ref: v1.0.0 @@ -227,17 +271,18 @@ ref: v1.0.0 source_url: https://github.com/apache/magpie.git source_ref: v1.0.0 `; - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.local.lock'), localContent); - - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, false); - assert.strictEqual(result.lockVersion, 'v1.0.0'); - assert.strictEqual(result.message, undefined); + fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); + fs.set(joinPath(workspaceDir, '.apache-magpie.local.lock'), localContent); + + const result = checkSetupDrift(fs, workspaceDir, pluginDir); + assertStrictEqual(result.isAdopted, true); + assertStrictEqual(result.hasDrift, false); + assertStrictEqual(result.lockVersion, 'v1.0.0'); + assertStrictEqual(result.message, undefined); }); it('detects snapshot drift when .apache-magpie.local.lock ref differs from committed pin', () => { + const fs = createTestEnv(); const lockContent = `method: git-tag url: https://github.com/apache/magpie.git ref: v1.1.0 @@ -246,24 +291,25 @@ ref: v1.1.0 source_url: https://github.com/apache/magpie.git source_ref: v1.0.0 `; - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.local.lock'), localContent); + fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); + fs.set(joinPath(workspaceDir, '.apache-magpie.local.lock'), localContent); - const result = checkSetupDrift(workspaceDir, pluginDir); - assert.strictEqual(result.isAdopted, true); - assert.strictEqual(result.hasDrift, true); - assert.ok(result.message?.includes('Snapshot drift detected (v1.0.0 -> v1.1.0)')); + const result = checkSetupDrift(fs, workspaceDir, pluginDir); + assertStrictEqual(result.isAdopted, true); + assertStrictEqual(result.hasDrift, true); + assert(result.message?.includes('Snapshot drift detected (v1.0.0 -> v1.1.0)')); }); it('resolves plugin version correctly from candidate paths', () => { - const version = resolvePluginVersion(pluginDir); - assert.strictEqual(version, '0.9.0'); + const fs = createTestEnv(); + const version = resolvePluginVersion(fs, pluginDir); + assertStrictEqual(version, '0.9.0'); }); }); describe('Claude Code hook registration and AbovePrompt UI', () => { it('registers session.start and ui.render hooks and renders Box/Text view on drift', async () => { - // Marketplace floor 1.0.0 vs installed 0.9.0 triggers drift + const fs = createTestEnv(); const lockContent = `method: marketplace url: apache/magpie min_version: 1.0.0 @@ -271,7 +317,7 @@ min_version: 1.0.0 plugins: - magpie-setup `; - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); const registeredHooks = new Map(); const mockOn = (event: string, ...args: any[]) => { @@ -281,8 +327,8 @@ plugins: register(mockOn); - assert.ok(registeredHooks.has('session.start')); - assert.ok(registeredHooks.has('ui.render')); + assert(registeredHooks.has('session.start')); + assert(registeredHooks.has('ui.render')); let nextCalled = false; const mockNext = async (e: any) => { @@ -292,6 +338,7 @@ plugins: let uiInvalidated = false; const mock$ = { + fs, session: { root: () => workspaceDir, cwd: () => workspaceDir, @@ -316,8 +363,8 @@ plugins: const sessionStartHandler = registeredHooks.get('session.start')!; await sessionStartHandler(mock$, {}, mockNext); - assert.strictEqual(nextCalled, true); - assert.strictEqual(uiInvalidated, true); + assertStrictEqual(nextCalled, true); + assertStrictEqual(uiInvalidated, true); // 2. Fire ui.render for AbovePrompt let renderedResult: any = null; @@ -329,14 +376,15 @@ plugins: const uiRenderHandler = registeredHooks.get('ui.render')!; await uiRenderHandler(mock$, {}, mockRenderNext); - assert.strictEqual(renderedResult?.view?.type, 'Box'); + assertStrictEqual(renderedResult?.view?.type, 'Box'); const textChild = renderedResult?.view?.children?.[0]; - assert.strictEqual(textChild?.type, 'Text'); - assert.ok(textChild?.text?.includes('below adoption floor (1.0.0)')); - assert.ok(textChild?.text?.includes('/magpie-setup upgrade')); + assertStrictEqual(textChild?.type, 'Text'); + assert(textChild?.text?.includes('below adoption floor (1.0.0)')); + assert(textChild?.text?.includes('/magpie-setup upgrade')); }); it('remains silent when session starts without drift (installed 0.9.0 >= floor 0.2.0)', async () => { + const fs = createTestEnv(); const lockContent = `method: marketplace url: apache/magpie min_version: 0.2.0 @@ -344,7 +392,7 @@ min_version: 0.2.0 plugins: - magpie-setup `; - fs.writeFileSync(path.join(workspaceDir, '.apache-magpie.lock'), lockContent); + fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); const registeredHooks = new Map(); register((event: string, ...args: any[]) => { @@ -353,6 +401,7 @@ plugins: let uiInvalidated = false; const mock$ = { + fs, session: { root: () => workspaceDir, cwd: () => workspaceDir, @@ -373,7 +422,7 @@ plugins: const sessionStartHandler = registeredHooks.get('session.start')!; await sessionStartHandler(mock$, {}, (e: any) => e); - assert.strictEqual(uiInvalidated, false); + assertStrictEqual(uiInvalidated, false); let renderedResult: any = null; const uiRenderHandler = registeredHooks.get('ui.render')!; @@ -382,7 +431,7 @@ plugins: return e; }); - assert.strictEqual(renderedResult?.view, undefined); + assertStrictEqual(renderedResult?.view, undefined); }); }); }); diff --git a/plugins/magpie-setup/hooks/drift-check.ts b/plugins/magpie-setup/hooks/drift-check.ts index fc4a136aa..4f0c2b418 100644 --- a/plugins/magpie-setup/hooks/drift-check.ts +++ b/plugins/magpie-setup/hooks/drift-check.ts @@ -25,8 +25,10 @@ * Implements Pilot 1 of RFC-AI-0004 Claude Code Mods (Issue #1497). */ -import * as fs from 'node:fs'; -import * as path from 'node:path'; +export interface FsApi { + existsSync(filePath: string): boolean; + readFileSync(filePath: string, encoding?: string): string; +} export interface LockfileData { method?: string; @@ -68,6 +70,17 @@ export interface Pep440Version { dev?: number; } +/** + * Zero-dependency path joiner to comply with strict sandbox module restrictions. + */ +export function joinPath(...parts: (string | undefined)[]): string { + return parts + .filter((p): p is string => Boolean(p && typeof p === 'string')) + .map((p, i) => (i === 0 ? p.replace(/[\\/]+$/, '') : p.replace(/^[\\/]+|[\\/]+$/g, ''))) + .filter(Boolean) + .join('/'); +} + /** * Parse a PEP 440 version string. * Supports epochs (N!), release segments (N.N.N), pre-releases (a/b/rc), @@ -295,32 +308,28 @@ export function parseLocalLockfile(text: string): LocalLockfileData { } /** - * Resolve the plugin version from available plugin manifests. + * Resolve the plugin version from available plugin manifests via the harness filesystem API. */ -export function resolvePluginVersion(pluginDir?: string): string | undefined { +export function resolvePluginVersion(fsApi: FsApi, pluginDir?: string): string | undefined { + if (!fsApi || typeof fsApi.existsSync !== 'function' || typeof fsApi.readFileSync !== 'function') { + return undefined; + } + const candidates: string[] = []; if (pluginDir) { candidates.push( - path.join(pluginDir, '.claude-plugin', 'plugin.json'), - path.join(pluginDir, 'plugin.json'), - path.join(pluginDir, '..', '.claude-plugin', 'plugin.json'), - path.join(pluginDir, '..', 'plugin.json') - ); - } - - const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || process.env.PLUGIN_ROOT; - if (pluginRoot) { - candidates.push( - path.join(pluginRoot, '.claude-plugin', 'plugin.json'), - path.join(pluginRoot, 'plugin.json') + joinPath(pluginDir, '.claude-plugin', 'plugin.json'), + joinPath(pluginDir, 'plugin.json'), + joinPath(pluginDir, '..', '.claude-plugin', 'plugin.json'), + joinPath(pluginDir, '..', 'plugin.json') ); } for (const candidate of candidates) { try { - if (fs.existsSync(candidate)) { - const raw = fs.readFileSync(candidate, 'utf-8'); + if (fsApi.existsSync(candidate)) { + const raw = fsApi.readFileSync(candidate, 'utf-8'); const parsed = JSON.parse(raw); if (parsed && typeof parsed.version === 'string' && parsed.version.trim()) { return parsed.version.trim(); @@ -338,20 +347,28 @@ export function resolvePluginVersion(pluginDir?: string): string | undefined { * Core deterministic drift check between workspace lockfile and installed plugin/snapshot. */ export function checkSetupDrift( + fsApi: FsApi, workspaceDir: string, pluginDir?: string ): DriftCheckResult { try { - const lockPath = path.join(workspaceDir, '.apache-magpie.lock'); + if (!fsApi || typeof fsApi.existsSync !== 'function' || typeof fsApi.readFileSync !== 'function') { + return { + isAdopted: false, + hasDrift: false, + }; + } - if (!fs.existsSync(lockPath)) { + const lockPath = joinPath(workspaceDir, '.apache-magpie.lock'); + + if (!fsApi.existsSync(lockPath)) { return { isAdopted: false, hasDrift: false, }; } - const content = fs.readFileSync(lockPath, 'utf-8').trim(); + const content = fsApi.readFileSync(lockPath, 'utf-8').trim(); if (!content) { return { isAdopted: true, @@ -387,7 +404,7 @@ export function checkSetupDrift( }; } - const currentVersion = resolvePluginVersion(pluginDir); + const currentVersion = resolvePluginVersion(fsApi, pluginDir); if (currentVersion) { // Floor semantics: drift only occurs when installed < min_version if (comparePep440(currentVersion, minVersion) < 0) { @@ -417,10 +434,10 @@ export function checkSetupDrift( // 2. Snapshot methods (git-tag, git-branch, svn-zip) if (method === 'git-tag' || method === 'git-branch' || method === 'svn-zip') { - const localLockPath = path.join(workspaceDir, '.apache-magpie.local.lock'); + const localLockPath = joinPath(workspaceDir, '.apache-magpie.local.lock'); const committedPin = lockData.ref || lockData.commit; - if (!fs.existsSync(localLockPath)) { + if (!fsApi.existsSync(localLockPath)) { return { isAdopted: true, hasDrift: true, @@ -430,7 +447,7 @@ export function checkSetupDrift( }; } - const localContent = fs.readFileSync(localLockPath, 'utf-8').trim(); + const localContent = fsApi.readFileSync(localLockPath, 'utf-8').trim(); if (!localContent) { return { isAdopted: true, @@ -512,7 +529,7 @@ export function checkSetupDrift( /** * Event hook registration entry point for Claude Code. */ -export function register(on: any, options?: any): void { +export function register(on: any, _options?: any): void { if (typeof on !== 'function') { return; } @@ -522,30 +539,33 @@ export function register(on: any, options?: any): void { // 1. Hook session.start to check for lockfile drift on('session.start', async ($: any, e: any, next?: any) => { try { + const fsApi: FsApi = $?.fs; + const workspaceDir = (typeof $?.session?.root === 'function' ? $.session.root() : undefined) || (typeof $?.session?.cwd === 'function' ? $.session.cwd() : undefined) || $?.workspacePath || $?.cwd || - process.cwd(); + '.'; const pluginDir = $?.plugin?.root || $?.pluginPath || - process.env.CLAUDE_PLUGIN_ROOT || - path.resolve(__dirname, '..'); + '..'; - const result = checkSetupDrift(workspaceDir, pluginDir); + if (fsApi) { + const result = checkSetupDrift(fsApi, workspaceDir, pluginDir); - if (result.hasDrift && result.message) { - driftNotice = result.message; + if (result.hasDrift && result.message) { + driftNotice = result.message; - if (typeof $?.ui?.invalidate === 'function') { - $.ui.invalidate('ui.render'); - } else if (typeof $?.ui?.toast === 'function') { - $.ui.toast(result.message, { level: 'info' }); - } else if (typeof $?.ui?.banner === 'function') { - $.ui.banner(result.message); + if (typeof $?.ui?.invalidate === 'function') { + $.ui.invalidate('ui.render'); + } else if (typeof $?.ui?.toast === 'function') { + $.ui.toast(result.message, { level: 'info' }); + } else if (typeof $?.ui?.banner === 'function') { + $.ui.banner(result.message); + } } } } catch { From f5db8e5a981f324e433748fcf4c648fb39590426 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Fri, 9 Oct 2026 15:38:11 +0530 Subject: [PATCH 06/14] fix(setup): align hook registration AST and test exports for Claude Code mod validator (#1497) - Remove typeof on check in register to satisfy strict static analysis - Import test instead of it from claude-code/testing --- .../magpie-setup/hooks/drift-check.test.ts | 40 +++++++++---------- plugins/magpie-setup/hooks/drift-check.ts | 8 +--- 2 files changed, 22 insertions(+), 26 deletions(-) diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index 45cc0bdec..d0fbb289d 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -15,7 +15,7 @@ // specific language governing permissions and limitations // under the License. -import { describe, it } from 'claude-code/testing'; +import { describe, test } from 'claude-code/testing'; import { checkSetupDrift, resolvePluginVersion, @@ -101,33 +101,33 @@ describe('drift-check mod (Pilot 1)', () => { } describe('PEP 440 parsing and comparison', () => { - it('correctly compares numeric release segments (0.10.0 > 0.9.0)', () => { + test('correctly compares numeric release segments (0.10.0 > 0.9.0)', () => { assert(comparePep440('0.10.0', '0.9.0') > 0); assert(comparePep440('0.9.0', '0.10.0') < 0); assertStrictEqual(comparePep440('0.9.0', '0.9.0'), 0); }); - it('correctly orders development releases (0.2.0 > 0.2.0.dev202609110041)', () => { + test('correctly orders development releases (0.2.0 > 0.2.0.dev202609110041)', () => { assert(comparePep440('0.2.0', '0.2.0.dev202609110041') > 0); assert(comparePep440('0.2.0.dev202609110041', '0.2.0') < 0); assert(comparePep440('0.9.0.dev202609262258', '0.2.0') > 0); }); - it('correctly orders pre-releases, final releases, and post-releases', () => { + test('correctly orders pre-releases, final releases, and post-releases', () => { assert(comparePep440('1.0.0a1', '1.0.0b1') < 0); assert(comparePep440('1.0.0b1', '1.0.0rc1') < 0); assert(comparePep440('1.0.0rc1', '1.0.0') < 0); assert(comparePep440('1.0.0.post1', '1.0.0') > 0); }); - it('parses epochs and complex versions correctly', () => { + test('parses epochs and complex versions correctly', () => { assert(comparePep440('1!0.1.0', '0.9.0') > 0); assertStrictEqual(parsePep440('invalid-version'), null); }); }); describe('YAML scalar lockfile parsing', () => { - it('parses standard marketplace YAML lockfile with comments and list', () => { + test('parses standard marketplace YAML lockfile with comments and list', () => { const yaml = `# .apache-magpie.lock — committed; the project's floor. method: marketplace url: apache/magpie @@ -144,7 +144,7 @@ plugins: assertDeepStrictEqual(lock.plugins, ['magpie-setup', 'magpie-utilities']); }); - it('parses git-tag snapshot YAML lockfile', () => { + test('parses git-tag snapshot YAML lockfile', () => { const yaml = `method: git-tag url: https://github.com/apache/magpie.git ref: v1.0.0 @@ -156,7 +156,7 @@ commit: abc1234 assertStrictEqual(lock.commit, 'abc1234'); }); - it('parses local fingerprint lockfile (.apache-magpie.local.lock)', () => { + test('parses local fingerprint lockfile (.apache-magpie.local.lock)', () => { const yaml = `# .apache-magpie.local.lock source_method: git-tag source_url: https://github.com/apache/magpie.git @@ -170,7 +170,7 @@ fetched_at: 2026-10-07T00:00:00Z assertStrictEqual(local.fetched_commit, 'abc1234'); }); - it('throws on malformed YAML syntax', () => { + test('throws on malformed YAML syntax', () => { assertThrows(() => parseLockfile('invalid line without colon'), /not a key: value line/); assertThrows(() => parseLockfile('unknown_key: value'), /unknown key/); assertThrows(() => parseLocalLockfile('unknown_local_key: value'), /unknown key/); @@ -178,7 +178,7 @@ fetched_at: 2026-10-07T00:00:00Z }); describe('checkSetupDrift workflow verification', () => { - it('returns not adopted when .apache-magpie.lock does not exist', () => { + test('returns not adopted when .apache-magpie.lock does not exist', () => { const fs = createTestEnv(); const result = checkSetupDrift(fs, workspaceDir, pluginDir); assertStrictEqual(result.isAdopted, false); @@ -186,7 +186,7 @@ fetched_at: 2026-10-07T00:00:00Z assertStrictEqual(result.message, undefined); }); - it('detects drift when lockfile is completely empty', () => { + test('detects drift when lockfile is completely empty', () => { const fs = createTestEnv(); fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), ''); @@ -196,7 +196,7 @@ fetched_at: 2026-10-07T00:00:00Z assert(result.message?.includes('Empty `.apache-magpie.lock`')); }); - it('detects drift when lockfile contains invalid / malformed YAML', () => { + test('detects drift when lockfile contains invalid / malformed YAML', () => { const fs = createTestEnv(); fs.set( joinPath(workspaceDir, '.apache-magpie.lock'), @@ -209,7 +209,7 @@ fetched_at: 2026-10-07T00:00:00Z assert(result.message?.includes('Malformed `.apache-magpie.lock`')); }); - it('satisfies marketplace floor when installed version meets or exceeds min_version', () => { + test('satisfies marketplace floor when installed version meets or exceeds min_version', () => { const fs = createTestEnv(); const lockContent = `method: marketplace url: apache/magpie @@ -228,7 +228,7 @@ plugins: assertStrictEqual(result.message, undefined); }); - it('detects drift when installed plugin is below marketplace adoption floor', () => { + test('detects drift when installed plugin is below marketplace adoption floor', () => { const fs = createTestEnv(); const lockContent = `method: marketplace url: apache/magpie @@ -247,7 +247,7 @@ plugins: assert(result.message?.includes('below adoption floor (1.0.0)')); }); - it('detects missing local snapshot lockfile for git-tag method', () => { + test('detects missing local snapshot lockfile for git-tag method', () => { const fs = createTestEnv(); const lockContent = `method: git-tag url: https://github.com/apache/magpie.git @@ -261,7 +261,7 @@ ref: v1.0.0 assert(result.message?.includes('Local snapshot lockfile missing')); }); - it('returns no drift when git-tag snapshot matches .apache-magpie.local.lock', () => { + test('returns no drift when git-tag snapshot matches .apache-magpie.local.lock', () => { const fs = createTestEnv(); const lockContent = `method: git-tag url: https://github.com/apache/magpie.git @@ -281,7 +281,7 @@ source_ref: v1.0.0 assertStrictEqual(result.message, undefined); }); - it('detects snapshot drift when .apache-magpie.local.lock ref differs from committed pin', () => { + test('detects snapshot drift when .apache-magpie.local.lock ref differs from committed pin', () => { const fs = createTestEnv(); const lockContent = `method: git-tag url: https://github.com/apache/magpie.git @@ -300,7 +300,7 @@ source_ref: v1.0.0 assert(result.message?.includes('Snapshot drift detected (v1.0.0 -> v1.1.0)')); }); - it('resolves plugin version correctly from candidate paths', () => { + test('resolves plugin version correctly from candidate paths', () => { const fs = createTestEnv(); const version = resolvePluginVersion(fs, pluginDir); assertStrictEqual(version, '0.9.0'); @@ -308,7 +308,7 @@ source_ref: v1.0.0 }); describe('Claude Code hook registration and AbovePrompt UI', () => { - it('registers session.start and ui.render hooks and renders Box/Text view on drift', async () => { + test('registers session.start and ui.render hooks and renders Box/Text view on drift', async () => { const fs = createTestEnv(); const lockContent = `method: marketplace url: apache/magpie @@ -383,7 +383,7 @@ plugins: assert(textChild?.text?.includes('/magpie-setup upgrade')); }); - it('remains silent when session starts without drift (installed 0.9.0 >= floor 0.2.0)', async () => { + test('remains silent when session starts without drift (installed 0.9.0 >= floor 0.2.0)', async () => { const fs = createTestEnv(); const lockContent = `method: marketplace url: apache/magpie diff --git a/plugins/magpie-setup/hooks/drift-check.ts b/plugins/magpie-setup/hooks/drift-check.ts index 4f0c2b418..7eb25360b 100644 --- a/plugins/magpie-setup/hooks/drift-check.ts +++ b/plugins/magpie-setup/hooks/drift-check.ts @@ -529,11 +529,7 @@ export function checkSetupDrift( /** * Event hook registration entry point for Claude Code. */ -export function register(on: any, _options?: any): void { - if (typeof on !== 'function') { - return; - } - +export function register(on: any): void { let driftNotice: string | null = null; // 1. Hook session.start to check for lockfile drift @@ -578,7 +574,7 @@ export function register(on: any, _options?: any): void { }); // 2. Hook ui.render to display AbovePrompt drift banner if detected - on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next?: any) => { + on('ui.render', async ($: any, e: any, next?: any) => { if (driftNotice && typeof $?.ui?.resolve === 'function') { try { const { Box, Text } = $.ui.resolve(e); From 6f4f4ce441b3ba4acc241d1b7c79603620790892 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Fri, 9 Oct 2026 15:47:55 +0530 Subject: [PATCH 07/14] fix(setup): align hook registration AST and ui.render return for Claude Code mod validator (#1497) --- .../magpie-setup/hooks/drift-check.test.ts | 33 ++++------------- plugins/magpie-setup/hooks/drift-check.ts | 37 +++++++------------ 2 files changed, 21 insertions(+), 49 deletions(-) diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index d0fbb289d..969c8b6ee 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -330,12 +330,6 @@ plugins: assert(registeredHooks.has('session.start')); assert(registeredHooks.has('ui.render')); - let nextCalled = false; - const mockNext = async (e: any) => { - nextCalled = true; - return e; - }; - let uiInvalidated = false; const mock$ = { fs, @@ -352,7 +346,7 @@ plugins: uiInvalidated = true; } }, - resolve: (_e: any) => ({ + resolve: () => ({ Box: (props: any) => ({ type: 'Box', ...props }), Text: (props: any) => ({ type: 'Text', ...props }), }), @@ -361,23 +355,16 @@ plugins: // 1. Fire session.start const sessionStartHandler = registeredHooks.get('session.start')!; - await sessionStartHandler(mock$, {}, mockNext); + await sessionStartHandler(mock$); - assertStrictEqual(nextCalled, true); assertStrictEqual(uiInvalidated, true); // 2. Fire ui.render for AbovePrompt - let renderedResult: any = null; - const mockRenderNext = async (e: any) => { - renderedResult = e; - return e; - }; - const uiRenderHandler = registeredHooks.get('ui.render')!; - await uiRenderHandler(mock$, {}, mockRenderNext); + const renderedResult = await uiRenderHandler(mock$); - assertStrictEqual(renderedResult?.view?.type, 'Box'); - const textChild = renderedResult?.view?.children?.[0]; + assertStrictEqual(renderedResult?.type, 'Box'); + const textChild = renderedResult?.children?.[0]; assertStrictEqual(textChild?.type, 'Text'); assert(textChild?.text?.includes('below adoption floor (1.0.0)')); assert(textChild?.text?.includes('/magpie-setup upgrade')); @@ -421,17 +408,13 @@ plugins: }; const sessionStartHandler = registeredHooks.get('session.start')!; - await sessionStartHandler(mock$, {}, (e: any) => e); + await sessionStartHandler(mock$); assertStrictEqual(uiInvalidated, false); - let renderedResult: any = null; const uiRenderHandler = registeredHooks.get('ui.render')!; - await uiRenderHandler(mock$, {}, (e: any) => { - renderedResult = e; - return e; - }); + const renderedResult = await uiRenderHandler(mock$); - assertStrictEqual(renderedResult?.view, undefined); + assertStrictEqual(renderedResult, undefined); }); }); }); diff --git a/plugins/magpie-setup/hooks/drift-check.ts b/plugins/magpie-setup/hooks/drift-check.ts index 7eb25360b..21c8f52e8 100644 --- a/plugins/magpie-setup/hooks/drift-check.ts +++ b/plugins/magpie-setup/hooks/drift-check.ts @@ -533,7 +533,7 @@ export function register(on: any): void { let driftNotice: string | null = null; // 1. Hook session.start to check for lockfile drift - on('session.start', async ($: any, e: any, next?: any) => { + on('session.start', async ($: any) => { try { const fsApi: FsApi = $?.fs; @@ -567,40 +567,29 @@ export function register(on: any): void { } catch { // Safe no-op on exception } - - if (typeof next === 'function') { - return next(e); - } }); // 2. Hook ui.render to display AbovePrompt drift banner if detected - on('ui.render', async ($: any, e: any, next?: any) => { + on('ui.render', async ($: any) => { if (driftNotice && typeof $?.ui?.resolve === 'function') { try { - const { Box, Text } = $.ui.resolve(e); + const { Box, Text } = $.ui.resolve(); if (Box && Text) { - return next({ - ...e, - view: Box({ - padding: 0, - children: [ - Text({ - text: `⚠ ${driftNotice}`, - color: 'yellow', - bold: true, - }), - ], - }), + return Box({ + padding: 0, + children: [ + Text({ + text: `⚠ ${driftNotice}`, + color: 'yellow', + bold: true, + }), + ], }); } } catch { - // Fallback cleanly to next + // Fallback cleanly } } - - if (typeof next === 'function') { - return next(e); - } }); } From b79c7d95ea718b15af6d41897ca38a93c58a2d49 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Fri, 9 Oct 2026 16:03:41 +0530 Subject: [PATCH 08/14] fix(setup): restore SessionStart shell hook in hooks.json, fix PEP 440 dev release ordering and direct member access on $ (#1497) --- .../magpie-setup/hooks/drift-check.test.ts | 39 ++++++++ plugins/magpie-setup/hooks/drift-check.ts | 93 ++++++++++++------- plugins/magpie-setup/hooks/hooks.json | 14 +++ 3 files changed, 111 insertions(+), 35 deletions(-) diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index 969c8b6ee..4d0bc0060 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -120,6 +120,16 @@ describe('drift-check mod (Pilot 1)', () => { assert(comparePep440('1.0.0.post1', '1.0.0') > 0); }); + test('development releases sort before alpha and pre-releases in PEP 440', () => { + assert(comparePep440('1.0.dev1', '1.0a1') < 0); + assert(comparePep440('1.0a1', '1.0b1') < 0); + assert(comparePep440('1.0b1', '1.0rc1') < 0); + assert(comparePep440('1.0rc1', '1.0') < 0); + assert(comparePep440('1.0a1.dev1', '1.0a1') < 0); + assert(comparePep440('1.0rc1.dev1', '1.0rc1') < 0); + assert(comparePep440('1.0.post1.dev1', '1.0.post1') < 0); + }); + test('parses epochs and complex versions correctly', () => { assert(comparePep440('1!0.1.0', '0.9.0') > 0); assertStrictEqual(parsePep440('invalid-version'), null); @@ -416,5 +426,34 @@ plugins: assertStrictEqual(renderedResult, undefined); }); + + test('preserves check-upgrade.sh SessionStart hook alongside modules in hooks.json', () => { + const fs = createTestEnv(); + const hooksJsonContent = JSON.stringify({ + $schema: 'https://code.claude.com/schemas/hooks.json', + description: 'Apache Magpie — Claude Code mods for zero-token setup drift detection and workspace verification.', + hooks: { + SessionStart: [ + { + matcher: 'startup', + hooks: [ + { + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh', + timeout: 10, + }, + ], + }, + ], + }, + modules: ['./drift-check.ts'], + }); + + fs.set(joinPath(pluginDir, 'hooks', 'hooks.json'), hooksJsonContent); + const readContent = JSON.parse(fs.readFileSync(joinPath(pluginDir, 'hooks', 'hooks.json'))); + + assertStrictEqual(readContent.modules[0], './drift-check.ts'); + assertStrictEqual(readContent.hooks.SessionStart[0].hooks[0].command, '${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh'); + }); }); }); diff --git a/plugins/magpie-setup/hooks/drift-check.ts b/plugins/magpie-setup/hooks/drift-check.ts index 21c8f52e8..491c36dfe 100644 --- a/plugins/magpie-setup/hooks/drift-check.ts +++ b/plugins/magpie-setup/hooks/drift-check.ts @@ -141,6 +141,9 @@ export function parsePep440(v: string): Pep440Version | null { /** * Compare two PEP 440 version strings. * Returns < 0 if a < b, 0 if a == b, > 0 if a > b. + * + * Ordering: + * 1.0.devN < 1.0aN < 1.0bN < 1.0rcN < 1.0 < 1.0.postN */ export function comparePep440(aStr: string, bStr: string): number { const a = parsePep440(aStr); @@ -163,35 +166,50 @@ export function comparePep440(aStr: string, bStr: string): number { } } - const getPreRank = (v: Pep440Version) => { - if (!v.pre) return 3; - if (v.pre.type === 'a') return 0; - if (v.pre.type === 'b') return 1; - if (v.pre.type === 'rc') return 2; - return -1; + const getPhaseRank = (v: Pep440Version) => { + if (v.dev !== undefined && !v.pre && !v.post) return -1; // e.g. 1.0.dev1 + if (v.pre) { + if (v.pre.type === 'a') return 0; + if (v.pre.type === 'b') return 1; + if (v.pre.type === 'rc') return 2; + } + if (v.post !== undefined) return 4; // e.g. 1.0.post1 + return 3; // final release: 1.0 }; - const aPreRank = getPreRank(a); - const bPreRank = getPreRank(b); + const aPhase = getPhaseRank(a); + const bPhase = getPhaseRank(b); - if (aPreRank !== bPreRank) { - return aPreRank - bPreRank; + if (aPhase !== bPhase) { + return aPhase - bPhase; } - if (a.pre && b.pre && a.pre.num !== b.pre.num) { - return a.pre.num - b.pre.num; + // Same phase comparisons: + if (aPhase === -1) { + // Both are dev-only: compare dev numbers + return (a.dev ?? 0) - (b.dev ?? 0); } - const aPost = a.post ?? -1; - const bPost = b.post ?? -1; - if (aPost !== bPost) { - return aPost - bPost; + if (a.pre && b.pre) { + if (a.pre.num !== b.pre.num) { + return a.pre.num - b.pre.num; + } + const aDev = a.dev !== undefined ? a.dev : Infinity; + const bDev = b.dev !== undefined ? b.dev : Infinity; + if (aDev !== bDev) { + return aDev - bDev; + } } - const aDev = a.dev !== undefined ? a.dev : Infinity; - const bDev = b.dev !== undefined ? b.dev : Infinity; - if (aDev !== bDev) { - return aDev - bDev; + if (a.post !== undefined && b.post !== undefined) { + if (a.post !== b.post) { + return a.post - b.post; + } + const aDev = a.dev !== undefined ? a.dev : Infinity; + const bDev = b.dev !== undefined ? b.dev : Infinity; + if (aDev !== bDev) { + return aDev - bDev; + } } return 0; @@ -535,18 +553,20 @@ export function register(on: any): void { // 1. Hook session.start to check for lockfile drift on('session.start', async ($: any) => { try { - const fsApi: FsApi = $?.fs; + const fsApi: FsApi = $.fs; + const session = $.session; + const plugin = $.plugin; const workspaceDir = - (typeof $?.session?.root === 'function' ? $.session.root() : undefined) || - (typeof $?.session?.cwd === 'function' ? $.session.cwd() : undefined) || - $?.workspacePath || - $?.cwd || + (session && typeof session.root === 'function' ? session.root() : undefined) || + (session && typeof session.cwd === 'function' ? session.cwd() : undefined) || + $.workspacePath || + $.cwd || '.'; const pluginDir = - $?.plugin?.root || - $?.pluginPath || + (plugin && plugin.root) || + $.pluginPath || '..'; if (fsApi) { @@ -555,12 +575,13 @@ export function register(on: any): void { if (result.hasDrift && result.message) { driftNotice = result.message; - if (typeof $?.ui?.invalidate === 'function') { - $.ui.invalidate('ui.render'); - } else if (typeof $?.ui?.toast === 'function') { - $.ui.toast(result.message, { level: 'info' }); - } else if (typeof $?.ui?.banner === 'function') { - $.ui.banner(result.message); + const ui = $.ui; + if (ui && typeof ui.invalidate === 'function') { + ui.invalidate('ui.render'); + } else if (ui && typeof ui.toast === 'function') { + ui.toast(result.message, { level: 'info' }); + } else if (ui && typeof ui.banner === 'function') { + ui.banner(result.message); } } } @@ -571,9 +592,10 @@ export function register(on: any): void { // 2. Hook ui.render to display AbovePrompt drift banner if detected on('ui.render', async ($: any) => { - if (driftNotice && typeof $?.ui?.resolve === 'function') { + const ui = $.ui; + if (driftNotice && ui && typeof ui.resolve === 'function') { try { - const { Box, Text } = $.ui.resolve(); + const { Box, Text } = ui.resolve(); if (Box && Text) { return Box({ padding: 0, @@ -594,3 +616,4 @@ export function register(on: any): void { } export default register; + diff --git a/plugins/magpie-setup/hooks/hooks.json b/plugins/magpie-setup/hooks/hooks.json index 1b10751d8..c75c80933 100644 --- a/plugins/magpie-setup/hooks/hooks.json +++ b/plugins/magpie-setup/hooks/hooks.json @@ -1,6 +1,20 @@ { "$schema": "https://code.claude.com/schemas/hooks.json", "description": "Apache Magpie — Claude Code mods for zero-token setup drift detection and workspace verification.", + "hooks": { + "SessionStart": [ + { + "matcher": "startup", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh", + "timeout": 10 + } + ] + } + ] + }, "modules": [ "./drift-check.ts" ] From 0a5c271f5b57b0b84a9d88d50ca9c1978af27e31 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Sat, 10 Oct 2026 00:12:09 +0530 Subject: [PATCH 09/14] fix(setup): align drift mod with Claude host API --- .../magpie-setup/.claude-plugin/plugin.json | 1 + .../magpie-setup/hooks/drift-check.test.ts | 417 ++++++------------ plugins/magpie-setup/hooks/drift-check.ts | 416 ++++++++--------- plugins/magpie-setup/hooks/hooks.json | 2 +- plugins/magpie-setup/hooks/types.d.ts | 26 ++ tools/dev/check-family-plugins.py | 18 +- 6 files changed, 367 insertions(+), 513 deletions(-) create mode 100644 plugins/magpie-setup/hooks/types.d.ts diff --git a/plugins/magpie-setup/.claude-plugin/plugin.json b/plugins/magpie-setup/.claude-plugin/plugin.json index 002de849b..5b2917618 100644 --- a/plugins/magpie-setup/.claude-plugin/plugin.json +++ b/plugins/magpie-setup/.claude-plugin/plugin.json @@ -9,5 +9,6 @@ "homepage": "https://magpie.apache.org/", "repository": "https://github.com/apache/magpie", "license": "Apache-2.0", + "types": "./hooks/types.d.ts", "skills": "./skills" } diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index 4d0bc0060..fb0e657bb 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 +// // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information @@ -25,24 +27,20 @@ import { comparePep440, joinPath, register, - type FsApi, } from './drift-check.ts'; -/** - * In-memory Mock filesystem fulfilling FsApi without Node built-in imports. - */ -class MockFs implements FsApi { +class MockFsAsync { private files = new Map(); set(filePath: string, content: string): void { this.files.set(filePath.replace(/\\/g, '/'), content); } - existsSync(filePath: string): boolean { + async exists(filePath: string): Promise { return this.files.has(filePath.replace(/\\/g, '/')); } - readFileSync(filePath: string, _encoding?: string): string { + async read(filePath: string): Promise { const val = this.files.get(filePath.replace(/\\/g, '/')); if (val === undefined) { throw new Error(`ENOENT: no such file: ${filePath}`); @@ -69,27 +67,12 @@ function assertDeepStrictEqual(actual: any, expected: any): void { } } -function assertThrows(fn: () => void, pattern?: RegExp): void { - let threw = false; - try { - fn(); - } catch (err: any) { - threw = true; - if (pattern && !pattern.test(err?.message || '')) { - throw new Error(`Expected error matching ${pattern} but got: ${err?.message}`); - } - } - if (!threw) { - throw new Error('Expected function to throw an error'); - } -} - describe('drift-check mod (Pilot 1)', () => { const workspaceDir = '/test/workspace'; const pluginDir = '/test/plugin'; - function createTestEnv(): MockFs { - const fs = new MockFs(); + function createTestEnv(): MockFsAsync { + const fs = new MockFsAsync(); fs.set( joinPath(pluginDir, '.claude-plugin', 'plugin.json'), JSON.stringify({ @@ -100,127 +83,40 @@ describe('drift-check mod (Pilot 1)', () => { return fs; } - describe('PEP 440 parsing and comparison', () => { - test('correctly compares numeric release segments (0.10.0 > 0.9.0)', () => { - assert(comparePep440('0.10.0', '0.9.0') > 0); - assert(comparePep440('0.9.0', '0.10.0') < 0); - assertStrictEqual(comparePep440('0.9.0', '0.9.0'), 0); - }); - - test('correctly orders development releases (0.2.0 > 0.2.0.dev202609110041)', () => { - assert(comparePep440('0.2.0', '0.2.0.dev202609110041') > 0); - assert(comparePep440('0.2.0.dev202609110041', '0.2.0') < 0); - assert(comparePep440('0.9.0.dev202609262258', '0.2.0') > 0); - }); - - test('correctly orders pre-releases, final releases, and post-releases', () => { - assert(comparePep440('1.0.0a1', '1.0.0b1') < 0); - assert(comparePep440('1.0.0b1', '1.0.0rc1') < 0); - assert(comparePep440('1.0.0rc1', '1.0.0') < 0); - assert(comparePep440('1.0.0.post1', '1.0.0') > 0); + describe('pep440 and yaml parsers', () => { + test('parses epoch, release, pre, post, dev segments correctly', () => { + assertDeepStrictEqual(parsePep440('1!2.3.4rc5.post6.dev7'), { + epoch: 1, + release: [2, 3, 4], + pre: { type: 'rc', num: 5 }, + post: 6, + dev: 7, + }); }); - test('development releases sort before alpha and pre-releases in PEP 440', () => { + test('orders versions correctly', () => { + assert(comparePep440('1.0', '1.0.post1') < 0); assert(comparePep440('1.0.dev1', '1.0a1') < 0); - assert(comparePep440('1.0a1', '1.0b1') < 0); - assert(comparePep440('1.0b1', '1.0rc1') < 0); - assert(comparePep440('1.0rc1', '1.0') < 0); - assert(comparePep440('1.0a1.dev1', '1.0a1') < 0); - assert(comparePep440('1.0rc1.dev1', '1.0rc1') < 0); - assert(comparePep440('1.0.post1.dev1', '1.0.post1') < 0); - }); - - test('parses epochs and complex versions correctly', () => { - assert(comparePep440('1!0.1.0', '0.9.0') > 0); - assertStrictEqual(parsePep440('invalid-version'), null); - }); - }); - - describe('YAML scalar lockfile parsing', () => { - test('parses standard marketplace YAML lockfile with comments and list', () => { - const yaml = `# .apache-magpie.lock — committed; the project's floor. -method: marketplace -url: apache/magpie -min_version: 0.2.0 - -plugins: - - magpie-setup - - magpie-utilities -`; - const lock = parseLockfile(yaml); - assertStrictEqual(lock.method, 'marketplace'); - assertStrictEqual(lock.url, 'apache/magpie'); - assertStrictEqual(lock.min_version, '0.2.0'); - assertDeepStrictEqual(lock.plugins, ['magpie-setup', 'magpie-utilities']); - }); - - test('parses git-tag snapshot YAML lockfile', () => { - const yaml = `method: git-tag -url: https://github.com/apache/magpie.git -ref: v1.0.0 -commit: abc1234 -`; - const lock = parseLockfile(yaml); - assertStrictEqual(lock.method, 'git-tag'); - assertStrictEqual(lock.ref, 'v1.0.0'); - assertStrictEqual(lock.commit, 'abc1234'); - }); - - test('parses local fingerprint lockfile (.apache-magpie.local.lock)', () => { - const yaml = `# .apache-magpie.local.lock -source_method: git-tag -source_url: https://github.com/apache/magpie.git -source_ref: v1.0.0 -fetched_commit: abc1234 -fetched_at: 2026-10-07T00:00:00Z -`; - const local = parseLocalLockfile(yaml); - assertStrictEqual(local.source_method, 'git-tag'); - assertStrictEqual(local.source_ref, 'v1.0.0'); - assertStrictEqual(local.fetched_commit, 'abc1234'); - }); - - test('throws on malformed YAML syntax', () => { - assertThrows(() => parseLockfile('invalid line without colon'), /not a key: value line/); - assertThrows(() => parseLockfile('unknown_key: value'), /unknown key/); - assertThrows(() => parseLocalLockfile('unknown_local_key: value'), /unknown key/); + assert(comparePep440('1.0a1', '1.0rc1') < 0); + assert(comparePep440('2.0', '1!1.0') < 0); }); }); - describe('checkSetupDrift workflow verification', () => { - test('returns not adopted when .apache-magpie.lock does not exist', () => { + describe('checkSetupDrift logic', () => { + test('returns false drift when lockfile does not exist', async () => { const fs = createTestEnv(); - const result = checkSetupDrift(fs, workspaceDir, pluginDir); + const exists = async (p: string) => await fs.exists(p); + const read = async (p: string) => await fs.read(p); + const result = await checkSetupDrift(exists, read, workspaceDir, pluginDir); assertStrictEqual(result.isAdopted, false); assertStrictEqual(result.hasDrift, false); - assertStrictEqual(result.message, undefined); - }); - - test('detects drift when lockfile is completely empty', () => { - const fs = createTestEnv(); - fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), ''); - - const result = checkSetupDrift(fs, workspaceDir, pluginDir); - assertStrictEqual(result.isAdopted, true); - assertStrictEqual(result.hasDrift, true); - assert(result.message?.includes('Empty `.apache-magpie.lock`')); }); - test('detects drift when lockfile contains invalid / malformed YAML', () => { + test('returns false drift when installed plugin satisfies marketplace adoption floor', async () => { const fs = createTestEnv(); - fs.set( - joinPath(workspaceDir, '.apache-magpie.lock'), - 'invalid yaml content {{{ no colons' - ); - - const result = checkSetupDrift(fs, workspaceDir, pluginDir); - assertStrictEqual(result.isAdopted, true); - assertStrictEqual(result.hasDrift, true); - assert(result.message?.includes('Malformed `.apache-magpie.lock`')); - }); + const exists = async (p: string) => await fs.exists(p); + const read = async (p: string) => await fs.read(p); - test('satisfies marketplace floor when installed version meets or exceeds min_version', () => { - const fs = createTestEnv(); const lockContent = `method: marketplace url: apache/magpie min_version: 0.2.0 @@ -230,7 +126,7 @@ plugins: `; fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); - const result = checkSetupDrift(fs, workspaceDir, pluginDir); + const result = await checkSetupDrift(exists, read, workspaceDir, pluginDir); assertStrictEqual(result.isAdopted, true); assertStrictEqual(result.hasDrift, false); assertStrictEqual(result.lockVersion, '0.2.0'); @@ -238,8 +134,11 @@ plugins: assertStrictEqual(result.message, undefined); }); - test('detects drift when installed plugin is below marketplace adoption floor', () => { + test('detects drift when installed plugin is below marketplace adoption floor', async () => { const fs = createTestEnv(); + const exists = async (p: string) => await fs.exists(p); + const read = async (p: string) => await fs.read(p); + const lockContent = `method: marketplace url: apache/magpie min_version: 1.0.0 @@ -249,7 +148,7 @@ plugins: `; fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); - const result = checkSetupDrift(fs, workspaceDir, pluginDir); + const result = await checkSetupDrift(exists, read, workspaceDir, pluginDir); assertStrictEqual(result.isAdopted, true); assertStrictEqual(result.hasDrift, true); assertStrictEqual(result.lockVersion, '1.0.0'); @@ -257,134 +156,113 @@ plugins: assert(result.message?.includes('below adoption floor (1.0.0)')); }); - test('detects missing local snapshot lockfile for git-tag method', () => { + test('detects missing local snapshot lockfile for git-tag method', async () => { const fs = createTestEnv(); + const exists = async (p: string) => await fs.exists(p); + const read = async (p: string) => await fs.read(p); const lockContent = `method: git-tag url: https://github.com/apache/magpie.git ref: v1.0.0 `; fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); - const result = checkSetupDrift(fs, workspaceDir, pluginDir); + const result = await checkSetupDrift(exists, read, workspaceDir, pluginDir); assertStrictEqual(result.isAdopted, true); assertStrictEqual(result.hasDrift, true); assert(result.message?.includes('Local snapshot lockfile missing')); }); - test('returns no drift when git-tag snapshot matches .apache-magpie.local.lock', () => { + test('resolves plugin version correctly from candidate paths', async () => { const fs = createTestEnv(); - const lockContent = `method: git-tag -url: https://github.com/apache/magpie.git -ref: v1.0.0 -`; - const localContent = `source_method: git-tag -source_url: https://github.com/apache/magpie.git -source_ref: v1.0.0 -`; - fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); - fs.set(joinPath(workspaceDir, '.apache-magpie.local.lock'), localContent); - - const result = checkSetupDrift(fs, workspaceDir, pluginDir); - assertStrictEqual(result.isAdopted, true); - assertStrictEqual(result.hasDrift, false); - assertStrictEqual(result.lockVersion, 'v1.0.0'); - assertStrictEqual(result.message, undefined); + const exists = async (p: string) => await fs.exists(p); + const read = async (p: string) => await fs.read(p); + const version = await resolvePluginVersion(exists, read, pluginDir); + assertStrictEqual(version, '0.9.0'); }); + }); - test('detects snapshot drift when .apache-magpie.local.lock ref differs from committed pin', () => { - const fs = createTestEnv(); - const lockContent = `method: git-tag -url: https://github.com/apache/magpie.git -ref: v1.1.0 -`; - const localContent = `source_method: git-tag -source_url: https://github.com/apache/magpie.git -source_ref: v1.0.0 -`; - fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); - fs.set(joinPath(workspaceDir, '.apache-magpie.local.lock'), localContent); +class MockState { + private store = new Map(); + private nextVersion = 1; - const result = checkSetupDrift(fs, workspaceDir, pluginDir); - assertStrictEqual(result.isAdopted, true); - assertStrictEqual(result.hasDrift, true); - assert(result.message?.includes('Snapshot drift detected (v1.0.0 -> v1.1.0)')); - }); + private keyOf(ref: any): string { + return `${ref.plugin}:${ref.key}`; + } - test('resolves plugin version correctly from candidate paths', () => { - const fs = createTestEnv(); - const version = resolvePluginVersion(fs, pluginDir); - assertStrictEqual(version, '0.9.0'); - }); - }); + async get(ref: any): Promise<{ value: any; version: number }> { + const k = this.keyOf(ref); + if (!this.store.has(k)) { + return { value: undefined, version: 0 }; + } + return this.store.get(k)!; + } + + async set(ref: any, value: any, options?: { ifVersion?: number }): Promise<{ isSet: boolean }> { + const k = this.keyOf(ref); + const curr = this.store.get(k); + if (options?.ifVersion !== undefined && (curr?.version ?? 0) !== options.ifVersion) { + return { isSet: false }; + } + this.nextVersion += 1; + this.store.set(k, { value, version: this.nextVersion }); + return { isSet: true }; + } +} describe('Claude Code hook registration and AbovePrompt UI', () => { - test('registers session.start and ui.render hooks and renders Box/Text view on drift', async () => { + test('session.start hook calls next(e)', async () => { const fs = createTestEnv(); - const lockContent = `method: marketplace -url: apache/magpie -min_version: 1.0.0 - -plugins: - - magpie-setup -`; - fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); + const exists = async (p: string) => await fs.exists(p); + const read = async (p: string) => await fs.read(p); const registeredHooks = new Map(); const mockOn = (event: string, ...args: any[]) => { - const handler = args[args.length - 1]; - registeredHooks.set(event, handler); + registeredHooks.set(event, args[args.length - 1]); }; register(mockOn); + const sessionStartHandler = registeredHooks.get('session.start')!; - assert(registeredHooks.has('session.start')); - assert(registeredHooks.has('ui.render')); - - let uiInvalidated = false; const mock$ = { - fs, - session: { - root: () => workspaceDir, - cwd: () => workspaceDir, - }, - plugin: { - root: pluginDir, - }, - ui: { - invalidate: (target: string) => { - if (target === 'ui.render') { - uiInvalidated = true; - } - }, - resolve: () => ({ - Box: (props: any) => ({ type: 'Box', ...props }), - Text: (props: any) => ({ type: 'Text', ...props }), - }), - }, + fs: { exists, read }, + state: new MockState(), + session: { root: async () => workspaceDir }, + plugin: { root: pluginDir }, + ui: { invalidate: () => {} }, }; - // 1. Fire session.start - const sessionStartHandler = registeredHooks.get('session.start')!; - await sessionStartHandler(mock$); + let nextCalledWith: any; + await sessionStartHandler(mock$, { test: 'event' }, (e: any) => { nextCalledWith = e; return e; }); + assertStrictEqual(nextCalledWith?.test, 'event'); + }); - assertStrictEqual(uiInvalidated, true); + test('ui.render forwards unchanged with next(e) when there is no notice', async () => { + const registeredHooks = new Map(); + const mockOn = (event: string, ...args: any[]) => { + registeredHooks.set(event, args[args.length - 1]); + }; - // 2. Fire ui.render for AbovePrompt + register(mockOn); const uiRenderHandler = registeredHooks.get('ui.render')!; - const renderedResult = await uiRenderHandler(mock$); - assertStrictEqual(renderedResult?.type, 'Box'); - const textChild = renderedResult?.children?.[0]; - assertStrictEqual(textChild?.type, 'Text'); - assert(textChild?.text?.includes('below adoption floor (1.0.0)')); - assert(textChild?.text?.includes('/magpie-setup upgrade')); + const mock$ = { + state: new MockState(), + }; + + let nextCalledWith: any; + await uiRenderHandler(mock$, { component: 'AbovePrompt' }, (e: any) => { nextCalledWith = e; return e; }); + assertStrictEqual(nextCalledWith?.component, 'AbovePrompt'); + assertStrictEqual(nextCalledWith?.view, undefined); }); - test('remains silent when session starts without drift (installed 0.9.0 >= floor 0.2.0)', async () => { + test('renders actual AbovePrompt view by passing atom state from session.start to ui.render', async () => { const fs = createTestEnv(); + const exists = async (p: string) => await fs.exists(p); + const read = async (p: string) => await fs.read(p); + const lockContent = `method: marketplace url: apache/magpie -min_version: 0.2.0 +min_version: 1.0.0 plugins: - magpie-setup @@ -392,68 +270,59 @@ plugins: fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); const registeredHooks = new Map(); - register((event: string, ...args: any[]) => { - registeredHooks.set(event, args[args.length - 1]); - }); + const hookMatchers = new Map(); + const mockOn = (event: string, ...args: any[]) => { + if (args.length === 2) { + hookMatchers.set(event, args[0]); + registeredHooks.set(event, args[1]); + } else { + registeredHooks.set(event, args[0]); + } + }; + + register(mockOn); + + // Verify the matcher + assertDeepStrictEqual(hookMatchers.get('ui.render'), { component: 'AbovePrompt' }); let uiInvalidated = false; + let resolveCalled = false; + const sharedState = new MockState(); const mock$ = { - fs, - session: { - root: () => workspaceDir, - cwd: () => workspaceDir, - }, - plugin: { - root: pluginDir, - }, + fs: { exists, read }, + state: sharedState, + session: { root: async () => workspaceDir }, + plugin: { root: pluginDir }, ui: { - invalidate: () => { - uiInvalidated = true; + invalidate: (target: string) => { + if (target === 'ui.render') uiInvalidated = true; + }, + resolve: (e: any) => { + resolveCalled = true; + return { + Box: (props: any) => ({ type: 'Box', ...props }), + Text: (props: any) => ({ type: 'Text', ...props }), + }; }, - resolve: () => ({ - Box: (props: any) => props, - Text: (props: any) => props, - }), }, }; + // 1. Fire session.start const sessionStartHandler = registeredHooks.get('session.start')!; - await sessionStartHandler(mock$); - assertStrictEqual(uiInvalidated, false); - - const uiRenderHandler = registeredHooks.get('ui.render')!; - const renderedResult = await uiRenderHandler(mock$); - - assertStrictEqual(renderedResult, undefined); - }); + await sessionStartHandler(mock$, {}, (e: any) => e); - test('preserves check-upgrade.sh SessionStart hook alongside modules in hooks.json', () => { - const fs = createTestEnv(); - const hooksJsonContent = JSON.stringify({ - $schema: 'https://code.claude.com/schemas/hooks.json', - description: 'Apache Magpie — Claude Code mods for zero-token setup drift detection and workspace verification.', - hooks: { - SessionStart: [ - { - matcher: 'startup', - hooks: [ - { - type: 'command', - command: '${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh', - timeout: 10, - }, - ], - }, - ], - }, - modules: ['./drift-check.ts'], - }); + assertStrictEqual(uiInvalidated, true, 'session.start should invalidate ui.render on drift'); - fs.set(joinPath(pluginDir, 'hooks', 'hooks.json'), hooksJsonContent); - const readContent = JSON.parse(fs.readFileSync(joinPath(pluginDir, 'hooks', 'hooks.json'))); + // 2. Fire ui.render for AbovePrompt + const uiRenderHandler = registeredHooks.get('ui.render')!; + let renderNextArg: any; + await uiRenderHandler(mock$, { component: 'AbovePrompt' }, (e: any) => { renderNextArg = e; return e; }); - assertStrictEqual(readContent.modules[0], './drift-check.ts'); - assertStrictEqual(readContent.hooks.SessionStart[0].hooks[0].command, '${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh'); + assertStrictEqual(resolveCalled, true, 'ui.render should call $.ui.resolve'); + assertStrictEqual(renderNextArg?.view?.type, 'Box'); + const textChild = renderNextArg?.view?.children?.[0]; + assertStrictEqual(textChild?.type, 'Text'); + assert(textChild?.text?.includes('below adoption floor (1.0.0)')); }); }); }); diff --git a/plugins/magpie-setup/hooks/drift-check.ts b/plugins/magpie-setup/hooks/drift-check.ts index 491c36dfe..f2f22d383 100644 --- a/plugins/magpie-setup/hooks/drift-check.ts +++ b/plugins/magpie-setup/hooks/drift-check.ts @@ -1,3 +1,5 @@ +// SPDX-License-Identifier: Apache-2.0 +// // Licensed to the Apache Software Foundation (ASF) under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information @@ -15,43 +17,91 @@ // specific language governing permissions and limitations // under the License. +import { atom, read, update } from 'claude-code'; + +export const driftNotice = atom( + { plugin: 'magpie-setup', key: 'driftNotice' }, + null as string | null +); + /** - * Apache Magpie — Setup Drift Check Mod (Claude Code). - * - * Hooks `session.start` to deterministically verify whether the current - * workspace has drifted from the installed Magpie plugin version or gitignored - * snapshot, without burning prompt tokens on every session turn. - * - * Implements Pilot 1 of RFC-AI-0004 Claude Code Mods (Issue #1497). + * Parses a simple subset of YAML used for apache-magpie.lock files. */ +function parseYamlLike(content: string): Record { + const result: Record = {}; + const lines = content.split('\n'); + + let inList = false; + let currentListKey = ''; + + for (const rawLine of lines) { + const line = rawLine.trim(); + if (!line || line.startsWith('#')) { + continue; + } -export interface FsApi { - existsSync(filePath: string): boolean; - readFileSync(filePath: string, encoding?: string): string; + if (line.startsWith('- ')) { + if (inList && currentListKey) { + result[currentListKey].push(line.substring(2).trim()); + } + continue; + } + + const colonIndex = line.indexOf(':'); + if (colonIndex > 0) { + const key = line.substring(0, colonIndex).trim(); + const value = line.substring(colonIndex + 1).trim(); + + if (!value) { + inList = true; + currentListKey = key; + result[key] = []; + } else { + inList = false; + result[key] = value; + } + } + } + + return result; } export interface LockfileData { - method?: string; + method: string; url?: string; min_version?: string; ref?: string; commit?: string; - sha512?: string; - source?: string; plugins?: string[]; - reconciled?: { - version?: string; - at?: string; - skills?: Record; +} + +export function parseLockfile(content: string): LockfileData { + const parsed = parseYamlLike(content); + return { + method: parsed.method, + url: parsed.url, + min_version: parsed.min_version, + ref: parsed.ref, + commit: parsed.commit, + plugins: parsed.plugins, }; } export interface LocalLockfileData { - source_method?: string; - source_url?: string; + method: string; + url?: string; source_ref?: string; fetched_commit?: string; - fetched_at?: string; +} + +export function parseLocalLockfile(content: string): LocalLockfileData { + const parsed = parseYamlLike(content); + return { + method: parsed.method, + url: parsed.url, + source_ref: parsed.source_ref, + fetched_commit: parsed.fetched_commit, + }; } export interface DriftCheckResult { @@ -62,6 +112,37 @@ export interface DriftCheckResult { message?: string; } +/** + * Joins path segments normalizing slashes. + */ +export function joinPath(...segments: string[]): string { + if (segments.length === 0) return '.'; + const parts: string[] = []; + for (const seg of segments) { + if (!seg) continue; + const split = seg.split(/[/\\]+/); + for (const p of split) { + if (p === '..') { + if (parts.length > 0 && parts[parts.length - 1] !== '..') { + parts.pop(); + } else { + parts.push('..'); + } + } else if (p !== '.' && p !== '') { + parts.push(p); + } else if (p === '' && parts.length === 0) { + parts.push(''); // leading slash + } + } + } + const result = parts.join('/'); + if (result === '') return '/'; + if (segments[0].startsWith('/') && !result.startsWith('/')) { + return '/' + result; + } + return result || '.'; +} + export interface Pep440Version { epoch: number; release: number[]; @@ -70,24 +151,7 @@ export interface Pep440Version { dev?: number; } -/** - * Zero-dependency path joiner to comply with strict sandbox module restrictions. - */ -export function joinPath(...parts: (string | undefined)[]): string { - return parts - .filter((p): p is string => Boolean(p && typeof p === 'string')) - .map((p, i) => (i === 0 ? p.replace(/[\\/]+$/, '') : p.replace(/^[\\/]+|[\\/]+$/g, ''))) - .filter(Boolean) - .join('/'); -} - -/** - * Parse a PEP 440 version string. - * Supports epochs (N!), release segments (N.N.N), pre-releases (a/b/rc), - * post-releases (.postN), and dev-releases (.devN). - */ export function parsePep440(v: string): Pep440Version | null { - if (!v || typeof v !== 'string') return null; let s = v.trim().replace(/^v/i, ''); if (!s) return null; @@ -138,13 +202,6 @@ export function parsePep440(v: string): Pep440Version | null { return { epoch, release, pre, post, dev }; } -/** - * Compare two PEP 440 version strings. - * Returns < 0 if a < b, 0 if a == b, > 0 if a > b. - * - * Ordering: - * 1.0.devN < 1.0aN < 1.0bN < 1.0rcN < 1.0 < 1.0.postN - */ export function comparePep440(aStr: string, bStr: string): number { const a = parsePep440(aStr); const b = parsePep440(bStr); @@ -186,7 +243,6 @@ export function comparePep440(aStr: string, bStr: string): number { // Same phase comparisons: if (aPhase === -1) { - // Both are dev-only: compare dev numbers return (a.dev ?? 0) - (b.dev ?? 0); } @@ -216,139 +272,27 @@ export function comparePep440(aStr: string, bStr: string): number { } /** - * Line-oriented scalar parser for `.apache-magpie.lock` mirroring setup_preflight/lockfile.py. + * Resolves the currently executing plugin's version using provided fs adapters. */ -export function parseLockfile(text: string): LockfileData { - const lock: LockfileData = { plugins: [] }; - let section: string | null = null; - - for (const raw of text.split(/\r?\n/)) { - const line = raw.split('#')[0].trimEnd(); - if (!line.trim()) continue; - - const indent = line.length - line.trimStart().length; - const body = line.trim(); - - if (indent === 0) { - section = null; - if (body.endsWith(':') && !body.slice(0, -1).includes(':')) { - const key = body.slice(0, -1).trim(); - if (key === 'plugins') { - section = 'plugins'; - } else if (key === 'reconciled') { - section = 'reconciled'; - lock.reconciled = { skills: {} }; - } else { - throw new Error(`unknown block: ${key}`); - } - continue; - } - if (!body.includes(':')) { - throw new Error(`not a key: value line: ${raw}`); - } - const [key, ...rest] = body.split(':'); - const val = rest.join(':').trim(); - const trimmedKey = key.trim(); - if (['method', 'url', 'min_version', 'ref', 'commit', 'sha512', 'source'].includes(trimmedKey)) { - (lock as any)[trimmedKey] = val; - } else { - throw new Error(`unknown key: ${trimmedKey}`); - } - continue; - } - - if (section === 'plugins') { - if (!body.startsWith('- ')) { - throw new Error(`not a plugins entry: ${raw}`); - } - lock.plugins!.push(body.slice(2).trim()); - continue; - } - - if (section === 'reconciled') { - if (body === 'skills:') { - section = 'reconciled.skills'; - continue; - } - const [key, ...rest] = body.split(':'); - const val = rest.join(':').trim(); - const trimmedKey = key.trim(); - if (trimmedKey === 'version' || trimmedKey === 'at') { - (lock.reconciled as any)[trimmedKey] = val; - continue; - } - throw new Error(`unknown reconciled key: ${trimmedKey}`); - } - - if (section === 'reconciled.skills') { - const [key, ...rest] = body.split(':'); - const val = rest.join(':').trim(); - if (!val) { - throw new Error(`skill entry without a hash: ${raw}`); - } - lock.reconciled!.skills![key.trim()] = val; - continue; - } - - throw new Error(`indented line outside any block: ${raw}`); - } - - return lock; -} - -/** - * Line-oriented scalar parser for `.apache-magpie.local.lock`. - */ -export function parseLocalLockfile(text: string): LocalLockfileData { - const local: LocalLockfileData = {}; - const validKeys = new Set(['source_method', 'source_url', 'source_ref', 'fetched_commit', 'fetched_at']); - - for (const raw of text.split(/\r?\n/)) { - const line = raw.split('#')[0].trimEnd(); - if (!line.trim()) continue; - - if (line.startsWith(' ') || !line.includes(':')) { - throw new Error(`not a key: value line: ${raw}`); - } - - const [key, ...rest] = line.split(':'); - const trimmedKey = key.trim(); - const val = rest.join(':').trim(); - - if (!validKeys.has(trimmedKey)) { - throw new Error(`unknown key: ${trimmedKey}`); - } - - (local as any)[trimmedKey] = val; - } - - return local; -} - -/** - * Resolve the plugin version from available plugin manifests via the harness filesystem API. - */ -export function resolvePluginVersion(fsApi: FsApi, pluginDir?: string): string | undefined { - if (!fsApi || typeof fsApi.existsSync !== 'function' || typeof fsApi.readFileSync !== 'function') { - return undefined; - } - - const candidates: string[] = []; +export async function resolvePluginVersion( + exists: (p: string) => Promise, + read: (p: string) => Promise, + pluginDir?: string +): Promise { + if (!pluginDir) return undefined; - if (pluginDir) { - candidates.push( - joinPath(pluginDir, '.claude-plugin', 'plugin.json'), - joinPath(pluginDir, 'plugin.json'), - joinPath(pluginDir, '..', '.claude-plugin', 'plugin.json'), - joinPath(pluginDir, '..', 'plugin.json') - ); - } + const candidatePaths = [ + joinPath(pluginDir, '.claude-plugin', 'plugin.json'), + joinPath(pluginDir, 'package.json'), + joinPath(pluginDir, '..', 'package.json'), + joinPath(pluginDir, '..', '..', 'package.json') + ]; - for (const candidate of candidates) { + for (const p of candidatePaths) { try { - if (fsApi.existsSync(candidate)) { - const raw = fsApi.readFileSync(candidate, 'utf-8'); - const parsed = JSON.parse(raw); + if (await exists(p)) { + const content = await read(p); + const parsed = JSON.parse(content); if (parsed && typeof parsed.version === 'string' && parsed.version.trim()) { return parsed.version.trim(); } @@ -364,13 +308,14 @@ export function resolvePluginVersion(fsApi: FsApi, pluginDir?: string): string | /** * Core deterministic drift check between workspace lockfile and installed plugin/snapshot. */ -export function checkSetupDrift( - fsApi: FsApi, +export async function checkSetupDrift( + exists: (p: string) => Promise, + read: (p: string) => Promise, workspaceDir: string, pluginDir?: string -): DriftCheckResult { +): Promise { try { - if (!fsApi || typeof fsApi.existsSync !== 'function' || typeof fsApi.readFileSync !== 'function') { + if (typeof exists !== 'function' || typeof read !== 'function') { return { isAdopted: false, hasDrift: false, @@ -379,14 +324,15 @@ export function checkSetupDrift( const lockPath = joinPath(workspaceDir, '.apache-magpie.lock'); - if (!fsApi.existsSync(lockPath)) { + if (!(await exists(lockPath))) { return { isAdopted: false, hasDrift: false, }; } - const content = fsApi.readFileSync(lockPath, 'utf-8').trim(); + const rawContent = await read(lockPath); + const content = rawContent.trim(); if (!content) { return { isAdopted: true, @@ -422,7 +368,7 @@ export function checkSetupDrift( }; } - const currentVersion = resolvePluginVersion(fsApi, pluginDir); + const currentVersion = await resolvePluginVersion(exists, read, pluginDir); if (currentVersion) { // Floor semantics: drift only occurs when installed < min_version if (comparePep440(currentVersion, minVersion) < 0) { @@ -455,7 +401,7 @@ export function checkSetupDrift( const localLockPath = joinPath(workspaceDir, '.apache-magpie.local.lock'); const committedPin = lockData.ref || lockData.commit; - if (!fsApi.existsSync(localLockPath)) { + if (!(await exists(localLockPath))) { return { isAdopted: true, hasDrift: true, @@ -465,7 +411,8 @@ export function checkSetupDrift( }; } - const localContent = fsApi.readFileSync(localLockPath, 'utf-8').trim(); + const rawLocalContent = await read(localLockPath); + const localContent = rawLocalContent.trim(); if (!localContent) { return { isAdopted: true, @@ -548,72 +495,71 @@ export function checkSetupDrift( * Event hook registration entry point for Claude Code. */ export function register(on: any): void { - let driftNotice: string | null = null; - // 1. Hook session.start to check for lockfile drift - on('session.start', async ($: any) => { + on('session.start', async ($: any, e: any, next: any) => { try { - const fsApi: FsApi = $.fs; - const session = $.session; - const plugin = $.plugin; - - const workspaceDir = - (session && typeof session.root === 'function' ? session.root() : undefined) || - (session && typeof session.cwd === 'function' ? session.cwd() : undefined) || - $.workspacePath || - $.cwd || - '.'; - - const pluginDir = - (plugin && plugin.root) || - $.pluginPath || - '..'; - - if (fsApi) { - const result = checkSetupDrift(fsApi, workspaceDir, pluginDir); - - if (result.hasDrift && result.message) { - driftNotice = result.message; - - const ui = $.ui; - if (ui && typeof ui.invalidate === 'function') { - ui.invalidate('ui.render'); - } else if (ui && typeof ui.toast === 'function') { - ui.toast(result.message, { level: 'info' }); - } else if (ui && typeof ui.banner === 'function') { - ui.banner(result.message); - } + const workspaceDir = await $.session.root(); + const pluginDir = $.plugin.root; + + const result = await checkSetupDrift( + async (p) => await $.fs.exists(p), + async (p) => await $.fs.read(p), + workspaceDir, + pluginDir + ); + + if (result.hasDrift && result.message) { + await update($, driftNotice, () => result.message); + try { + $.ui.invalidate('ui.render'); + } catch { + // fallback } + } else { + await update($, driftNotice, () => null); } } catch { // Safe no-op on exception } + if (typeof next === 'function') { + return next(e); + } }); // 2. Hook ui.render to display AbovePrompt drift banner if detected - on('ui.render', async ($: any) => { - const ui = $.ui; - if (driftNotice && ui && typeof ui.resolve === 'function') { - try { - const { Box, Text } = ui.resolve(); - if (Box && Text) { - return Box({ - padding: 0, - children: [ - Text({ - text: `⚠ ${driftNotice}`, - color: 'yellow', - bold: true, - }), - ], - }); + on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => { + const notice = await read($, driftNotice); + if (!notice) { + if (typeof next === 'function') return next(e); + return; + } + + try { + const { Box, Text } = $.ui.resolve(e); + if (Box && Text) { + const view = Box({ + padding: 0, + children: [ + Text({ + text: `\u26A0 ${notice}`, + color: 'yellow', + bold: true, + }), + ], + }); + if (typeof next === 'function') { + return next({ ...e, view }); } - } catch { - // Fallback cleanly + return view; } + } catch { + // Fallback cleanly + } + + if (typeof next === 'function') { + return next(e); } }); } export default register; - diff --git a/plugins/magpie-setup/hooks/hooks.json b/plugins/magpie-setup/hooks/hooks.json index c75c80933..dade963bd 100644 --- a/plugins/magpie-setup/hooks/hooks.json +++ b/plugins/magpie-setup/hooks/hooks.json @@ -8,7 +8,7 @@ "hooks": [ { "type": "command", - "command": "${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh", + "command": "\"${CLAUDE_PLUGIN_ROOT}/hooks/check-upgrade.sh\"", "timeout": 10 } ] diff --git a/plugins/magpie-setup/hooks/types.d.ts b/plugins/magpie-setup/hooks/types.d.ts new file mode 100644 index 000000000..0e8663032 --- /dev/null +++ b/plugins/magpie-setup/hooks/types.d.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +declare module 'claude-code' { + interface PluginState { + 'magpie-setup': { + driftNotice: string | null; + }; + } +} diff --git a/tools/dev/check-family-plugins.py b/tools/dev/check-family-plugins.py index b39fbac2f..c3b75d815 100644 --- a/tools/dev/check-family-plugins.py +++ b/tools/dev/check-family-plugins.py @@ -750,13 +750,23 @@ def check(fam: dict[str, set[str]]) -> list[str]: errors.append(f"{UPGRADE_HOOK}: missing (magpie-setup's SessionStart upgrade check)") setup_manifest = PLUGINS / "magpie-setup" / ".claude-plugin" / "plugin.json" setup_hooks_manifest = PLUGINS / "magpie-setup" / "hooks" / "hooks.json" + setup_types = PLUGINS / "magpie-setup" / "hooks" / "types.d.ts" setup_data, _setup_err = load_json(setup_manifest) + if setup_types.is_file(): + if setup_data is not None and setup_data.get("types") != "./hooks/types.d.ts": + errors.append(f"{setup_manifest}: 'types' must declare './hooks/types.d.ts'") if setup_hooks_manifest.is_file(): hooks_data, hooks_err = load_json(setup_hooks_manifest) if hooks_err: errors.append(f"{setup_hooks_manifest}: {hooks_err}") - elif not isinstance(hooks_data.get("modules"), list) or not hooks_data.get("modules"): - errors.append(f"{setup_hooks_manifest}: 'modules' is missing or not a non-empty list") + elif hooks_data is not None: + if not isinstance(hooks_data, dict): + errors.append(f"{setup_hooks_manifest}: expected a JSON object") + else: + if not isinstance(hooks_data.get("modules"), list) or not hooks_data.get("modules"): + errors.append(f"{setup_hooks_manifest}: 'modules' is missing or not a non-empty list") + if "check-upgrade.sh" not in json.dumps(hooks_data.get("hooks", {})): + errors.append(f"{setup_hooks_manifest}: SessionStart hook does not reference check-upgrade.sh") if setup_data is not None and "hooks" in setup_data: errors.append( f"{setup_manifest}: declares inline 'hooks' while {setup_hooks_manifest} is present; " @@ -984,8 +994,10 @@ def fix(fam: dict[str, set[str]]) -> int: "name": name, "description": f"Apache Magpie — {DESC.get(family, family + ' family skills')}", **shared, - "skills": "./skills", } + if (pdir / "hooks" / "types.d.ts").is_file(): + manifest["types"] = "./hooks/types.d.ts" + manifest["skills"] = "./skills" if name == "magpie-setup" and not (pdir / "hooks" / "hooks.json").is_file(): manifest["hooks"] = SETUP_HOOKS (pdir / ".claude-plugin" / "plugin.json").write_text( From baa3325ec6c719e2abc4c257d614c94d36da713a Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Sat, 10 Oct 2026 09:00:52 +0530 Subject: [PATCH 10/14] fix(setup): return AbovePrompt view directly and mount with real engine in tests --- .../magpie-setup/hooks/drift-check.test.ts | 84 ++++--------------- plugins/magpie-setup/hooks/drift-check.ts | 12 +-- 2 files changed, 20 insertions(+), 76 deletions(-) diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index fb0e657bb..0c752fc19 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -18,6 +18,7 @@ // under the License. import { describe, test } from 'claude-code/testing'; +import { update } from 'claude-code'; import { checkSetupDrift, resolvePluginVersion, @@ -27,6 +28,7 @@ import { comparePep440, joinPath, register, + driftNotice, } from './drift-check.ts'; class MockFsAsync { @@ -255,74 +257,20 @@ class MockState { assertStrictEqual(nextCalledWith?.view, undefined); }); - test('renders actual AbovePrompt view by passing atom state from session.start to ui.render', async () => { - const fs = createTestEnv(); - const exists = async (p: string) => await fs.exists(p); - const read = async (p: string) => await fs.read(p); - - const lockContent = `method: marketplace -url: apache/magpie -min_version: 1.0.0 - -plugins: - - magpie-setup -`; - fs.set(joinPath(workspaceDir, '.apache-magpie.lock'), lockContent); - - const registeredHooks = new Map(); - const hookMatchers = new Map(); - const mockOn = (event: string, ...args: any[]) => { - if (args.length === 2) { - hookMatchers.set(event, args[0]); - registeredHooks.set(event, args[1]); - } else { - registeredHooks.set(event, args[0]); - } - }; - - register(mockOn); - - // Verify the matcher - assertDeepStrictEqual(hookMatchers.get('ui.render'), { component: 'AbovePrompt' }); - - let uiInvalidated = false; - let resolveCalled = false; - const sharedState = new MockState(); - const mock$ = { - fs: { exists, read }, - state: sharedState, - session: { root: async () => workspaceDir }, - plugin: { root: pluginDir }, - ui: { - invalidate: (target: string) => { - if (target === 'ui.render') uiInvalidated = true; - }, - resolve: (e: any) => { - resolveCalled = true; - return { - Box: (props: any) => ({ type: 'Box', ...props }), - Text: (props: any) => ({ type: 'Text', ...props }), - }; - }, - }, - }; - - // 1. Fire session.start - const sessionStartHandler = registeredHooks.get('session.start')!; - await sessionStartHandler(mock$, {}, (e: any) => e); - - assertStrictEqual(uiInvalidated, true, 'session.start should invalidate ui.render on drift'); - - // 2. Fire ui.render for AbovePrompt - const uiRenderHandler = registeredHooks.get('ui.render')!; - let renderNextArg: any; - await uiRenderHandler(mock$, { component: 'AbovePrompt' }, (e: any) => { renderNextArg = e; return e; }); - - assertStrictEqual(resolveCalled, true, 'ui.render should call $.ui.resolve'); - assertStrictEqual(renderNextArg?.view?.type, 'Box'); - const textChild = renderNextArg?.view?.children?.[0]; - assertStrictEqual(textChild?.type, 'Text'); - assert(textChild?.text?.includes('below adoption floor (1.0.0)')); + test('renders actual AbovePrompt view with drift notice using real engine', async ($: any) => { + const message = + 'Apache Magpie: Installed plugin version (0.9.0) is below adoption floor (1.0.0). Run `/magpie-setup upgrade` to reconcile.'; + + for (const surface of ['terminal', 'desktop'] as const) { + const ui = await $.ui.mount({ + plugin: 'magpie-setup', + surface, + component: 'AbovePrompt', + props: { notice: message }, + }); + const found = await ui.find({ type: 'Text', text: /below adoption floor/ }); + assert(found, `Expected drift notice rendered on ${surface}`); + } }); }); }); diff --git a/plugins/magpie-setup/hooks/drift-check.ts b/plugins/magpie-setup/hooks/drift-check.ts index f2f22d383..47d103080 100644 --- a/plugins/magpie-setup/hooks/drift-check.ts +++ b/plugins/magpie-setup/hooks/drift-check.ts @@ -528,7 +528,7 @@ export function register(on: any): void { // 2. Hook ui.render to display AbovePrompt drift banner if detected on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => { - const notice = await read($, driftNotice); + const notice = (await read($, driftNotice)) ?? e?.props?.notice; if (!notice) { if (typeof next === 'function') return next(e); return; @@ -537,20 +537,16 @@ export function register(on: any): void { try { const { Box, Text } = $.ui.resolve(e); if (Box && Text) { - const view = Box({ + return Box({ padding: 0, children: [ Text({ - text: `\u26A0 ${notice}`, - color: 'yellow', + children: [`\u26A0 ${notice}`], + color: 'warning', bold: true, }), ], }); - if (typeof next === 'function') { - return next({ ...e, view }); - } - return view; } } catch { // Fallback cleanly From 0ae7610eac2d9c72eb55198005c84091603d5447 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Sat, 10 Oct 2026 09:13:30 +0530 Subject: [PATCH 11/14] style: format check-family-plugins.py with ruff --- tools/dev/check-family-plugins.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/tools/dev/check-family-plugins.py b/tools/dev/check-family-plugins.py index c3b75d815..2165fa081 100644 --- a/tools/dev/check-family-plugins.py +++ b/tools/dev/check-family-plugins.py @@ -752,9 +752,8 @@ def check(fam: dict[str, set[str]]) -> list[str]: setup_hooks_manifest = PLUGINS / "magpie-setup" / "hooks" / "hooks.json" setup_types = PLUGINS / "magpie-setup" / "hooks" / "types.d.ts" setup_data, _setup_err = load_json(setup_manifest) - if setup_types.is_file(): - if setup_data is not None and setup_data.get("types") != "./hooks/types.d.ts": - errors.append(f"{setup_manifest}: 'types' must declare './hooks/types.d.ts'") + if setup_types.is_file() and setup_data is not None and setup_data.get("types") != "./hooks/types.d.ts": + errors.append(f"{setup_manifest}: 'types' must declare './hooks/types.d.ts'") if setup_hooks_manifest.is_file(): hooks_data, hooks_err = load_json(setup_hooks_manifest) if hooks_err: @@ -766,7 +765,9 @@ def check(fam: dict[str, set[str]]) -> list[str]: if not isinstance(hooks_data.get("modules"), list) or not hooks_data.get("modules"): errors.append(f"{setup_hooks_manifest}: 'modules' is missing or not a non-empty list") if "check-upgrade.sh" not in json.dumps(hooks_data.get("hooks", {})): - errors.append(f"{setup_hooks_manifest}: SessionStart hook does not reference check-upgrade.sh") + errors.append( + f"{setup_hooks_manifest}: SessionStart hook does not reference check-upgrade.sh" + ) if setup_data is not None and "hooks" in setup_data: errors.append( f"{setup_manifest}: declares inline 'hooks' while {setup_hooks_manifest} is present; " From f4ba9a7752cb7eb0a578fd7cc5b65e2045e3655d Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Sat, 10 Oct 2026 13:06:37 +0530 Subject: [PATCH 12/14] fix(setup): drop test-only fallback in hook and seed atom directly in tests - Read driftNotice atom alone without test-only fallback in ui.render - Remove MockState class and synthetic mocks in test suite - Mount AbovePrompt with real engine and props: {} - Seed driftNotice atom directly and verify AbovePrompt mount --- .../magpie-setup/hooks/drift-check.test.ts | 116 +++++++----------- plugins/magpie-setup/hooks/drift-check.ts | 2 +- 2 files changed, 47 insertions(+), 71 deletions(-) diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index 0c752fc19..cb6588376 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -18,7 +18,7 @@ // under the License. import { describe, test } from 'claude-code/testing'; -import { update } from 'claude-code'; +import { read, update } from 'claude-code'; import { checkSetupDrift, resolvePluginVersion, @@ -183,93 +183,69 @@ ref: v1.0.0 }); }); -class MockState { - private store = new Map(); - private nextVersion = 1; - - private keyOf(ref: any): string { - return `${ref.plugin}:${ref.key}`; - } - - async get(ref: any): Promise<{ value: any; version: number }> { - const k = this.keyOf(ref); - if (!this.store.has(k)) { - return { value: undefined, version: 0 }; - } - return this.store.get(k)!; - } - - async set(ref: any, value: any, options?: { ifVersion?: number }): Promise<{ isSet: boolean }> { - const k = this.keyOf(ref); - const curr = this.store.get(k); - if (options?.ifVersion !== undefined && (curr?.version ?? 0) !== options.ifVersion) { - return { isSet: false }; - } - this.nextVersion += 1; - this.store.set(k, { value, version: this.nextVersion }); - return { isSet: true }; - } -} - describe('Claude Code hook registration and AbovePrompt UI', () => { - test('session.start hook calls next(e)', async () => { - const fs = createTestEnv(); - const exists = async (p: string) => await fs.exists(p); - const read = async (p: string) => await fs.read(p); - - const registeredHooks = new Map(); - const mockOn = (event: string, ...args: any[]) => { - registeredHooks.set(event, args[args.length - 1]); - }; - - register(mockOn); - const sessionStartHandler = registeredHooks.get('session.start')!; - - const mock$ = { - fs: { exists, read }, - state: new MockState(), - session: { root: async () => workspaceDir }, - plugin: { root: pluginDir }, - ui: { invalidate: () => {} }, - }; + test('session.start hook calls next(e)', async ($: any) => { + let registeredHandler: any; + register((event: string, ...args: any[]) => { + if (event === 'session.start') { + registeredHandler = args[args.length - 1]; + } + }); + assert(registeredHandler, 'session.start handler should be registered'); let nextCalledWith: any; - await sessionStartHandler(mock$, { test: 'event' }, (e: any) => { nextCalledWith = e; return e; }); + await registeredHandler($, { test: 'event' }, (e: any) => { + nextCalledWith = e; + return e; + }); assertStrictEqual(nextCalledWith?.test, 'event'); }); - test('ui.render forwards unchanged with next(e) when there is no notice', async () => { - const registeredHooks = new Map(); - const mockOn = (event: string, ...args: any[]) => { - registeredHooks.set(event, args[args.length - 1]); - }; - - register(mockOn); - const uiRenderHandler = registeredHooks.get('ui.render')!; - - const mock$ = { - state: new MockState(), - }; + test('ui.render forwards unchanged when there is no notice', async ($: any, on: any) => { + on('ui.render', (e: any) => { + return { type: 'Box', children: [] }; + }); - let nextCalledWith: any; - await uiRenderHandler(mock$, { component: 'AbovePrompt' }, (e: any) => { nextCalledWith = e; return e; }); - assertStrictEqual(nextCalledWith?.component, 'AbovePrompt'); - assertStrictEqual(nextCalledWith?.view, undefined); + const ui = await $.ui.mount({ + plugin: 'magpie-setup', + surface: 'terminal', + component: 'AbovePrompt', + props: {}, + }); + const found = await ui.find({ type: 'Text', text: /below adoption floor/ }); + assertStrictEqual(found, undefined, 'AbovePrompt should not render drift text when no notice'); }); - test('renders actual AbovePrompt view with drift notice using real engine', async ($: any) => { + test('renders actual AbovePrompt view with drift notice using real engine', async ($: any, on: any) => { + on('ui.render', (e: any) => { + return { type: 'Box', children: [] }; + }); + const message = 'Apache Magpie: Installed plugin version (0.9.0) is below adoption floor (1.0.0). Run `/magpie-setup upgrade` to reconcile.'; + let atomSeeded = false; + try { + await update($, driftNotice, () => message); + atomSeeded = true; + } catch { + // The test harness $ does not expose $.state to test callbacks + // (TypeError: undefined is not an object (evaluating 'state.get')). + } + for (const surface of ['terminal', 'desktop'] as const) { const ui = await $.ui.mount({ plugin: 'magpie-setup', surface, component: 'AbovePrompt', - props: { notice: message }, + props: {}, }); - const found = await ui.find({ type: 'Text', text: /below adoption floor/ }); - assert(found, `Expected drift notice rendered on ${surface}`); + if (atomSeeded) { + const found = await ui.find({ type: 'Text', text: /below adoption floor/ }); + assert(found, `Expected drift notice rendered on ${surface}`); + } else { + assert(ui, `Expected AbovePrompt component to mount cleanly on ${surface}`); + } } }); }); diff --git a/plugins/magpie-setup/hooks/drift-check.ts b/plugins/magpie-setup/hooks/drift-check.ts index 47d103080..83bc4649a 100644 --- a/plugins/magpie-setup/hooks/drift-check.ts +++ b/plugins/magpie-setup/hooks/drift-check.ts @@ -528,7 +528,7 @@ export function register(on: any): void { // 2. Hook ui.render to display AbovePrompt drift banner if detected on('ui.render', { component: 'AbovePrompt' }, async ($: any, e: any, next: any) => { - const notice = (await read($, driftNotice)) ?? e?.props?.notice; + const notice = await read($, driftNotice); if (!notice) { if (typeof next === 'function') return next(e); return; From f766d5744508a4e5c0afc45e50680b8e789e9543 Mon Sep 17 00:00:00 2001 From: Vardhman Gupta Date: Sun, 11 Oct 2026 12:06:36 +0530 Subject: [PATCH 13/14] test(setup): drive AbovePrompt drift tests end-to-end via engine session.start stubs --- .../magpie-setup/hooks/drift-check.test.ts | 122 ++++++++++-------- 1 file changed, 68 insertions(+), 54 deletions(-) diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index cb6588376..7d5257dad 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -18,7 +18,6 @@ // under the License. import { describe, test } from 'claude-code/testing'; -import { read, update } from 'claude-code'; import { checkSetupDrift, resolvePluginVersion, @@ -27,8 +26,6 @@ import { parsePep440, comparePep440, joinPath, - register, - driftNotice, } from './drift-check.ts'; class MockFsAsync { @@ -184,54 +181,75 @@ ref: v1.0.0 }); describe('Claude Code hook registration and AbovePrompt UI', () => { - test('session.start hook calls next(e)', async ($: any) => { - let registeredHandler: any; - register((event: string, ...args: any[]) => { - if (event === 'session.start') { - registeredHandler = args[args.length - 1]; - } - }); - assert(registeredHandler, 'session.start handler should be registered'); - - let nextCalledWith: any; - await registeredHandler($, { test: 'event' }, (e: any) => { - nextCalledWith = e; - return e; - }); - assertStrictEqual(nextCalledWith?.test, 'event'); - }); - - test('ui.render forwards unchanged when there is no notice', async ($: any, on: any) => { - on('ui.render', (e: any) => { - return { type: 'Box', children: [] }; - }); + const LOCK_BELOW_FLOOR = [ + 'method: marketplace', + 'url: apache/magpie', + 'min_version: 1.0.0', + '', + 'plugins:', + ' - magpie-setup', + '', + ].join('\n'); + + const LOCK_SATISFYING_FLOOR = [ + 'method: marketplace', + 'url: apache/magpie', + 'min_version: 0.9.0', + '', + 'plugins:', + ' - magpie-setup', + '', + ].join('\n'); + + test('renders actual AbovePrompt view with drift notice when installed plugin is below adoption floor', async ($: any, on: any) => { + on('fs.exists', (_: any, e: any) => ({ + value: true, + })); + on('fs.read', (_: any, e: any) => ({ + value: e.path.endsWith('.apache-magpie.lock') + ? LOCK_BELOW_FLOOR + : e.path.endsWith('package.json') + ? JSON.stringify({ version: '0.9.0' }) + : '', + })); + + on('ui.render', () => ({ type: 'Box', children: [] })); + on('session.start', () => ({ cwd: '/work' })); + on('session.root', () => ({ value: '/work' })); + on('command.register', () => ({ value: undefined })); + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }); - const ui = await $.ui.mount({ - plugin: 'magpie-setup', - surface: 'terminal', - component: 'AbovePrompt', - props: {}, - }); - const found = await ui.find({ type: 'Text', text: /below adoption floor/ }); - assertStrictEqual(found, undefined, 'AbovePrompt should not render drift text when no notice'); + for (const surface of ['terminal', 'desktop'] as const) { + const ui = await $.ui.mount({ + plugin: 'magpie-setup', + surface, + component: 'AbovePrompt', + props: {}, + }); + const found = await ui.find({ type: 'Text', text: /below adoption floor/ }); + assert(found, `Expected drift notice rendered on ${surface}`); + } }); - test('renders actual AbovePrompt view with drift notice using real engine', async ($: any, on: any) => { - on('ui.render', (e: any) => { - return { type: 'Box', children: [] }; - }); - - const message = - 'Apache Magpie: Installed plugin version (0.9.0) is below adoption floor (1.0.0). Run `/magpie-setup upgrade` to reconcile.'; - - let atomSeeded = false; - try { - await update($, driftNotice, () => message); - atomSeeded = true; - } catch { - // The test harness $ does not expose $.state to test callbacks - // (TypeError: undefined is not an object (evaluating 'state.get')). - } + test('ui.render forwards unchanged when there is no notice (satisfying adoption floor)', async ($: any, on: any) => { + on('fs.exists', (_: any, e: any) => ({ + value: true, + })); + on('fs.read', (_: any, e: any) => ({ + value: e.path.endsWith('.apache-magpie.lock') + ? LOCK_SATISFYING_FLOOR + : e.path.endsWith('package.json') + ? JSON.stringify({ version: '0.9.0' }) + : '', + })); + + on('ui.render', () => ({ type: 'Box', children: [] })); + on('session.start', () => ({ cwd: '/work' })); + on('session.root', () => ({ value: '/work' })); + on('command.register', () => ({ value: undefined })); + + await $.session.start({ surface: 'terminal', isInteractive: true, cwd: '/work' }); for (const surface of ['terminal', 'desktop'] as const) { const ui = await $.ui.mount({ @@ -240,12 +258,8 @@ ref: v1.0.0 component: 'AbovePrompt', props: {}, }); - if (atomSeeded) { - const found = await ui.find({ type: 'Text', text: /below adoption floor/ }); - assert(found, `Expected drift notice rendered on ${surface}`); - } else { - assert(ui, `Expected AbovePrompt component to mount cleanly on ${surface}`); - } + const found = await ui.find({ type: 'Text', text: /below adoption floor/ }); + assertStrictEqual(found, undefined, `AbovePrompt should not render drift text on ${surface} when no notice`); } }); }); From d9845850a97b98911c2ad6f724bd5305ba417825 Mon Sep 17 00:00:00 2001 From: Jarek Potiuk Date: Sun, 11 Oct 2026 16:23:45 +0100 Subject: [PATCH 14/14] test(setup): serve the installed version from plugin.json in the engine tests The engine tests answered the version read from package.json and returned '' for .claude-plugin/plugin.json, so they only exercised resolvePluginVersion's fallback. Serve it from plugin.json, the path a real session reads first. Generated-by: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GTPsZ5aEE5bUVYqp47Dpvv --- plugins/magpie-setup/hooks/drift-check.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/plugins/magpie-setup/hooks/drift-check.test.ts b/plugins/magpie-setup/hooks/drift-check.test.ts index 7d5257dad..370074a27 100644 --- a/plugins/magpie-setup/hooks/drift-check.test.ts +++ b/plugins/magpie-setup/hooks/drift-check.test.ts @@ -208,8 +208,8 @@ ref: v1.0.0 on('fs.read', (_: any, e: any) => ({ value: e.path.endsWith('.apache-magpie.lock') ? LOCK_BELOW_FLOOR - : e.path.endsWith('package.json') - ? JSON.stringify({ version: '0.9.0' }) + : e.path.endsWith('.claude-plugin/plugin.json') + ? JSON.stringify({ name: 'magpie-setup', version: '0.9.0' }) : '', })); @@ -239,8 +239,8 @@ ref: v1.0.0 on('fs.read', (_: any, e: any) => ({ value: e.path.endsWith('.apache-magpie.lock') ? LOCK_SATISFYING_FLOOR - : e.path.endsWith('package.json') - ? JSON.stringify({ version: '0.9.0' }) + : e.path.endsWith('.claude-plugin/plugin.json') + ? JSON.stringify({ name: 'magpie-setup', version: '0.9.0' }) : '', }));