openclaw/scripts/check-session-accessor-boundary.mts
Peter Steinberger b576bb41cf
fix(usage): retain reset history and unify accounting (#131667)
* fix(usage): retain reset history and unify accounting

Keep family selection within the recorded agent and assign each instance once.
Carry discovered SQLite or JSONL sources into batched loading, including empty
current sessions and history encountered before a current artifact.

Share Gateway and Control UI aggregation and total arithmetic, use canonical
pricing for detail logs, and invalidate both result caches on config changes.
Split calendar, cache, selection, and loading owners out of the usage handler.

Add failing-before regressions for reset history, overlapping families, mixed
sources, config replacement, missing-price exports, and log pricing.

* refactor(usage): keep accounting type imports acyclic

Make batched loading own its minimal input contract and import shared accounting
types from the leaf definition instead of the runtime barrel. Remove obsolete
current-JSONL fixture setup to keep the existing usage test within its line cap.

Verified both CI failures locally: zero Madge cycles and passing core lint with
the CI tsconfig. No runtime behavior or public protocol changes.

* refactor(usage): remove unused accounting type re-exports

The shared usage contracts now import their canonical leaf definitions. Remove
the six obsolete internal-barrel type re-exports left by that ownership repair.
2026-08-28 03:00:19 -07:00

1005 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",
"resolveSessionStoreEntryCore",
]);
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([
"listSessionEntriesCore",
"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/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-session-selection.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);