From 9efbab1f0c7412361a8f0099d4f03bb2e7b49161 Mon Sep 17 00:00:00 2001 From: VVeb1250 Date: Tue, 21 Jul 2026 15:30:53 +0900 Subject: [PATCH 1/5] fix(check): detect stale generated outputs --- .changeset/check-stale-outputs.md | 5 ++ src/cli/command-result.ts | 6 +++ src/cli/commands/check.ts | 7 +++ src/cli/renderers/check.ts | 3 ++ src/core/check/lock-sync.ts | 45 ++++++++++++++---- src/core/generate/stale-cleanup.ts | 31 +++++++------ src/mcp/handlers/orchestrate-types.ts | 4 ++ src/mcp/handlers/orchestrate.ts | 4 ++ src/mcp/tool-tables/orchestrate-tools.ts | 2 +- tests/e2e/check-outputs.e2e.test.ts | 46 ++++++++++++++++++- .../mcp-generate-lock.integration.test.ts | 3 ++ tests/unit/cli/commands/check.test.ts | 9 ++++ tests/unit/cli/renderers/check.test.ts | 19 ++++++++ tests/unit/core/check/lock-sync.test.ts | 40 ++++++++++++++++ tests/unit/mcp/handlers/orchestrate.test.ts | 9 ++++ website/src/content/docs/cli/check.mdx | 6 +-- .../docs/guides/ci-drift-detection.mdx | 4 +- .../src/content/docs/reference/mcp-server.mdx | 2 +- 18 files changed, 216 insertions(+), 29 deletions(-) create mode 100644 .changeset/check-stale-outputs.md diff --git a/.changeset/check-stale-outputs.md b/.changeset/check-stale-outputs.md new file mode 100644 index 00000000..4da384f8 --- /dev/null +++ b/.changeset/check-stale-outputs.md @@ -0,0 +1,5 @@ +--- +"agentsmesh": patch +--- + +Detect stale files in managed generated-output locations during `agentsmesh check`, and expose `canonicalDrift`, `outputDrift`, and `outputsStale` across CLI, programmatic, and MCP results. diff --git a/src/cli/command-result.ts b/src/cli/command-result.ts index 8bc99949..14765d9e 100644 --- a/src/cli/command-result.ts +++ b/src/cli/command-result.ts @@ -65,6 +65,10 @@ export interface LintData { export interface CheckData { hasLock: boolean; + /** True when canonical files or extends differ from the lock. */ + canonicalDrift: boolean; + /** True when a generated output is modified, removed, or stale. */ + outputDrift: boolean; inSync: boolean; modified: string[]; added: string[]; @@ -75,6 +79,8 @@ export interface CheckData { outputsModified: string[]; /** Generated outputs recorded in the lock but missing from disk. */ outputsRemoved: string[]; + /** Managed generated outputs present on disk but absent from the lock. */ + outputsStale: string[]; /** * True when generated-output drift was actually verified. False for * old-format locks (no `outputs` map) or when `--no-outputs` was passed; diff --git a/src/cli/commands/check.ts b/src/cli/commands/check.ts index 0430f33c..7f1d9902 100644 --- a/src/cli/commands/check.ts +++ b/src/cli/commands/check.ts @@ -39,6 +39,7 @@ export async function runCheck( configDir: context.configDir, canonicalDir: context.canonicalDir, rootBase: verifyOutputs ? context.rootBase : undefined, + scope, }); if (!report.hasLock) { @@ -46,6 +47,8 @@ export async function runCheck( exitCode: 1, data: { hasLock: false, + canonicalDrift: false, + outputDrift: false, inSync: false, modified: [], added: [], @@ -54,6 +57,7 @@ export async function runCheck( lockedViolations: [], outputsModified: [], outputsRemoved: [], + outputsStale: [], outputsChecked: false, }, }; @@ -63,6 +67,8 @@ export async function runCheck( exitCode: report.inSync ? 0 : 1, data: { hasLock: true, + canonicalDrift: report.canonicalDrift, + outputDrift: report.outputDrift, inSync: report.inSync, modified: [...report.modified], added: [...report.added], @@ -71,6 +77,7 @@ export async function runCheck( lockedViolations: [...report.lockedViolations], outputsModified: [...report.outputsModified], outputsRemoved: [...report.outputsRemoved], + outputsStale: [...report.outputsStale], outputsChecked: report.outputsChecked, }, }; diff --git a/src/cli/renderers/check.ts b/src/cli/renderers/check.ts index bd611dfb..3feee38d 100644 --- a/src/cli/renderers/check.ts +++ b/src/cli/renderers/check.ts @@ -43,6 +43,9 @@ export function renderCheck(result: CheckCommandResult): void { for (const p of data.outputsRemoved) { ui.error(` generated output "${fwd(p)}" was removed`); } + for (const p of data.outputsStale) { + ui.error(` generated output "${fwd(p)}" is stale`); + } ui.note('Generated files are out of sync.', 'Check'); ui.info( "Run 'agentsmesh merge' to resolve, or 'agentsmesh generate --force' to accept current state.", diff --git a/src/core/check/lock-sync.ts b/src/core/check/lock-sync.ts index a3349f50..fd42d7a4 100644 --- a/src/core/check/lock-sync.ts +++ b/src/core/check/lock-sync.ts @@ -13,12 +13,18 @@ import { } from '../../config/core/lock.js'; import { resolveExtendPaths } from '../../config/resolve/resolver.js'; import { diffOutputChecksums } from '../../config/core/lock-outputs.js'; +import { findStaleGeneratedOutputs } from '../generate/stale-cleanup.js'; +import type { TargetLayoutScope } from '../../targets/catalog/target-descriptor.js'; export interface LockSyncReport { - /** True when the canonical state matches the lock file and no extend drifted. */ + /** True when canonical state and checked generated outputs are all in sync. */ readonly inSync: boolean; /** True when a `.lock` file was found at the canonical directory. */ readonly hasLock: boolean; + /** True when canonical files or extends differ from the lock. */ + readonly canonicalDrift: boolean; + /** True when a generated output is modified, removed, or stale. */ + readonly outputDrift: boolean; /** Canonical files whose checksum differs from the lock. */ readonly modified: readonly string[]; /** Canonical files present now but not in the lock. */ @@ -36,6 +42,8 @@ export interface LockSyncReport { readonly outputsModified: readonly string[]; /** Generated outputs recorded in the lock but missing from disk. */ readonly outputsRemoved: readonly string[]; + /** Managed generated outputs present on disk but absent from the lock. */ + readonly outputsStale: readonly string[]; /** * True when output drift was actually verified — requires `rootBase` and a * lock with an `outputs` map. False for old-format locks or when no @@ -55,6 +63,8 @@ export interface CheckLockSyncOptions { * verification is skipped (keeps the programmatic API backward compatible). */ readonly rootBase?: string; + /** Output-layout scope used when scanning managed locations for stale files. */ + readonly scope?: TargetLayoutScope; } /** @@ -65,13 +75,15 @@ export interface CheckLockSyncOptions { * callers decide whether that's a hard error (CI) or just informational. */ export async function checkLockSync(opts: CheckLockSyncOptions): Promise { - const { config, configDir, canonicalDir, rootBase } = opts; + const { config, configDir, canonicalDir, rootBase, scope = 'project' } = opts; const lock = await readLock(canonicalDir); if (lock === null) { return { inSync: false, hasLock: false, + canonicalDrift: false, + outputDrift: false, modified: [], added: [], removed: [], @@ -79,6 +91,7 @@ export async function checkLockSync(opts: CheckLockSyncOptions): Promise 0 || + added.length > 0 || + removed.length > 0 || + extendsModified.length > 0; + const outputDrift = + outputsModified.length > 0 || outputsRemoved.length > 0 || outputsStale.length > 0; + const inSync = !canonicalDrift && !outputDrift; return { inSync, hasLock: true, + canonicalDrift, + outputDrift, modified, added, removed, @@ -148,6 +174,7 @@ export async function checkLockSync(opts: CheckLockSyncOptions): Promise { return files; } -async function removeIfStale( - projectRoot: string, - relPath: string, - expected: Set, -): Promise { - if (expected.has(relPath)) return; - const abs = join(projectRoot, relPath); - if (await exists(abs)) await rm(abs, { recursive: true, force: true }); -} - -export async function cleanupStaleGeneratedOutputs(args: { +interface StaleGeneratedOutputsArgs { projectRoot: string; targets: string[]; expectedPaths: string[]; scope?: TargetLayoutScope; -}): Promise { +} + +export async function findStaleGeneratedOutputs( + args: StaleGeneratedOutputsArgs, +): Promise { const expected = new Set(args.expectedPaths); const stale = new Set(); const scope = args.scope ?? 'project'; @@ -51,7 +45,18 @@ export async function cleanupStaleGeneratedOutputs(args: { } } + const found: string[] = []; for (const relPath of stale) { - await removeIfStale(args.projectRoot, relPath, expected); + if (expected.has(relPath)) continue; + if (await exists(join(args.projectRoot, relPath))) found.push(relPath); + } + return found.sort(); +} + +export async function cleanupStaleGeneratedOutputs( + args: StaleGeneratedOutputsArgs, +): Promise { + for (const relPath of await findStaleGeneratedOutputs(args)) { + await rm(join(args.projectRoot, relPath), { recursive: true, force: true }); } } diff --git a/src/mcp/handlers/orchestrate-types.ts b/src/mcp/handlers/orchestrate-types.ts index 54d98b2d..4d683596 100644 --- a/src/mcp/handlers/orchestrate-types.ts +++ b/src/mcp/handlers/orchestrate-types.ts @@ -16,6 +16,8 @@ export interface LintHandlerResult { export interface CheckHandlerResult { drift: boolean; + canonicalDrift: boolean; + outputDrift: boolean; missing: string[]; extra: string[]; modified: string[]; @@ -23,6 +25,8 @@ export interface CheckHandlerResult { outputsModified: string[]; /** Generated outputs recorded in the lock but missing from disk. */ outputsRemoved: string[]; + /** Managed generated outputs present on disk but absent from the lock. */ + outputsStale: string[]; /** * True when generated-output drift was verified; false for old-format locks * without an `outputs` map. diff --git a/src/mcp/handlers/orchestrate.ts b/src/mcp/handlers/orchestrate.ts index 92a64f0f..b9180523 100644 --- a/src/mcp/handlers/orchestrate.ts +++ b/src/mcp/handlers/orchestrate.ts @@ -100,14 +100,18 @@ async function check(ctx: McpContext): Promise { canonicalDir: pctx.canonicalDir, // Enables generated-output verification (skipped for old-format locks). rootBase: pctx.projectRoot, + scope: pctx.scope, }); return { drift: !report.inSync, + canonicalDrift: report.canonicalDrift, + outputDrift: report.outputDrift, missing: [...report.removed], extra: [...report.added], modified: [...report.modified], outputsModified: [...report.outputsModified], outputsRemoved: [...report.outputsRemoved], + outputsStale: [...report.outputsStale], outputsChecked: report.outputsChecked, }; } catch (e) { diff --git a/src/mcp/tool-tables/orchestrate-tools.ts b/src/mcp/tool-tables/orchestrate-tools.ts index 90840b7b..9d2e284a 100644 --- a/src/mcp/tool-tables/orchestrate-tools.ts +++ b/src/mcp/tool-tables/orchestrate-tools.ts @@ -53,7 +53,7 @@ export const ORCHESTRATE_TOOL_DESCRIPTORS: ToolDescriptor[] = [ { name: 'check', description: - 'Detect drift between canonical and lockfile, including hand-edits to generated target outputs (outputsChecked is false for old-format locks without an outputs map)', + 'Detect canonical and generated-output drift, including hand-edits and stale files in managed output locations (outputsChecked is false for old-format locks without an outputs map)', inputSchema: NoInput, handler: (ctx) => orchestrateHandlers.check(ctx), }, diff --git a/tests/e2e/check-outputs.e2e.test.ts b/tests/e2e/check-outputs.e2e.test.ts index 01e0f18f..a76a0337 100644 --- a/tests/e2e/check-outputs.e2e.test.ts +++ b/tests/e2e/check-outputs.e2e.test.ts @@ -8,13 +8,22 @@ */ import { afterEach, describe, expect, it } from 'vitest'; -import { appendFileSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { + appendFileSync, + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; import { join } from 'node:path'; import { cleanup, createTestProject } from './helpers/setup.js'; import { runCli } from './helpers/run-cli.js'; interface CheckData { hasLock: boolean; + canonicalDrift: boolean; + outputDrift: boolean; inSync: boolean; modified: string[]; added: string[]; @@ -23,6 +32,7 @@ interface CheckData { lockedViolations: string[]; outputsModified: string[]; outputsRemoved: string[]; + outputsStale: string[]; outputsChecked: boolean; } @@ -71,8 +81,11 @@ describe('check generated-output verification (e2e)', () => { const { data } = parseCheck(r.stdout); expect(data.inSync).toBe(false); expect(data.outputsChecked).toBe(true); + expect(data.canonicalDrift).toBe(false); + expect(data.outputDrift).toBe(true); expect(data.outputsModified).toEqual(['AGENTS.md']); expect(data.outputsRemoved).toEqual([]); + expect(data.outputsStale).toEqual([]); expect(data.modified).toEqual([]); }); @@ -87,11 +100,33 @@ describe('check generated-output verification (e2e)', () => { const { data } = parseCheck(r.stdout); expect(data.inSync).toBe(false); expect(data.outputsChecked).toBe(true); + expect(data.canonicalDrift).toBe(false); + expect(data.outputDrift).toBe(true); expect(data.outputsRemoved).toEqual(['AGENTS.md']); expect(data.outputsModified).toEqual([]); + expect(data.outputsStale).toEqual([]); expect(data.modified).toEqual([]); }); + it('hand-added managed output → exit 1, outputsStale only, without deleting it', async () => { + dir = createTestProject('canonical-full'); + await runCli('generate', dir); + + const stalePath = join(dir, '.cursor', 'rules', 'orphaned.mdc'); + mkdirSync(join(dir, '.cursor', 'rules'), { recursive: true }); + writeFileSync(stalePath, '# hand-added output\n'); + + const r = await runCli('check --json', dir); + expect(r.exitCode).toBe(1); + const { data } = parseCheck(r.stdout); + expect(data.canonicalDrift).toBe(false); + expect(data.outputDrift).toBe(true); + expect(data.outputsStale).toEqual(['.cursor/rules/orphaned.mdc']); + expect(data.outputsModified).toEqual([]); + expect(data.outputsRemoved).toEqual([]); + expect(existsSync(stalePath)).toBe(true); + }); + it('edited canonical file → exit 1, modified set but outputsModified empty', async () => { dir = createTestProject('canonical-full'); await runCli('generate', dir); @@ -103,9 +138,12 @@ describe('check generated-output verification (e2e)', () => { const { data } = parseCheck(r.stdout); expect(data.inSync).toBe(false); expect(data.outputsChecked).toBe(true); + expect(data.canonicalDrift).toBe(true); + expect(data.outputDrift).toBe(false); expect(data.modified).toEqual(['rules/typescript.md']); expect(data.outputsModified).toEqual([]); expect(data.outputsRemoved).toEqual([]); + expect(data.outputsStale).toEqual([]); }); it('old-format lock (no outputs block) → exit 0, outputsChecked:false', async () => { @@ -122,8 +160,11 @@ describe('check generated-output verification (e2e)', () => { const { data } = parseCheck(r.stdout); expect(data.inSync).toBe(true); expect(data.outputsChecked).toBe(false); + expect(data.canonicalDrift).toBe(false); + expect(data.outputDrift).toBe(false); expect(data.outputsModified).toEqual([]); expect(data.outputsRemoved).toEqual([]); + expect(data.outputsStale).toEqual([]); }); it('old-format lock → human output prints the skipped-verification note', async () => { @@ -149,7 +190,10 @@ describe('check generated-output verification (e2e)', () => { const { data } = parseCheck(r.stdout); expect(data.inSync).toBe(true); expect(data.outputsChecked).toBe(false); + expect(data.canonicalDrift).toBe(false); + expect(data.outputDrift).toBe(false); expect(data.outputsModified).toEqual([]); + expect(data.outputsStale).toEqual([]); }); it('human (non-JSON) drift output names the modified generated output on stderr', async () => { diff --git a/tests/integration/mcp-generate-lock.integration.test.ts b/tests/integration/mcp-generate-lock.integration.test.ts index 3e03ce01..b3c66ec7 100644 --- a/tests/integration/mcp-generate-lock.integration.test.ts +++ b/tests/integration/mcp-generate-lock.integration.test.ts @@ -33,11 +33,14 @@ describe('mcp generate — lockfile persistence', () => { // (outputsChecked: true) and finds no drift right after generation. expect(check).toEqual({ drift: false, + canonicalDrift: false, + outputDrift: false, missing: [], extra: [], modified: [], outputsModified: [], outputsRemoved: [], + outputsStale: [], outputsChecked: true, }); }); diff --git a/tests/unit/cli/commands/check.test.ts b/tests/unit/cli/commands/check.test.ts index 752f8344..b6583b38 100644 --- a/tests/unit/cli/commands/check.test.ts +++ b/tests/unit/cli/commands/check.test.ts @@ -206,9 +206,12 @@ outputs: expect(result.exitCode).toBe(1); expect(result.data.inSync).toBe(false); expect(result.data.outputsChecked).toBe(true); + expect(result.data.canonicalDrift).toBe(false); + expect(result.data.outputDrift).toBe(true); expect(result.data.outputsModified).toEqual(['AGENTS.md']); // Locked-but-absent output is reported as removed. expect(result.data.outputsRemoved).toEqual(['.cursor/rules/_root.mdc']); + expect(result.data.outputsStale).toEqual([]); }); it('returns exitCode 0 and outputsChecked:false for an old-format lock (no outputs map)', async () => { @@ -224,8 +227,11 @@ outputs: expect(result.exitCode).toBe(0); expect(result.data.inSync).toBe(true); expect(result.data.outputsChecked).toBe(false); + expect(result.data.canonicalDrift).toBe(false); + expect(result.data.outputDrift).toBe(false); expect(result.data.outputsModified).toEqual([]); expect(result.data.outputsRemoved).toEqual([]); + expect(result.data.outputsStale).toEqual([]); }); it('skips output verification and returns exitCode 0 with --no-outputs despite drift', async () => { @@ -249,8 +255,11 @@ outputs: expect(result.exitCode).toBe(0); expect(result.data.inSync).toBe(true); expect(result.data.outputsChecked).toBe(false); + expect(result.data.canonicalDrift).toBe(false); + expect(result.data.outputDrift).toBe(false); expect(result.data.outputsModified).toEqual([]); expect(result.data.outputsRemoved).toEqual([]); + expect(result.data.outputsStale).toEqual([]); }); it('reads ~/.agentsmesh/.lock when --global is set', async () => { diff --git a/tests/unit/cli/renderers/check.test.ts b/tests/unit/cli/renderers/check.test.ts index 7113aeef..5ffffc83 100644 --- a/tests/unit/cli/renderers/check.test.ts +++ b/tests/unit/cli/renderers/check.test.ts @@ -10,6 +10,8 @@ describe('renderCheck', () => { exitCode: 1, data: { hasLock: false, + canonicalDrift: false, + outputDrift: false, inSync: false, modified: [], added: [], @@ -18,6 +20,7 @@ describe('renderCheck', () => { lockedViolations: [], outputsModified: [], outputsRemoved: [], + outputsStale: [], outputsChecked: false, }, }); @@ -30,6 +33,8 @@ describe('renderCheck', () => { exitCode: 0, data: { hasLock: true, + canonicalDrift: false, + outputDrift: false, inSync: true, modified: [], added: [], @@ -38,6 +43,7 @@ describe('renderCheck', () => { lockedViolations: [], outputsModified: [], outputsRemoved: [], + outputsStale: [], outputsChecked: true, }, }); @@ -50,6 +56,8 @@ describe('renderCheck', () => { exitCode: 1, data: { hasLock: true, + canonicalDrift: true, + outputDrift: false, inSync: false, extendsModified: ['pack-a'], modified: ['rules/root.md', 'rules/open.md'], @@ -58,6 +66,7 @@ describe('renderCheck', () => { lockedViolations: ['rules/root.md', 'commands/deploy.md', 'skills/old/SKILL.md'], outputsModified: [], outputsRemoved: [], + outputsStale: [], outputsChecked: true, }, }); @@ -79,6 +88,8 @@ describe('renderCheck', () => { exitCode: 1, data: { hasLock: true, + canonicalDrift: false, + outputDrift: true, inSync: false, modified: [], added: [], @@ -87,6 +98,7 @@ describe('renderCheck', () => { lockedViolations: [], outputsModified: ['.cursor/rules/_root.mdc', 'AGENTS.md'], outputsRemoved: ['.claude/CLAUDE.md'], + outputsStale: ['.cursor/rules/orphaned.mdc'], outputsChecked: true, }, }); @@ -96,6 +108,7 @@ describe('renderCheck', () => { expect(errors).toContain('generated output ".cursor/rules/_root.mdc" was modified'); expect(errors).toContain('generated output "AGENTS.md" was modified'); expect(errors).toContain('generated output ".claude/CLAUDE.md" was removed'); + expect(errors).toContain('generated output ".cursor/rules/orphaned.mdc" is stale'); // Forward slashes only — never backslashes. expect(errors).not.toContain('\\'); }); @@ -105,6 +118,8 @@ describe('renderCheck', () => { exitCode: 0, data: { hasLock: true, + canonicalDrift: false, + outputDrift: false, inSync: true, modified: [], added: [], @@ -113,6 +128,7 @@ describe('renderCheck', () => { lockedViolations: [], outputsModified: [], outputsRemoved: [], + outputsStale: [], outputsChecked: false, }, }); @@ -126,6 +142,8 @@ describe('renderCheck', () => { exitCode: 0, data: { hasLock: true, + canonicalDrift: false, + outputDrift: false, inSync: true, modified: [], added: [], @@ -134,6 +152,7 @@ describe('renderCheck', () => { lockedViolations: [], outputsModified: [], outputsRemoved: [], + outputsStale: [], outputsChecked: true, }, }); diff --git a/tests/unit/core/check/lock-sync.test.ts b/tests/unit/core/check/lock-sync.test.ts index 186dd3f1..47e44d31 100644 --- a/tests/unit/core/check/lock-sync.test.ts +++ b/tests/unit/core/check/lock-sync.test.ts @@ -328,13 +328,53 @@ collaboration: }); expect(report.outputsChecked).toBe(true); + expect(report.canonicalDrift).toBe(false); + expect(report.outputDrift).toBe(true); expect(report.outputsModified).toEqual(['AGENTS.md']); expect(report.outputsRemoved).toEqual(['CLAUDE.md']); + expect(report.outputsStale).toEqual([]); expect(report.inSync).toBe(false); // Canonical is untouched. expect(report.modified).toEqual([]); }); + it('hand-added managed output is reported as stale without changing canonical drift', async () => { + const { projectRoot, canonicalDir } = setupBareProject( + 'version: 1\ntargets: [cursor]\nfeatures: [rules]\n', + ); + writeFileSync(join(canonicalDir, 'rules', '_root.md'), '# stable'); + const checksums = await buildChecksums(canonicalDir); + mkdirSync(join(projectRoot, '.cursor', 'rules'), { recursive: true }); + writeFileSync(join(projectRoot, '.cursor', 'rules', '_root.mdc'), '# generated'); + writeFileSync(join(projectRoot, '.cursor', 'rules', 'orphaned.mdc'), '# hand-added'); + await writeLock(canonicalDir, { + generatedAt: '2026-07-18T00:00:00Z', + generatedBy: 'test', + libVersion: '0.1.0', + checksums, + extends: {}, + packs: {}, + outputs: { + '.cursor/rules/_root.mdc': `sha256:${hashContent('# generated')}`, + }, + }); + + const config = await loadConfig(projectRoot); + const report = await checkLockSync({ + config, + configDir: projectRoot, + canonicalDir, + rootBase: projectRoot, + }); + + expect(report.canonicalDrift).toBe(false); + expect(report.outputDrift).toBe(true); + expect(report.outputsModified).toEqual([]); + expect(report.outputsRemoved).toEqual([]); + expect(report.outputsStale).toEqual(['.cursor/rules/orphaned.mdc']); + expect(report.inSync).toBe(false); + }); + it('old lock without outputs: outputsChecked=false and canonical inSync unaffected', async () => { const { projectRoot, canonicalDir } = setupBareProject( 'version: 1\ntargets: [claude-code]\nfeatures: [rules]\n', diff --git a/tests/unit/mcp/handlers/orchestrate.test.ts b/tests/unit/mcp/handlers/orchestrate.test.ts index 4ced73eb..0e6961a1 100644 --- a/tests/unit/mcp/handlers/orchestrate.test.ts +++ b/tests/unit/mcp/handlers/orchestrate.test.ts @@ -241,6 +241,8 @@ describe('orchestrateHandlers.check', () => { mockCheck.mockResolvedValue({ inSync: false, hasLock: true, + canonicalDrift: true, + outputDrift: true, modified: ['rules/foo.md'], added: ['rules/new.md'], removed: ['rules/old.md'], @@ -248,6 +250,7 @@ describe('orchestrateHandlers.check', () => { lockedViolations: [], outputsModified: ['AGENTS.md'], outputsRemoved: ['.claude/CLAUDE.md'], + outputsStale: ['.cursor/rules/orphaned.mdc'], outputsChecked: true, } satisfies LockSyncReport); @@ -257,8 +260,11 @@ describe('orchestrateHandlers.check', () => { expect(out.missing).toEqual(['rules/old.md']); expect(out.extra).toEqual(['rules/new.md']); expect(out.modified).toEqual(['rules/foo.md']); + expect(out.canonicalDrift).toBe(true); + expect(out.outputDrift).toBe(true); expect(out.outputsModified).toEqual(['AGENTS.md']); expect(out.outputsRemoved).toEqual(['.claude/CLAUDE.md']); + expect(out.outputsStale).toEqual(['.cursor/rules/orphaned.mdc']); expect(out.outputsChecked).toBe(true); }); @@ -266,6 +272,8 @@ describe('orchestrateHandlers.check', () => { mockCheck.mockResolvedValue({ inSync: true, hasLock: true, + canonicalDrift: false, + outputDrift: false, modified: [], added: [], removed: [], @@ -273,6 +281,7 @@ describe('orchestrateHandlers.check', () => { lockedViolations: [], outputsModified: [], outputsRemoved: [], + outputsStale: [], outputsChecked: true, } satisfies LockSyncReport); diff --git a/website/src/content/docs/cli/check.mdx b/website/src/content/docs/cli/check.mdx index 6738b609..afecbe99 100644 --- a/website/src/content/docs/cli/check.mdx +++ b/website/src/content/docs/cli/check.mdx @@ -36,9 +36,9 @@ The `.agentsmesh/` sources no longer match the checksums recorded in `.agentsmes ### Generated-output drift -A generated file (`.claude/`, `.cursor/`, `AGENTS.md`, etc.) was hand-edited or deleted. When `agentsmesh generate` runs, it records an `outputs` map in the lock — a checksum of every generated file it wrote or verified. `agentsmesh check` re-hashes those files on disk and reports any that were changed (`outputsModified`) or deleted (`outputsRemoved`). This is what lets `check` catch direct edits to generated files without running the generators. +A generated file (`.claude/`, `.cursor/`, `AGENTS.md`, etc.) was hand-edited, deleted, or added unexpectedly under a configured target's managed output locations. When `agentsmesh generate` runs, it records an `outputs` map in the lock — a checksum of every generated file it wrote or verified. `agentsmesh check` re-hashes those files on disk and reports any that were changed (`outputsModified`) or deleted (`outputsRemoved`), then scans managed locations for files absent from the lock (`outputsStale`). This catches generated-output drift without running the generators. -`check` does **not** flag brand-new unexpected files in output directories — stale-file cleanup during `generate` owns that. +JSON output exposes the two drift classes directly as `canonicalDrift` and `outputDrift`; the per-path arrays remain available for actionable diagnostics. ### Old-format locks @@ -89,7 +89,7 @@ jobs: | `0` | Canonical sources and recorded generated outputs both match the lock. | | `1` | Drift detected — canonical sources or generated outputs are out of sync. | -The MCP `check` tool mirrors this in its result payload, exposing `outputsModified`, `outputsRemoved`, and `outputsChecked` alongside the canonical-drift fields. +The MCP `check` tool mirrors this in its result payload, exposing `canonicalDrift`, `outputDrift`, `outputsModified`, `outputsRemoved`, `outputsStale`, and `outputsChecked`. ## Difference between `check` and `generate --check` diff --git a/website/src/content/docs/guides/ci-drift-detection.mdx b/website/src/content/docs/guides/ci-drift-detection.mdx index e9ed704a..0329b3dc 100644 --- a/website/src/content/docs/guides/ci-drift-detection.mdx +++ b/website/src/content/docs/guides/ci-drift-detection.mdx @@ -13,10 +13,11 @@ Add `agentsmesh check` and `agentsmesh lint` to your CI pipeline to catch config **Drift** happens when: - Someone edits `.agentsmesh/` but forgets to run `agentsmesh generate` - Someone edits a generated file (`.claude/`, `.cursor/`, etc.) directly +- Someone adds an unexpected file under a managed generated-output location - A PR merges canonical changes without regenerating - An `extends` source changes and the project hasn't refreshed -`agentsmesh check` detects all of these. It verifies two things against the stored lock: **canonical-source drift** (the `.agentsmesh/` sources vs. the recorded checksums) and **generated-output drift** (each generated file listed in the lock's `outputs` map, re-hashed on disk to catch direct hand-edits or deletions). Either kind of drift exits with code 1. +`agentsmesh check` detects all of these. It verifies two things against the stored lock: **canonical-source drift** (the `.agentsmesh/` sources vs. the recorded checksums) and **generated-output drift** (recorded files are re-hashed to catch hand-edits or deletions, and managed output locations are scanned for stale files absent from the lock). Either kind of drift exits with code 1. ## Basic CI setup @@ -64,6 +65,7 @@ Fails with exit code 1 if any lint errors are found. Verifies that both canonical sources and generated tool directories match the lock: - Re-hashes `.agentsmesh/` sources against the lock's `checksums` (canonical drift) - Re-hashes each generated file recorded in the lock's `outputs` map (generated-output drift) +- Scans configured targets' managed output locations for stale files absent from the lock - Fails with exit code 1 if either has drifted Locks written before generated-output tracking existed have no `outputs` map; `check` skips output verification and prints a note until you run `agentsmesh generate` once to upgrade the lock. Pass `--no-outputs` to skip output verification deliberately — for example, in CI setups that gitignore generated outputs, where every output would otherwise report as removed after checkout. diff --git a/website/src/content/docs/reference/mcp-server.mdx b/website/src/content/docs/reference/mcp-server.mdx index 9c8cec5a..92daa16c 100644 --- a/website/src/content/docs/reference/mcp-server.mdx +++ b/website/src/content/docs/reference/mcp-server.mdx @@ -127,7 +127,7 @@ The server exposes **50 tools** grouped by category. All tool errors return a st |------|-------------| | `generate` | Generate target-native config from canonical | | `lint` | Lint canonical files | -| `check` | Detect canonical-source and generated-output drift against the lock (result includes `outputsModified`/`outputsRemoved`/`outputsChecked`) | +| `check` | Detect canonical-source and generated-output drift against the lock (result includes `canonicalDrift`/`outputDrift`, per-path output arrays, and `outputsChecked`) | | `diff` | Preview generation changes | | `import` | Import another tool's config into canonical | | `convert` | Convert directly from one tool to another | From 076a683afe9bda0b6bc17035b3ac409635c6d54d Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Tue, 21 Jul 2026 18:01:43 +0200 Subject: [PATCH 2/5] fix(check): default pluginTargets when scanning for stale outputs The stale-output scan spread config.pluginTargets directly; every other call site in the codebase guards it with '?? []'. ValidatedConfig always populates the field, so this is a robustness/consistency fix for configs constructed outside the Zod parse path. --- src/core/check/lock-sync.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/core/check/lock-sync.ts b/src/core/check/lock-sync.ts index fd42d7a4..6e7b70dc 100644 --- a/src/core/check/lock-sync.ts +++ b/src/core/check/lock-sync.ts @@ -147,7 +147,7 @@ export async function checkLockSync(opts: CheckLockSyncOptions): Promise Date: Tue, 21 Jul 2026 18:03:04 +0200 Subject: [PATCH 3/5] test(check): cover stale detection for registered plugin descriptors Per the repo 'plugins are first-class targets' contract, any code that iterates targets must hold for registered plugin descriptors, not just builtins. The stale-output scan iterates config.pluginTargets, so add a lock-sync test that registers a plugin descriptor with a managedOutputs layout and asserts a hand-added file under its managed dir is reported in outputsStale (resolved via getDescriptor, not builtin-only). --- tests/unit/core/check/lock-sync.test.ts | 90 +++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/tests/unit/core/check/lock-sync.test.ts b/tests/unit/core/check/lock-sync.test.ts index 47e44d31..94ce7d88 100644 --- a/tests/unit/core/check/lock-sync.test.ts +++ b/tests/unit/core/check/lock-sync.test.ts @@ -12,6 +12,11 @@ import { checkLockSync } from '../../../../src/core/check/lock-sync.js'; import { writeLock, buildChecksums } from '../../../../src/config/core/lock.js'; import { loadConfigFromDir } from '../../../../src/config/core/loader.js'; import { hashContent } from '../../../../src/utils/crypto/hash.js'; +import { + registerTargetDescriptor, + resetRegistry, +} from '../../../../src/targets/catalog/registry.js'; +import type { TargetDescriptor } from '../../../../src/targets/catalog/target-descriptor.js'; import type { ValidatedConfig } from '../../../../src/config/core/schema.js'; const TEST_DIR = join(tmpdir(), `am-lock-sync-unit-${process.pid}`); @@ -35,6 +40,49 @@ function setupBareProject(yaml: string): { projectRoot: string; canonicalDir: st return { projectRoot, canonicalDir }; } +const PLUGIN_ID = 'check-stale-plugin'; + +/** + * Register a plugin descriptor whose managed output location is `.plugin/rules`. + * Proves the stale-output scan resolves plugin targets via the registry + * (`getBuiltinTargetDefinition(id) ?? getDescriptor(id)`), not builtins only. + */ +function registerPluginWithManagedOutputs(): void { + registerTargetDescriptor({ + id: PLUGIN_ID, + metadata: { + displayName: PLUGIN_ID, + category: 'cli', + officialUrl: 'https://example.test/', + shortDescription: 'Stale-output contract fixture', + }, + generators: { name: PLUGIN_ID, generateRules: () => [], importFrom: async () => [] }, + capabilities: { + rules: 'native', + additionalRules: 'none', + commands: 'none', + agents: 'none', + skills: 'none', + mcp: 'none', + hooks: 'none', + ignore: 'none', + permissions: 'none', + }, + emptyImportMessage: 'No plugin config found.', + lintRules: null, + project: { + paths: { + rulePath: (slug: string) => `.plugin/rules/${slug}.md`, + commandPath: () => null, + agentPath: () => null, + }, + managedOutputs: { dirs: ['.plugin/rules'], files: [] }, + }, + buildImportPaths: async () => {}, + detectionPaths: ['.plugin'], + } as unknown as TargetDescriptor); +} + describe('checkLockSync', () => { it('hasLock=false and inSync=false when .lock is missing — and all drift arrays are empty', async () => { const { projectRoot, canonicalDir } = setupBareProject('version: 1\n'); @@ -375,6 +423,48 @@ collaboration: expect(report.inSync).toBe(false); }); + describe('plugin-target managed outputs (registered descriptor)', () => { + beforeEach(() => registerPluginWithManagedOutputs()); + afterEach(() => resetRegistry()); + + it("reports a hand-added file under a plugin target's managed dir as stale", async () => { + const { projectRoot, canonicalDir } = setupBareProject( + `version: 1\ntargets: [claude-code]\npluginTargets: [${PLUGIN_ID}]\nfeatures: [rules]\n`, + ); + writeFileSync(join(canonicalDir, 'rules', '_root.md'), '# stable'); + const checksums = await buildChecksums(canonicalDir); + mkdirSync(join(projectRoot, '.plugin', 'rules'), { recursive: true }); + writeFileSync(join(projectRoot, '.plugin', 'rules', '_root.md'), '# generated'); + writeFileSync(join(projectRoot, '.plugin', 'rules', 'orphaned.md'), '# hand-added'); + await writeLock(canonicalDir, { + generatedAt: '2026-07-18T00:00:00Z', + generatedBy: 'test', + libVersion: '0.1.0', + checksums, + extends: {}, + packs: {}, + outputs: { + '.plugin/rules/_root.md': `sha256:${hashContent('# generated')}`, + }, + }); + + const config = await loadConfig(projectRoot); + const report = await checkLockSync({ + config, + configDir: projectRoot, + canonicalDir, + rootBase: projectRoot, + }); + + expect(report.canonicalDrift).toBe(false); + expect(report.outputDrift).toBe(true); + expect(report.outputsStale).toEqual(['.plugin/rules/orphaned.md']); + expect(report.outputsModified).toEqual([]); + expect(report.outputsRemoved).toEqual([]); + expect(report.inSync).toBe(false); + }); + }); + it('old lock without outputs: outputsChecked=false and canonical inSync unaffected', async () => { const { projectRoot, canonicalDir } = setupBareProject( 'version: 1\ntargets: [claude-code]\nfeatures: [rules]\n', From 49d70d2a098d43ac744d5d4853677b851a6ede80 Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Tue, 21 Jul 2026 18:03:29 +0200 Subject: [PATCH 4/5] chore(changeset): bump check-stale-outputs from patch to minor Stale-output detection adds new user-facing behavior plus additive CLI JSON and MCP result fields (canonicalDrift, outputDrift, outputsStale). Under semver that is a backward-compatible feature, so minor is the correct bump for a 0.x release rather than patch. --- .changeset/check-stale-outputs.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/check-stale-outputs.md b/.changeset/check-stale-outputs.md index 4da384f8..f84d7fe9 100644 --- a/.changeset/check-stale-outputs.md +++ b/.changeset/check-stale-outputs.md @@ -1,5 +1,5 @@ --- -"agentsmesh": patch +"agentsmesh": minor --- Detect stale files in managed generated-output locations during `agentsmesh check`, and expose `canonicalDrift`, `outputDrift`, and `outputsStale` across CLI, programmatic, and MCP results. From a180aedd95607500c4698a6331e1cba3b0a4a2af Mon Sep 17 00:00:00 2001 From: Serhii Zhabskyi Date: Tue, 21 Jul 2026 18:30:17 +0200 Subject: [PATCH 5/5] fix(deps): bump tar to 7.5.20 to clear high/critical advisories pnpm audit --prod --audit-level=high failed on tar versions up to 7.5.18: - critical GHSA-23hp-3jrh-7fpw (decompression/parse DoS), patched 7.5.19+ - high GHSA-8x88-c5mf-7j5w (negative entry size infinite loop), patched 7.5.18+ tar is a direct prod dependency, so bumping the pin from 7.5.13 to 7.5.20 clears both. Lockfile regenerated with pnpm 10 to match CI; frozen install and the audit gate both verified green. Pre-existing issue, unrelated to the check changes on this branch. --- package.json | 2 +- pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/package.json b/package.json index 6607bdfd..38a49a83 100644 --- a/package.json +++ b/package.json @@ -156,7 +156,7 @@ "diff": "8.0.4", "picomatch": "^4.0.4", "smol-toml": "^1.6.1", - "tar": "7.5.13", + "tar": "7.5.20", "yaml": "^2.8.3", "zod": "^4.3.6" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 604afddc..daaa8783 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: ^1.6.1 version: 1.6.1 tar: - specifier: 7.5.13 - version: 7.5.13 + specifier: 7.5.20 + version: 7.5.20 yaml: specifier: ^2.8.3 version: 2.8.3 @@ -2465,8 +2465,8 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} - tar@7.5.13: - resolution: {integrity: sha512-tOG/7GyXpFevhXVh8jOPJrmtRpOTsYqUIkVdVooZYJS/z8WhfQUX8RJILmeuJNinGAMSu1veBr4asSHFt5/hng==} + tar@7.5.20: + resolution: {integrity: sha512-9FcyK4PA6+WbzlTM9WhQm6vB5W7cP7dUiPsv1g7YDwEQnQ1CGpK3MGlKk/ITVWMk05kHZuBhmVhiv8LZoy/PFQ==} engines: {node: '>=18'} term-size@2.2.1: @@ -5313,7 +5313,7 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} - tar@7.5.13: + tar@7.5.20: dependencies: '@isaacs/fs-minipass': 4.0.1 chownr: 3.0.0