Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/check-stale-outputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"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.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions src/cli/command-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand All @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/cli/commands/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,16 @@ export async function runCheck(
configDir: context.configDir,
canonicalDir: context.canonicalDir,
rootBase: verifyOutputs ? context.rootBase : undefined,
scope,
});

if (!report.hasLock) {
return {
exitCode: 1,
data: {
hasLock: false,
canonicalDrift: false,
outputDrift: false,
inSync: false,
modified: [],
added: [],
Expand All @@ -54,6 +57,7 @@ export async function runCheck(
lockedViolations: [],
outputsModified: [],
outputsRemoved: [],
outputsStale: [],
outputsChecked: false,
},
};
Expand All @@ -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],
Expand All @@ -71,6 +77,7 @@ export async function runCheck(
lockedViolations: [...report.lockedViolations],
outputsModified: [...report.outputsModified],
outputsRemoved: [...report.outputsRemoved],
outputsStale: [...report.outputsStale],
outputsChecked: report.outputsChecked,
},
};
Expand Down
3 changes: 3 additions & 0 deletions src/cli/renderers/check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
45 changes: 36 additions & 9 deletions src/core/check/lock-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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
Expand All @@ -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;
}

/**
Expand All @@ -65,20 +75,23 @@ export interface CheckLockSyncOptions {
* callers decide whether that's a hard error (CI) or just informational.
*/
export async function checkLockSync(opts: CheckLockSyncOptions): Promise<LockSyncReport> {
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: [],
extendsModified: [],
lockedViolations: [],
outputsModified: [],
outputsRemoved: [],
outputsStale: [],
outputsChecked: false,
};
}
Expand Down Expand Up @@ -130,24 +143,38 @@ export async function checkLockSync(opts: CheckLockSyncOptions): Promise<LockSyn
? await diffOutputChecksums(rootBase, lock.outputs ?? {})
: { outputsModified: [], outputsRemoved: [] };

const inSync =
modified.length === 0 &&
added.length === 0 &&
removed.length === 0 &&
extendsModified.length === 0 &&
outputsModified.length === 0 &&
outputsRemoved.length === 0;
const outputsStale =
rootBase !== undefined && lock.outputs !== undefined
? await findStaleGeneratedOutputs({
projectRoot: rootBase,
targets: [...config.targets, ...(config.pluginTargets ?? [])],
expectedPaths: Object.keys(lock.outputs),
scope,
})
: [];

const canonicalDrift =
modified.length > 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,
extendsModified,
lockedViolations,
outputsModified,
outputsRemoved,
outputsStale,
outputsChecked,
};
}
31 changes: 18 additions & 13 deletions src/core/generate/stale-cleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,22 +18,16 @@ async function listFiles(root: string, base = root): Promise<string[]> {
return files;
}

async function removeIfStale(
projectRoot: string,
relPath: string,
expected: Set<string>,
): Promise<void> {
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<void> {
}

export async function findStaleGeneratedOutputs(
args: StaleGeneratedOutputsArgs,
): Promise<string[]> {
const expected = new Set(args.expectedPaths);
const stale = new Set<string>();
const scope = args.scope ?? 'project';
Expand All @@ -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<void> {
for (const relPath of await findStaleGeneratedOutputs(args)) {
await rm(join(args.projectRoot, relPath), { recursive: true, force: true });
}
}
4 changes: 4 additions & 0 deletions src/mcp/handlers/orchestrate-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ export interface LintHandlerResult {

export interface CheckHandlerResult {
drift: boolean;
canonicalDrift: boolean;
outputDrift: boolean;
missing: string[];
extra: string[];
modified: string[];
/** Generated outputs whose on-disk hash differs from the lock. */
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.
Expand Down
4 changes: 4 additions & 0 deletions src/mcp/handlers/orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,14 +100,18 @@ async function check(ctx: McpContext): Promise<CheckHandlerResult> {
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) {
Expand Down
2 changes: 1 addition & 1 deletion src/mcp/tool-tables/orchestrate-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
},
Expand Down
Loading
Loading