openclaw/scripts/check-session-accessor-boundary.mts
Peter Steinberger 1ced7441eb
refactor(agents): move announce, completion, recovery, and registry families into concept directories (#121553)
* refactor(agents): move announce family into subagents/announce/

* refactor(agents): move completion and main-session recovery families

* refactor(agents): move registry family into subagents/registry

* refactor(agents): update registry state type imports

* style(agents): format moved subagent imports

* test(agents): preserve isolated registry test routing

* fix(scripts): recognize relocated subagent announce seams
2026-08-10 16:06:39 -07:00

1002 lines
34 KiB
TypeScript

#!/usr/bin/env node
import fs from "node:fs/promises";
import path from "node:path";
import ts from "typescript";
import { z } from "zod";
import { resolveRepoRoot } from "./lib/repo-root.mjs";
import {
collectFileViolations,
getPropertyNameText,
resolveSourceRoots,
runAsScript,
toLine,
unwrapExpression,
} from "./lib/ts-guard-utils.mts";
const legacyReaderNames = new Set([
"loadSessionStore",
"readSessionEntries",
"readSessionEntry",
"readSessionStoreReadOnly",
"readSessionStoreSnapshot",
"resolveSessionStoreEntry",
]);
const legacyWholeStoreAccessNames = new Set([
...legacyReaderNames,
"saveSessionStore",
"updateSessionStore",
]);
const legacyWriterNames = new Set([
"applySessionStoreEntryPatch",
"recordSessionMetaFromInbound",
"saveSessionStore",
"updateLastRoute",
"updateSessionStore",
"updateSessionStoreEntry",
]);
const legacyTranscriptWriterNames = new Set([
"appendSessionTranscriptMessage",
"emitSessionTranscriptUpdate",
]);
const sessionCreateLifecycleWriterNames = new Set([
"applySessionStoreEntryPatch",
"saveSessionStore",
"updateSessionStore",
"updateSessionStoreEntry",
"ensureSessionTranscriptFile",
]);
const legacyManualCompactTrimNames = new Set([
"archiveFileOnDisk",
"readRecentSessionTranscriptLines",
]);
const legacyLifecycleCleanupNames = new Set([
"archiveRemovedSessionTranscripts",
"cleanupArchivedSessionTranscripts",
]);
const sessionStoreRuntimeFileBackedCompatNames = new Set([
"loadSessionStore",
"readSessionEntries",
"readSessionEntry",
"readLatestAssistantTextFromSessionTranscript",
"readSessionStoreReadOnly",
"resolveAndPersistSessionFile",
"resolveSessionFilePath",
"resolveSessionStoreEntry",
"saveSessionStore",
"updateSessionStore",
]);
const embeddedAgentSessionFileRuntimeNames = new Set(["resolveSessionFilePath"]);
const materializingSessionEntryAccessorNames = new Set(["listSessionEntries", "loadSessionEntry"]);
// Shipped beta.5 official plugins import these deprecated helpers during
// doctor migrations. Remove this ratchet with the compatibility bridge once
// beta.5 is outside the supported upgrade window; do not add runtime callers.
export const allowedSessionStoreRuntimeFileBackedCompatExports = new Set([
"loadSessionStore",
"resolveSessionFilePath",
"resolveSessionStoreEntry",
"updateSessionStore",
]);
const gatewaySessionServerMethodFiles = [
"src/gateway/server-methods/sessions-abort.ts",
"src/gateway/server-methods/sessions-compact.ts",
"src/gateway/server-methods/sessions-compaction-checkpoints.ts",
"src/gateway/server-methods/sessions-compaction-queries.ts",
"src/gateway/server-methods/sessions-compaction-runner.ts",
"src/gateway/server-methods/sessions-create.ts",
"src/gateway/server-methods/sessions-delete.ts",
"src/gateway/server-methods/sessions-dispatch.ts",
"src/gateway/server-methods/sessions-groups.ts",
"src/gateway/server-methods/sessions-messaging.ts",
"src/gateway/server-methods/sessions-mutations.ts",
"src/gateway/server-methods/sessions-read.ts",
"src/gateway/server-methods/sessions-shared.ts",
"src/gateway/server-methods/sessions-subscriptions.ts",
];
export const migratedSessionAccessorFiles = new Set([
"packages/memory-host-sdk/src/host/session-files.ts",
"src/acp/control-plane/manager.background-task.ts",
"src/acp/control-plane/manager.core.ts",
"src/acp/runtime/session-meta.ts",
"src/agents/subagents/spawn/acp-spawn.ts",
"src/agents/auth-profiles/session-override.ts",
"src/agents/embedded-agent-runner/compaction-successor-transcript.ts",
"src/agents/embedded-agent-runner/run/attempt.ts",
"src/agents/embedded-agent-runner/tool-result-truncation.ts",
"src/agents/embedded-agent-runner/transcript-rewrite.ts",
"src/agents/embedded-agent-runner/transcript-runtime-state.ts",
"src/agents/live-model-switch.ts",
"src/agents/subagents/registry/subagent-control.ts",
"src/agents/subagents/registry/subagent-registry-helpers.ts",
"src/auto-reply/reply/abort.ts",
"src/auto-reply/reply/agent-runner-helpers.ts",
"src/auto-reply/reply/agent-runner.ts",
"src/auto-reply/reply/commands-subagents/action-info.ts",
"src/auto-reply/reply/followup-runner.ts",
"src/auto-reply/reply/queue/drain.ts",
"src/commands/export-trajectory.ts",
"src/commands/health.ts",
"src/commands/sandbox-explain.ts",
"src/commands/sessions-tail.ts",
"src/commands/sessions.ts",
"src/commands/status.agent-local.ts",
"src/status/summary.ts",
"src/commands/tasks.ts",
"src/config/sessions/combined-store-gateway.ts",
"src/config/sessions/delivery-info.ts",
"src/config/sessions/goals.ts",
"src/cron/isolated-agent/delivery-target.ts",
"src/cron/service/timer.ts",
"src/gateway/session-compaction-checkpoints.ts",
"src/gateway/session-history-state.ts",
"src/gateway/sessions-history-http.ts",
"src/gateway/session-utils.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/boot.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/sessions-resolve.ts",
"src/gateway/server-methods/sessions-files.ts",
...gatewaySessionServerMethodFiles,
"src/gateway/server-session-events.ts",
"src/gateway/session-reset-service.ts",
"src/infra/outbound/message-action-tts.ts",
"src/agents/tools/embedded-gateway-stub.ts",
"src/agents/tools/session-status-tool.ts",
"src/agents/tools/sessions-list-tool.ts",
"src/plugins/host-hook-state.ts",
"src/status/status-message.ts",
"src/tui/embedded-backend.ts",
]);
export const migratedBundledPluginSessionAccessorFiles = new Set([
"extensions/codex/src/conversation-binding.ts",
"extensions/discord/src/monitor/native-command-model-picker-ui.ts",
"extensions/discord/src/monitor/native-command-model-picker-apply.ts",
"extensions/discord/src/monitor/thread-session-close.ts",
"extensions/feishu/src/reasoning-preview.ts",
"extensions/memory-core/src/dreaming-phases.ts",
"extensions/memory-core/src/dreaming-narrative.ts",
"extensions/mattermost/src/mattermost/model-picker.ts",
"extensions/matrix/src/matrix/monitor/handler.ts",
"extensions/matrix/src/session-route.ts",
"extensions/qqbot/src/engine/group/activation.ts",
"extensions/slack/src/monitor/slash.ts",
"extensions/telegram/src/bot-core.ts",
"extensions/telegram/src/bot-handlers.runtime.ts",
"extensions/telegram/src/bot.ts",
"extensions/telegram/src/bot-message-dispatch.ts",
"extensions/telegram/src/bot-native-commands.ts",
"extensions/voice-call/src/response-generator.ts",
"extensions/whatsapp/src/auto-reply/monitor/group-activation.ts",
]);
export const migratedEmbeddedAgentSessionTargetFiles = new Set([
"extensions/voice-call/src/response-generator.ts",
]);
export const migratedSessionAccessorWriteFiles = new Set([
"src/acp/runtime/session-meta.ts",
"src/agents/auth-profiles/session-override.ts",
"src/agents/command/attempt-execution.shared.ts",
"src/agents/command/session-store.ts",
"src/agents/embedded-agent-runner/run.ts",
"src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts",
"src/agents/embedded-agent-runner/run/attempt.ts",
"src/agents/live-model-switch.ts",
"src/agents/main-session-recovery/main-session-restart-recovery-checkpoint.ts",
"src/agents/main-session-recovery/main-session-restart-recovery-marking.ts",
"src/agents/main-session-recovery/main-session-restart-recovery-store.ts",
"src/agents/session-suspension.ts",
"src/auto-reply/reply/abort.ts",
"src/agents/subagents/registry/subagent-control.ts",
"src/agents/subagents/registry/subagent-registry-helpers.ts",
"src/agents/tools/session-status-tool.ts",
"src/auto-reply/reply/abort-cutoff.runtime.ts",
"src/auto-reply/reply/agent-runner-cli-dispatch.ts",
"src/auto-reply/reply/agent-runner-execution.ts",
"src/auto-reply/reply/agent-runner-memory.ts",
"src/auto-reply/reply/agent-runner-session-reset.ts",
"src/auto-reply/reply/agent-runner.ts",
"src/auto-reply/reply/body.ts",
"src/auto-reply/reply/commands-acp/lifecycle.ts",
"src/auto-reply/reply/commands-reset.ts",
"src/auto-reply/reply/commands-session-store.ts",
"src/auto-reply/reply/directive-handling.impl.ts",
"src/auto-reply/reply/directive-handling.persist.ts",
"src/auto-reply/reply/dispatch-from-config.runtime.ts",
"src/auto-reply/reply/followup-runner.ts",
"src/auto-reply/reply/get-reply.ts",
"src/auto-reply/reply/model-selection.ts",
"src/auto-reply/reply/session.ts",
"src/auto-reply/reply/session-reset-model.ts",
"src/auto-reply/reply/session-updates.ts",
"src/auto-reply/reply/session-usage.ts",
"src/commands/tasks.ts",
"src/config/sessions/cleanup-service.ts",
"src/config/sessions/goals.ts",
"src/gateway/boot.ts",
"src/gateway/server-methods/chat.ts",
...gatewaySessionServerMethodFiles,
"src/gateway/server-node-events.ts",
"src/gateway/session-compaction-checkpoints.ts",
"src/infra/outbound/outbound-session.ts",
"src/plugins/host-hook-cleanup.ts",
"src/plugins/host-hook-state.ts",
"src/plugins/runtime/runtime-channel.ts",
"src/tui/embedded-backend.ts",
]);
export const migratedTranscriptWriterFiles = new Set([
"src/agents/command/attempt-execution.ts",
"src/agents/embedded-agent-runner/context-engine-maintenance.ts",
"src/auto-reply/reply/session-fork.runtime.ts",
"src/config/sessions/transcript.ts",
"src/gateway/server-methods/chat.ts",
"src/gateway/server-methods/chat-transcript-inject.ts",
"src/sessions/user-turn-transcript.ts",
]);
export const migratedSessionCompactManualTrimFiles = new Set([
"src/gateway/server-methods/sessions-compact.ts",
]);
export const migratedSessionLifecycleCleanupFiles = new Set([
"src/config/sessions/cleanup-service.ts",
"src/cron/session-reaper.ts",
"src/infra/heartbeat-runner.ts",
]);
export const readOnlyGatewaySessionAccessorFiles = new Set([
"src/gateway/approval-session-audience.ts",
"src/gateway/control-ui-session-prs.ts",
"src/gateway/managed-image-attachments.ts",
"src/gateway/mcp-app-reconstruction.ts",
"src/gateway/server-chat.ts",
"src/gateway/server-methods/artifacts.ts",
"src/gateway/server-methods/chat-history-handler.ts",
"src/gateway/server-methods/chat-message-get-handler.ts",
"src/gateway/server-methods/sessions-diff.ts",
"src/gateway/server-methods/sessions-files.ts",
"src/gateway/server-methods/sessions-read.ts",
"src/gateway/server-methods/sessions-subscriptions.ts",
"src/gateway/server-methods/task-suggestions.ts",
"src/gateway/server-methods/tools-effective.ts",
"src/gateway/server-methods/usage.ts",
"src/gateway/server-session-events.ts",
]);
export const migratedMemoryHostSessionCorpusFiles = new Set([
"packages/memory-host-sdk/src/host/session-files.ts",
"packages/memory-host-sdk/src/host/session-transcript-corpus.ts",
]);
const memoryHostSessionCorpusFunctionNames = new Set([
"listSessionTranscriptCorpusEntriesForAgentSync",
"listSessionTranscriptCorpusEntriesForAgent",
"loadDreamingNarrativeTranscriptPathSetForAgent",
"loadSessionTranscriptClassificationForAgent",
"listSessionFilesForAgent",
]);
const legacyMemoryHostSessionCorpusNames = new Set([
"loadSessionTranscriptClassificationForSessionsDir",
"readSessionTranscriptClassificationStore",
]);
type BoundaryViolation = { line: number; reason: string };
const sessionAccessorDebtCountsSchema = z.record(z.string(), z.record(z.string(), z.number()));
type SessionAccessorDebtCounts = z.infer<typeof sessionAccessorDebtCountsSchema>;
function normalizeRelativePath(filePath: string) {
return filePath.replaceAll(path.sep, "/");
}
function legacyNamesForFile(fileName: string) {
const normalized = normalizeRelativePath(fileName);
if (
fileName === "source.ts" ||
[...migratedBundledPluginSessionAccessorFiles].some((filePath) => normalized.endsWith(filePath))
) {
return legacyWholeStoreAccessNames;
}
return legacyReaderNames;
}
function propertyAccessName(expression: ts.Expression) {
const unwrapped = unwrapExpression(expression);
if (ts.isIdentifier(unwrapped)) {
return unwrapped.text;
}
if (ts.isPropertyAccessExpression(unwrapped)) {
return unwrapped.name.text;
}
if (ts.isElementAccessExpression(unwrapped) && ts.isStringLiteral(unwrapped.argumentExpression)) {
return unwrapped.argumentExpression.text;
}
return null;
}
function bindingName(node: ts.BindingElement) {
if (node.propertyName && ts.isIdentifier(node.propertyName)) {
return node.propertyName.text;
}
if (ts.isIdentifier(node.name)) {
return node.name.text;
}
return null;
}
function findNamedBoundaryViolations(
content: string,
fileName: string,
legacyNames: ReadonlySet<string>,
subject: string,
) {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations: BoundaryViolation[] = [];
const addViolation = (node: ts.Node, action: string, name: string) => {
violations.push({
line: toLine(sourceFile, node),
reason: `${action} ${subject} "${name}"`,
});
};
const visit = (node: ts.Node) => {
if (ts.isImportDeclaration(node)) {
const namedBindings = node.importClause?.namedBindings;
if (namedBindings && ts.isNamedImports(namedBindings)) {
for (const specifier of namedBindings.elements) {
const importedName = specifier.propertyName?.text ?? specifier.name.text;
if (legacyNames.has(importedName)) {
addViolation(specifier, "imports", importedName);
}
}
}
}
if (ts.isBindingElement(node)) {
const name = bindingName(node);
if (name && legacyNames.has(name)) {
addViolation(node, "aliases", name);
}
}
if (ts.isPropertyAccessExpression(node) && legacyNames.has(node.name.text)) {
addViolation(node.name, "references", node.name.text);
}
if (
ts.isElementAccessExpression(node) &&
ts.isStringLiteral(node.argumentExpression) &&
legacyNames.has(node.argumentExpression.text)
) {
addViolation(node.argumentExpression, "references", node.argumentExpression.text);
}
if (ts.isCallExpression(node)) {
const calleeName = propertyAccessName(node.expression);
if (
calleeName &&
legacyNames.has(calleeName) &&
ts.isIdentifier(unwrapExpression(node.expression))
) {
addViolation(node.expression, "calls", calleeName);
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
function findNamedSessionStoreViolations(
content: string,
fileName: string,
legacyNames: ReadonlySet<string>,
legacyKind: string,
) {
return findNamedBoundaryViolations(
content,
fileName,
legacyNames,
`legacy session store ${legacyKind}`,
);
}
export function collectSessionStoreRuntimeFileBackedCompatExports(
content: string,
fileName = "source.ts",
) {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const exports = new Map<string, { line: number; sourceName: string }>();
const rememberExport = (node: ts.Node, exportedName: string, sourceName = exportedName) => {
if (!sessionStoreRuntimeFileBackedCompatNames.has(sourceName)) {
return;
}
exports.set(exportedName, {
line: toLine(sourceFile, node),
sourceName,
});
};
for (const statement of sourceFile.statements) {
const isExported =
ts.canHaveModifiers(statement) &&
ts.getModifiers(statement)?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword);
if (isExported && ts.isVariableStatement(statement)) {
for (const declaration of statement.declarationList.declarations) {
if (ts.isIdentifier(declaration.name)) {
rememberExport(declaration.name, declaration.name.text);
}
}
continue;
}
if (isExported && ts.isFunctionDeclaration(statement) && statement.name) {
rememberExport(statement.name, statement.name.text);
continue;
}
if (
ts.isExportDeclaration(statement) &&
statement.exportClause &&
ts.isNamedExports(statement.exportClause)
) {
for (const specifier of statement.exportClause.elements) {
rememberExport(
specifier,
specifier.name.text,
specifier.propertyName?.text ?? specifier.name.text,
);
}
}
}
return exports;
}
export function findSessionStoreRuntimeFileBackedCompatExportViolations(
content: string,
fileName = "source.ts",
) {
const exports = collectSessionStoreRuntimeFileBackedCompatExports(content, fileName);
const violations: BoundaryViolation[] = [];
for (const [exportedName, exported] of exports) {
if (
exportedName !== exported.sourceName ||
!allowedSessionStoreRuntimeFileBackedCompatExports.has(exportedName)
) {
violations.push({
line: exported.line,
reason: `exports unratcheted file-backed SDK session helper "${exported.sourceName}"`,
});
}
}
return violations;
}
export function findSessionAccessorBoundaryViolations(content: string, fileName = "source.ts") {
const legacyNames = legacyNamesForFile(fileName);
const legacyKind = legacyNames === legacyWholeStoreAccessNames ? "access" : "reader";
return findNamedSessionStoreViolations(content, fileName, legacyNames, legacyKind);
}
export function findReadOnlySessionAccessorViolations(content: string, fileName = "source.ts") {
return findNamedBoundaryViolations(
content,
fileName,
materializingSessionEntryAccessorNames,
"materializing session entry accessor",
);
}
export function findEmbeddedAgentSessionTargetViolations(content: string, fileName = "source.ts") {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations = findNamedBoundaryViolations(
content,
fileName,
embeddedAgentSessionFileRuntimeNames,
"legacy embedded-agent session file resolver",
);
const recordDeprecatedSessionFile = (name: ts.Node) => {
violations.push({
line: toLine(sourceFile, name),
reason:
'passes deprecated embedded-agent runtime identity field "sessionFile"; use sessionTarget',
});
};
const visitRunOptions = (options: ts.ObjectLiteralExpression) => {
for (const property of options.properties) {
if (
ts.isPropertyAssignment(property) &&
getPropertyNameText(property.name) === "sessionFile"
) {
recordDeprecatedSessionFile(property.name);
} else if (
ts.isShorthandPropertyAssignment(property) &&
property.name.text === "sessionFile"
) {
recordDeprecatedSessionFile(property.name);
}
}
};
const visit = (node: ts.Node) => {
if (ts.isCallExpression(node) && propertyAccessName(node.expression) === "runEmbeddedAgent") {
const firstArgument = node.arguments[0];
const options = firstArgument ? unwrapExpression(firstArgument) : undefined;
if (options && ts.isObjectLiteralExpression(options)) {
visitRunOptions(options);
}
return;
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
export function findSessionAccessorWriteBoundaryViolations(
content: string,
fileName = "source.ts",
) {
return findNamedSessionStoreViolations(content, fileName, legacyWriterNames, "writer");
}
export function findTranscriptWriterBoundaryViolations(content: string, fileName = "source.ts") {
return findNamedBoundaryViolations(
content,
fileName,
legacyTranscriptWriterNames,
"legacy transcript writer",
);
}
export function findGatewaySessionCreateLifecycleViolations(
content: string,
fileName = "source.ts",
) {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const violations: BoundaryViolation[] = [];
const visitCreateHandler = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
const calleeName = propertyAccessName(node.expression);
if (calleeName && sessionCreateLifecycleWriterNames.has(calleeName)) {
violations.push({
line: toLine(sourceFile, node.expression),
reason: `calls legacy sessions.create lifecycle writer "${calleeName}"`,
});
}
}
ts.forEachChild(node, visitCreateHandler);
};
const visit = (node: ts.Node) => {
if (
ts.isPropertyAssignment(node) &&
ts.isStringLiteralLike(node.name) &&
node.name.text === "sessions.create"
) {
visitCreateHandler(node.initializer);
return;
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
export function findSessionCompactManualTrimBoundaryViolations(
content: string,
fileName = "source.ts",
) {
return findNamedSessionStoreViolations(
content,
fileName,
legacyManualCompactTrimNames,
"manual compact trim",
);
}
export function findSessionLifecycleCleanupBoundaryViolations(
content: string,
fileName = "source.ts",
) {
return findNamedSessionStoreViolations(
content,
fileName,
legacyLifecycleCleanupNames,
"lifecycle cleanup",
);
}
// Source roots shared by the enforced boundary checks in main() and the debt
// ratchet below; keeping one list prevents the two scans from drifting apart.
const readSourceRootPaths = [
"packages/memory-host-sdk/src/host",
"extensions/discord/src/monitor",
"extensions/memory-core/src",
"extensions/telegram/src",
"extensions/voice-call/src",
"src/acp",
"src/agents",
"src/auto-reply",
"src/commands",
"src/config/sessions",
"src/cron",
"src/gateway",
"src/infra",
"src/plugins",
"src/tui",
];
const writeSourceRootPaths = [
"src/acp",
"src/agents",
"src/auto-reply",
"src/commands",
"src/config/sessions",
"src/gateway",
"src/infra",
"src/plugins",
"src/tui",
];
const transcriptWriterSourceRootPaths = [
"src/agents/command",
"src/agents/embedded-agent-runner",
"src/auto-reply/reply",
"src/config/sessions",
"src/gateway/server-methods",
"src/sessions",
];
function collectTopLevelFunctionBodies(sourceFile: ts.SourceFile) {
const bodies = new Map<string, ts.ConciseBody>();
for (const statement of sourceFile.statements) {
if (ts.isFunctionDeclaration(statement)) {
if (statement.name && statement.body) {
bodies.set(statement.name.text, statement.body);
}
continue;
}
if (!ts.isVariableStatement(statement)) {
continue;
}
const declaration = statement.declarationList.declarations[0];
const initializer = declaration?.initializer;
if (
declaration &&
ts.isIdentifier(declaration.name) &&
initializer &&
(ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))
) {
bodies.set(declaration.name.text, initializer.body);
}
}
return bodies;
}
export function findMemoryHostSessionCorpusBoundaryViolations(
content: string,
fileName = "source.ts",
) {
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
const functionBodies = collectTopLevelFunctionBodies(sourceFile);
const visitedFunctions = new Set<string>();
const violationKeys = new Set<string>();
const violations: BoundaryViolation[] = [];
const visitCorpusBody = (node: ts.Node) => {
if (ts.isCallExpression(node)) {
const calleeName = propertyAccessName(node.expression);
if (calleeName && legacyMemoryHostSessionCorpusNames.has(calleeName)) {
const line = toLine(sourceFile, node.expression);
const reason = `calls legacy memory-host session corpus helper "${calleeName}"`;
const key = `${line}:${reason}`;
if (!violationKeys.has(key)) {
violationKeys.add(key);
violations.push({ line, reason });
}
}
if (calleeName && ts.isIdentifier(unwrapExpression(node.expression))) {
const localBody = functionBodies.get(calleeName);
if (localBody && !visitedFunctions.has(calleeName)) {
visitedFunctions.add(calleeName);
visitCorpusBody(localBody);
}
}
}
ts.forEachChild(node, visitCorpusBody);
};
for (const name of memoryHostSessionCorpusFunctionNames) {
const body = functionBodies.get(name);
if (!body || visitedFunctions.has(name)) {
continue;
}
visitedFunctions.add(name);
visitCorpusBody(body);
}
return violations;
}
// Debt ratchet: the boundary checks above only scan files already on the
// migrated lists, so unmigrated files could quietly gain new legacy call
// sites. The checked-in baseline locks each unmigrated file's current legacy
// call-site count per concern; any drift from the baseline fails the guard.
const sessionAccessorDebtBaselineRelativePath = "scripts/lib/session-accessor-debt-baseline.json";
const debtBaselineRegenCommand = "pnpm lint:tmp:session-accessor-boundary:gen";
// Keys sorted alphabetically so the generated baseline JSON stays deterministic.
const sessionAccessorDebtConcerns = {
embeddedAgentSessionTarget: {
sourceRootPaths: ["extensions/voice-call/src"],
migratedFiles: migratedEmbeddedAgentSessionTargetFiles,
findViolations: findEmbeddedAgentSessionTargetViolations,
},
memoryHostSessionCorpus: {
sourceRootPaths: ["packages/memory-host-sdk/src/host"],
migratedFiles: migratedMemoryHostSessionCorpusFiles,
findViolations: findMemoryHostSessionCorpusBoundaryViolations,
},
sessionAccessorRead: {
sourceRootPaths: readSourceRootPaths,
migratedFiles: new Set([
...migratedSessionAccessorFiles,
...migratedBundledPluginSessionAccessorFiles,
]),
findViolations: findSessionAccessorBoundaryViolations,
},
sessionAccessorWrite: {
sourceRootPaths: writeSourceRootPaths,
migratedFiles: migratedSessionAccessorWriteFiles,
findViolations: findSessionAccessorWriteBoundaryViolations,
},
sessionCompactManualTrim: {
sourceRootPaths: ["src/gateway/server-methods"],
migratedFiles: migratedSessionCompactManualTrimFiles,
findViolations: findSessionCompactManualTrimBoundaryViolations,
},
sessionLifecycleCleanup: {
sourceRootPaths: readSourceRootPaths,
migratedFiles: migratedSessionLifecycleCleanupFiles,
findViolations: findSessionLifecycleCleanupBoundaryViolations,
},
transcriptWriter: {
sourceRootPaths: transcriptWriterSourceRootPaths,
migratedFiles: migratedTranscriptWriterFiles,
findViolations: findTranscriptWriterBoundaryViolations,
},
};
function sortRecordByKey<Value>(record: Record<string, Value>) {
return Object.fromEntries(
Object.entries(record).toSorted(([left], [right]) =>
left < right ? -1 : left > right ? 1 : 0,
),
);
}
/** Counts legacy call sites per unmigrated file for every debt concern. */
async function collectSessionAccessorDebtCounts(repoRoot: string) {
const counts: SessionAccessorDebtCounts = {};
for (const [key, concern] of Object.entries(sessionAccessorDebtConcerns)) {
const violations = await collectFileViolations({
repoRoot,
sourceRoots: resolveSourceRoots(repoRoot, concern.sourceRootPaths),
// Inverse of the enforcement skip: migrated files are held at zero by the
// boundary checks, so the ratchet tracks only the unmigrated rest.
skipFile: (filePath) =>
concern.migratedFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
findViolations: concern.findViolations,
});
const fileCounts: Record<string, number> = {};
for (const violation of violations) {
const relativePath = normalizeRelativePath(violation.path);
fileCounts[relativePath] = (fileCounts[relativePath] ?? 0) + 1;
}
counts[key] = sortRecordByKey(fileCounts);
}
return sortRecordByKey(counts);
}
/** Ratchet compare: counts above baseline are regressions, below are improvements. */
export function compareSessionAccessorDebt(
currentCounts: SessionAccessorDebtCounts,
baselineCounts: SessionAccessorDebtCounts,
) {
const regressions = [];
const improvements = [];
const concerns = [
...new Set([...Object.keys(baselineCounts), ...Object.keys(currentCounts)]),
].toSorted();
for (const concern of concerns) {
const current = currentCounts[concern] ?? {};
const baseline = baselineCounts[concern] ?? {};
const filePaths = [...new Set([...Object.keys(baseline), ...Object.keys(current)])].toSorted();
for (const filePath of filePaths) {
const currentCount = current[filePath] ?? 0;
const baselineCount = baseline[filePath] ?? 0;
if (currentCount === baselineCount) {
continue;
}
const entry = { concern, path: filePath, currentCount, baselineCount };
if (currentCount > baselineCount) {
regressions.push(entry);
} else {
improvements.push(entry);
}
}
}
return { regressions, improvements };
}
type DebtComparison = ReturnType<typeof compareSessionAccessorDebt>;
// Improvements fail the guard too: passing silently would leave the baseline
// stale, letting a later change reintroduce legacy call sites up to the old
// count without tripping the ratchet.
export function formatSessionAccessorDebtImprovements(
improvements: DebtComparison["improvements"],
) {
return [
`Legacy session accessor debt dropped below ${sessionAccessorDebtBaselineRelativePath}:`,
...improvements.map(
(improvement) =>
`- ${improvement.path} [${improvement.concern}]: ${improvement.currentCount} legacy call site(s), stale baseline allows ${improvement.baselineCount}`,
),
`Run \`${debtBaselineRegenCommand}\` to ratchet the baseline down and commit it.`,
];
}
function resolveDebtBaselinePath(repoRoot: string) {
return path.join(repoRoot, ...sessionAccessorDebtBaselineRelativePath.split("/"));
}
async function readSessionAccessorDebtBaseline(repoRoot: string) {
try {
return sessionAccessorDebtCountsSchema.parse(
JSON.parse(await fs.readFile(resolveDebtBaselinePath(repoRoot), "utf8")),
);
} catch (error) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
async function writeSessionAccessorDebtBaseline(repoRoot: string) {
const counts = await collectSessionAccessorDebtCounts(repoRoot);
await fs.writeFile(resolveDebtBaselinePath(repoRoot), `${JSON.stringify(counts, null, 2)}\n`);
}
function compareMigratedFilePaths(left: string, right: string, sourceRootPaths: string[]) {
const rootIndex = (filePath: string) =>
sourceRootPaths.findIndex((sourceRoot) => filePath.startsWith(`${sourceRoot}/`));
const leftRoot = rootIndex(left);
const rightRoot = rootIndex(right);
const rootOrder =
(leftRoot < 0 ? sourceRootPaths.length : leftRoot) -
(rightRoot < 0 ? sourceRootPaths.length : rightRoot);
if (rootOrder !== 0) {
return rootOrder;
}
// Recursive directory walks visit nested files before same-prefix sibling files.
const leftTraversalPath = left.replaceAll("/", "\0");
const rightTraversalPath = right.replaceAll("/", "\0");
return leftTraversalPath < rightTraversalPath
? -1
: leftTraversalPath > rightTraversalPath
? 1
: 0;
}
export async function main() {
const repoRoot = resolveRepoRoot(import.meta.url);
if (process.argv.includes("--update-debt-baseline")) {
await writeSessionAccessorDebtBaseline(repoRoot);
console.log(`Wrote ${sessionAccessorDebtBaselineRelativePath}`);
return;
}
const enforcementConcerns = [
sessionAccessorDebtConcerns.sessionAccessorRead,
sessionAccessorDebtConcerns.sessionAccessorWrite,
sessionAccessorDebtConcerns.transcriptWriter,
{
sourceRootPaths: ["src/gateway/server-methods"],
migratedFiles: new Set(["src/gateway/server-methods/sessions-create.ts"]),
findViolations: findGatewaySessionCreateLifecycleViolations,
},
sessionAccessorDebtConcerns.sessionCompactManualTrim,
sessionAccessorDebtConcerns.sessionLifecycleCleanup,
sessionAccessorDebtConcerns.memoryHostSessionCorpus,
sessionAccessorDebtConcerns.embeddedAgentSessionTarget,
{
sourceRootPaths: ["src/gateway"],
migratedFiles: readOnlyGatewaySessionAccessorFiles,
findViolations: findReadOnlySessionAccessorViolations,
},
];
const violations: Array<BoundaryViolation & { path: string }> = [];
for (const concern of enforcementConcerns) {
violations.push(
...(await collectFileViolations({
repoRoot,
sourceRoots: resolveSourceRoots(
repoRoot,
[...concern.migratedFiles].toSorted((left, right) =>
compareMigratedFilePaths(left, right, concern.sourceRootPaths),
),
),
findViolations: concern.findViolations,
})),
);
}
const sessionStoreRuntimePath = path.join(repoRoot, "src/plugin-sdk/session-store-runtime.ts");
violations.push(
...findSessionStoreRuntimeFileBackedCompatExportViolations(
await fs.readFile(sessionStoreRuntimePath, "utf8"),
sessionStoreRuntimePath,
).map((violation) =>
Object.assign({ path: "src/plugin-sdk/session-store-runtime.ts" }, violation),
),
);
const baselineCounts = await readSessionAccessorDebtBaseline(repoRoot);
if (!baselineCounts) {
console.error(
`Missing ${sessionAccessorDebtBaselineRelativePath}; run \`${debtBaselineRegenCommand}\` and commit it.`,
);
process.exit(1);
}
const debt = compareSessionAccessorDebt(
await collectSessionAccessorDebtCounts(repoRoot),
baselineCounts,
);
if (violations.length === 0 && debt.regressions.length === 0 && debt.improvements.length === 0) {
console.log("session accessor boundary guard passed.");
return;
}
if (violations.length > 0) {
console.error("Found legacy session store usage in session-accessor migrated files:");
for (const violation of violations) {
console.error(`- ${violation.path}:${violation.line}: ${violation.reason}`);
}
console.error(
"Use src/config/sessions/session-accessor.ts helpers for migrated read/write and transcript-writer paths. Expand file-backed SDK compatibility only as an explicit pre-SQLite migration decision.",
);
}
if (debt.regressions.length > 0) {
console.error(
`Found new legacy session call sites in unmigrated files (counts exceed ${sessionAccessorDebtBaselineRelativePath}):`,
);
for (const regression of debt.regressions) {
console.error(
`- ${regression.path} [${regression.concern}]: ${regression.currentCount} legacy call site(s), baseline allows ${regression.baselineCount}`,
);
}
console.error(
`Use src/config/sessions/session-accessor.ts helpers instead of adding legacy call sites. If the increase is an intentional seam-owner change, run \`${debtBaselineRegenCommand}\` and commit the updated baseline.`,
);
}
if (debt.improvements.length > 0) {
for (const line of formatSessionAccessorDebtImprovements(debt.improvements)) {
console.error(line);
}
}
process.exit(1);
}
runAsScript(import.meta.url, main);