mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-07 19:48:31 +08:00
970 lines
33 KiB
JavaScript
970 lines
33 KiB
JavaScript
#!/usr/bin/env node
|
|
|
|
import fs from "node:fs/promises";
|
|
import path from "node:path";
|
|
import ts from "typescript";
|
|
import {
|
|
collectFileViolations,
|
|
resolveRepoRoot,
|
|
resolveSourceRoots,
|
|
runAsScript,
|
|
toLine,
|
|
unwrapExpression,
|
|
} from "./lib/ts-guard-utils.mjs";
|
|
|
|
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"]);
|
|
|
|
export const allowedSessionStoreRuntimeFileBackedCompatExports = new Set([
|
|
"loadSessionStore",
|
|
"readLatestAssistantTextFromSessionTranscript",
|
|
"resolveAndPersistSessionFile",
|
|
"resolveSessionFilePath",
|
|
"resolveSessionStoreEntry",
|
|
"saveSessionStore",
|
|
"updateSessionStore",
|
|
]);
|
|
|
|
export const migratedSessionAccessorFiles = new Set([
|
|
"packages/memory-host-sdk/src/host/session-files.ts",
|
|
"src/acp/runtime/session-meta.ts",
|
|
"src/agents/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/subagent-control.ts",
|
|
"src/agents/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/commands/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",
|
|
"src/gateway/server-methods/sessions.ts",
|
|
"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-restart-recovery.ts",
|
|
"src/auto-reply/reply/abort.ts",
|
|
"src/agents/subagent-control.ts",
|
|
"src/agents/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/sessions.ts",
|
|
"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.ts",
|
|
]);
|
|
|
|
export const migratedSessionLifecycleCleanupFiles = new Set([
|
|
"src/config/sessions/cleanup-service.ts",
|
|
"src/cron/session-reaper.ts",
|
|
"src/infra/heartbeat-runner.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",
|
|
]);
|
|
|
|
function normalizeRelativePath(filePath) {
|
|
return filePath.replaceAll(path.sep, "/");
|
|
}
|
|
|
|
function legacyNamesForFile(fileName) {
|
|
const normalized = normalizeRelativePath(fileName);
|
|
if (
|
|
fileName === "source.ts" ||
|
|
[...migratedBundledPluginSessionAccessorFiles].some((filePath) => normalized.endsWith(filePath))
|
|
) {
|
|
return legacyWholeStoreAccessNames;
|
|
}
|
|
return legacyReaderNames;
|
|
}
|
|
|
|
function propertyAccessName(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 propertyNameText(name) {
|
|
if (ts.isIdentifier(name) || ts.isStringLiteralLike(name) || ts.isNumericLiteral(name)) {
|
|
return name.text;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function bindingName(node) {
|
|
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, fileName, legacyNames, subject) {
|
|
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
|
const violations = [];
|
|
|
|
const visit = (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)) {
|
|
violations.push({
|
|
line: toLine(sourceFile, specifier),
|
|
reason: `imports ${subject} "${importedName}"`,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (ts.isBindingElement(node)) {
|
|
const name = bindingName(node);
|
|
if (name && legacyNames.has(name)) {
|
|
violations.push({
|
|
line: toLine(sourceFile, node),
|
|
reason: `aliases ${subject} "${name}"`,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (ts.isPropertyAccessExpression(node) && legacyNames.has(node.name.text)) {
|
|
violations.push({
|
|
line: toLine(sourceFile, node.name),
|
|
reason: `references ${subject} "${node.name.text}"`,
|
|
});
|
|
}
|
|
|
|
if (
|
|
ts.isElementAccessExpression(node) &&
|
|
ts.isStringLiteral(node.argumentExpression) &&
|
|
legacyNames.has(node.argumentExpression.text)
|
|
) {
|
|
violations.push({
|
|
line: toLine(sourceFile, node.argumentExpression),
|
|
reason: `references ${subject} "${node.argumentExpression.text}"`,
|
|
});
|
|
}
|
|
|
|
if (ts.isCallExpression(node)) {
|
|
const calleeName = propertyAccessName(node.expression);
|
|
if (
|
|
calleeName &&
|
|
legacyNames.has(calleeName) &&
|
|
ts.isIdentifier(unwrapExpression(node.expression))
|
|
) {
|
|
violations.push({
|
|
line: toLine(sourceFile, node.expression),
|
|
reason: `calls ${subject} "${calleeName}"`,
|
|
});
|
|
}
|
|
}
|
|
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
|
|
visit(sourceFile);
|
|
return violations;
|
|
}
|
|
|
|
function findNamedSessionStoreViolations(content, fileName, legacyNames, legacyKind) {
|
|
return findNamedBoundaryViolations(
|
|
content,
|
|
fileName,
|
|
legacyNames,
|
|
`legacy session store ${legacyKind}`,
|
|
);
|
|
}
|
|
|
|
export function collectSessionStoreRuntimeFileBackedCompatExports(content, fileName = "source.ts") {
|
|
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
|
const exports = new Map();
|
|
|
|
const rememberExport = (node, exportedName, sourceName = exportedName) => {
|
|
if (!sessionStoreRuntimeFileBackedCompatNames.has(sourceName)) {
|
|
return;
|
|
}
|
|
exports.set(exportedName, {
|
|
line: toLine(sourceFile, node),
|
|
sourceName,
|
|
});
|
|
};
|
|
|
|
for (const statement of sourceFile.statements) {
|
|
const isExported = statement.modifiers?.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,
|
|
fileName = "source.ts",
|
|
) {
|
|
const exports = collectSessionStoreRuntimeFileBackedCompatExports(content, fileName);
|
|
const violations = [];
|
|
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, fileName = "source.ts") {
|
|
const legacyNames = legacyNamesForFile(fileName);
|
|
const legacyKind = legacyNames === legacyWholeStoreAccessNames ? "access" : "reader";
|
|
return findNamedSessionStoreViolations(content, fileName, legacyNames, legacyKind);
|
|
}
|
|
|
|
export function findEmbeddedAgentSessionTargetViolations(content, 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) => {
|
|
violations.push({
|
|
line: toLine(sourceFile, name),
|
|
reason:
|
|
'passes deprecated embedded-agent runtime identity field "sessionFile"; use sessionTarget',
|
|
});
|
|
};
|
|
|
|
const visitRunOptions = (options) => {
|
|
for (const property of options.properties) {
|
|
if (ts.isPropertyAssignment(property) && propertyNameText(property.name) === "sessionFile") {
|
|
recordDeprecatedSessionFile(property.name);
|
|
} else if (
|
|
ts.isShorthandPropertyAssignment(property) &&
|
|
property.name.text === "sessionFile"
|
|
) {
|
|
recordDeprecatedSessionFile(property.name);
|
|
}
|
|
}
|
|
};
|
|
|
|
const visit = (node) => {
|
|
if (ts.isCallExpression(node) && propertyAccessName(node.expression) === "runEmbeddedAgent") {
|
|
const options = unwrapExpression(node.arguments[0]);
|
|
if (options && ts.isObjectLiteralExpression(options)) {
|
|
visitRunOptions(options);
|
|
}
|
|
return;
|
|
}
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
|
|
visit(sourceFile);
|
|
return violations;
|
|
}
|
|
|
|
export function findSessionAccessorWriteBoundaryViolations(content, fileName = "source.ts") {
|
|
return findNamedSessionStoreViolations(content, fileName, legacyWriterNames, "writer");
|
|
}
|
|
|
|
export function findTranscriptWriterBoundaryViolations(content, fileName = "source.ts") {
|
|
return findNamedBoundaryViolations(
|
|
content,
|
|
fileName,
|
|
legacyTranscriptWriterNames,
|
|
"legacy transcript writer",
|
|
);
|
|
}
|
|
|
|
export function findGatewaySessionCreateLifecycleViolations(content, fileName = "source.ts") {
|
|
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
|
const violations = [];
|
|
|
|
const visitCreateHandler = (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) => {
|
|
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, fileName = "source.ts") {
|
|
return findNamedSessionStoreViolations(
|
|
content,
|
|
fileName,
|
|
legacyManualCompactTrimNames,
|
|
"manual compact trim",
|
|
);
|
|
}
|
|
|
|
export function findSessionLifecycleCleanupBoundaryViolations(content, 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 declarationName(node) {
|
|
if (ts.isFunctionDeclaration(node) && node.name) {
|
|
return node.name.text;
|
|
}
|
|
if (!ts.isVariableStatement(node)) {
|
|
return null;
|
|
}
|
|
const declaration = node.declarationList.declarations[0];
|
|
return declaration && ts.isIdentifier(declaration.name) ? declaration.name.text : null;
|
|
}
|
|
|
|
function functionBodyForDeclaration(node) {
|
|
if (ts.isFunctionDeclaration(node)) {
|
|
return node.body ?? null;
|
|
}
|
|
if (!ts.isVariableStatement(node)) {
|
|
return null;
|
|
}
|
|
const declaration = node.declarationList.declarations[0];
|
|
const initializer = declaration?.initializer;
|
|
if (initializer && (ts.isArrowFunction(initializer) || ts.isFunctionExpression(initializer))) {
|
|
return initializer.body;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collectTopLevelFunctionBodies(sourceFile) {
|
|
const bodies = new Map();
|
|
for (const statement of sourceFile.statements) {
|
|
const name = declarationName(statement);
|
|
const body = functionBodyForDeclaration(statement);
|
|
if (name && body) {
|
|
bodies.set(name, body);
|
|
}
|
|
}
|
|
return bodies;
|
|
}
|
|
|
|
export function findMemoryHostSessionCorpusBoundaryViolations(content, fileName = "source.ts") {
|
|
const sourceFile = ts.createSourceFile(fileName, content, ts.ScriptTarget.Latest, true);
|
|
const functionBodies = collectTopLevelFunctionBodies(sourceFile);
|
|
const visitedFunctions = new Set();
|
|
const violationKeys = new Set();
|
|
const violations = [];
|
|
|
|
const visitCorpusBody = (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.
|
|
export 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 = [
|
|
{
|
|
key: "embeddedAgentSessionTarget",
|
|
sourceRootPaths: ["extensions/voice-call/src"],
|
|
migratedFiles: migratedEmbeddedAgentSessionTargetFiles,
|
|
findViolations: findEmbeddedAgentSessionTargetViolations,
|
|
},
|
|
{
|
|
key: "memoryHostSessionCorpus",
|
|
sourceRootPaths: ["packages/memory-host-sdk/src/host"],
|
|
migratedFiles: migratedMemoryHostSessionCorpusFiles,
|
|
findViolations: findMemoryHostSessionCorpusBoundaryViolations,
|
|
},
|
|
{
|
|
key: "sessionAccessorRead",
|
|
sourceRootPaths: readSourceRootPaths,
|
|
migratedFiles: new Set([
|
|
...migratedSessionAccessorFiles,
|
|
...migratedBundledPluginSessionAccessorFiles,
|
|
]),
|
|
findViolations: findSessionAccessorBoundaryViolations,
|
|
},
|
|
{
|
|
key: "sessionAccessorWrite",
|
|
sourceRootPaths: writeSourceRootPaths,
|
|
migratedFiles: migratedSessionAccessorWriteFiles,
|
|
findViolations: findSessionAccessorWriteBoundaryViolations,
|
|
},
|
|
{
|
|
key: "sessionCompactManualTrim",
|
|
sourceRootPaths: ["src/gateway/server-methods"],
|
|
migratedFiles: migratedSessionCompactManualTrimFiles,
|
|
findViolations: findSessionCompactManualTrimBoundaryViolations,
|
|
},
|
|
{
|
|
key: "sessionLifecycleCleanup",
|
|
sourceRootPaths: readSourceRootPaths,
|
|
migratedFiles: migratedSessionLifecycleCleanupFiles,
|
|
findViolations: findSessionLifecycleCleanupBoundaryViolations,
|
|
},
|
|
{
|
|
key: "transcriptWriter",
|
|
sourceRootPaths: transcriptWriterSourceRootPaths,
|
|
migratedFiles: migratedTranscriptWriterFiles,
|
|
findViolations: findTranscriptWriterBoundaryViolations,
|
|
},
|
|
];
|
|
|
|
function sortRecordByKey(record) {
|
|
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. */
|
|
export async function collectSessionAccessorDebtCounts(repoRoot) {
|
|
const counts = {};
|
|
for (const concern of 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 = {};
|
|
for (const violation of violations) {
|
|
const relativePath = normalizeRelativePath(violation.path);
|
|
fileCounts[relativePath] = (fileCounts[relativePath] ?? 0) + 1;
|
|
}
|
|
counts[concern.key] = sortRecordByKey(fileCounts);
|
|
}
|
|
return sortRecordByKey(counts);
|
|
}
|
|
|
|
/** Ratchet compare: counts above baseline are regressions, below are improvements. */
|
|
export function compareSessionAccessorDebt(currentCounts, baselineCounts) {
|
|
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 };
|
|
}
|
|
|
|
// 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) {
|
|
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) {
|
|
return path.join(repoRoot, ...sessionAccessorDebtBaselineRelativePath.split("/"));
|
|
}
|
|
|
|
async function readSessionAccessorDebtBaseline(repoRoot) {
|
|
try {
|
|
return JSON.parse(await fs.readFile(resolveDebtBaselinePath(repoRoot), "utf8"));
|
|
} catch (error) {
|
|
if (error && typeof error === "object" && error.code === "ENOENT") {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async function writeSessionAccessorDebtBaseline(repoRoot) {
|
|
const counts = await collectSessionAccessorDebtCounts(repoRoot);
|
|
await fs.writeFile(resolveDebtBaselinePath(repoRoot), `${JSON.stringify(counts, null, 2)}\n`);
|
|
}
|
|
|
|
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 readSourceRoots = resolveSourceRoots(repoRoot, readSourceRootPaths);
|
|
const writeSourceRoots = resolveSourceRoots(repoRoot, writeSourceRootPaths);
|
|
const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, transcriptWriterSourceRootPaths);
|
|
const readViolations = await collectFileViolations({
|
|
repoRoot,
|
|
sourceRoots: readSourceRoots,
|
|
skipFile: (filePath) => {
|
|
const relativePath = normalizeRelativePath(path.relative(repoRoot, filePath));
|
|
return (
|
|
!migratedSessionAccessorFiles.has(relativePath) &&
|
|
!migratedBundledPluginSessionAccessorFiles.has(relativePath)
|
|
);
|
|
},
|
|
findViolations: findSessionAccessorBoundaryViolations,
|
|
});
|
|
const writeViolations = await collectFileViolations({
|
|
repoRoot,
|
|
sourceRoots: writeSourceRoots,
|
|
skipFile: (filePath) =>
|
|
!migratedSessionAccessorWriteFiles.has(
|
|
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
|
),
|
|
findViolations: findSessionAccessorWriteBoundaryViolations,
|
|
});
|
|
const transcriptWriterViolations = await collectFileViolations({
|
|
repoRoot,
|
|
sourceRoots: transcriptWriterSourceRoots,
|
|
skipFile: (filePath) =>
|
|
!migratedTranscriptWriterFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
|
|
findViolations: findTranscriptWriterBoundaryViolations,
|
|
});
|
|
const sessionCreateLifecycleViolations = await collectFileViolations({
|
|
repoRoot,
|
|
sourceRoots: resolveSourceRoots(repoRoot, ["src/gateway/server-methods"]),
|
|
skipFile: (filePath) =>
|
|
normalizeRelativePath(path.relative(repoRoot, filePath)) !==
|
|
"src/gateway/server-methods/sessions.ts",
|
|
findViolations: findGatewaySessionCreateLifecycleViolations,
|
|
});
|
|
const manualCompactTrimViolations = await collectFileViolations({
|
|
repoRoot,
|
|
sourceRoots: resolveSourceRoots(repoRoot, ["src/gateway/server-methods"]),
|
|
skipFile: (filePath) =>
|
|
!migratedSessionCompactManualTrimFiles.has(
|
|
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
|
),
|
|
findViolations: findSessionCompactManualTrimBoundaryViolations,
|
|
});
|
|
const lifecycleCleanupViolations = await collectFileViolations({
|
|
repoRoot,
|
|
sourceRoots: readSourceRoots,
|
|
skipFile: (filePath) =>
|
|
!migratedSessionLifecycleCleanupFiles.has(
|
|
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
|
),
|
|
findViolations: findSessionLifecycleCleanupBoundaryViolations,
|
|
});
|
|
const memoryHostSessionCorpusViolations = await collectFileViolations({
|
|
repoRoot,
|
|
sourceRoots: resolveSourceRoots(repoRoot, ["packages/memory-host-sdk/src/host"]),
|
|
skipFile: (filePath) =>
|
|
!migratedMemoryHostSessionCorpusFiles.has(
|
|
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
|
),
|
|
findViolations: findMemoryHostSessionCorpusBoundaryViolations,
|
|
});
|
|
const embeddedAgentSessionTargetViolations = await collectFileViolations({
|
|
repoRoot,
|
|
sourceRoots: resolveSourceRoots(repoRoot, ["extensions/voice-call/src"]),
|
|
skipFile: (filePath) =>
|
|
!migratedEmbeddedAgentSessionTargetFiles.has(
|
|
normalizeRelativePath(path.relative(repoRoot, filePath)),
|
|
),
|
|
findViolations: findEmbeddedAgentSessionTargetViolations,
|
|
});
|
|
const sessionStoreRuntimePath = path.join(repoRoot, "src/plugin-sdk/session-store-runtime.ts");
|
|
const sessionStoreRuntimeCompatViolations =
|
|
findSessionStoreRuntimeFileBackedCompatExportViolations(
|
|
await fs.readFile(sessionStoreRuntimePath, "utf8"),
|
|
sessionStoreRuntimePath,
|
|
).map((violation) =>
|
|
Object.assign({ path: "src/plugin-sdk/session-store-runtime.ts" }, violation),
|
|
);
|
|
const violations = [
|
|
...readViolations,
|
|
...writeViolations,
|
|
...transcriptWriterViolations,
|
|
...sessionCreateLifecycleViolations,
|
|
...manualCompactTrimViolations,
|
|
...lifecycleCleanupViolations,
|
|
...memoryHostSessionCorpusViolations,
|
|
...embeddedAgentSessionTargetViolations,
|
|
...sessionStoreRuntimeCompatViolations,
|
|
];
|
|
|
|
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);
|