diff --git a/package.json b/package.json index 5e0975d3f6b6..cda09f31c307 100644 --- a/package.json +++ b/package.json @@ -1684,6 +1684,7 @@ "lint:tmp:no-raw-channel-fetch": "node scripts/check-no-raw-channel-fetch.mjs", "lint:tmp:no-raw-http2-imports": "node scripts/check-no-raw-http2-imports.mjs", "lint:tmp:session-accessor-boundary": "node scripts/check-session-accessor-boundary.mjs", + "lint:tmp:session-accessor-boundary:gen": "node scripts/check-session-accessor-boundary.mjs --update-debt-baseline", "lint:tmp:session-transcript-reader-boundary": "node scripts/check-session-transcript-reader-boundary.mjs", "lint:tmp:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs", "lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs", diff --git a/scripts/check-session-accessor-boundary.mjs b/scripts/check-session-accessor-boundary.mjs index b20959f5d5b5..78159fa5d9cc 100644 --- a/scripts/check-session-accessor-boundary.mjs +++ b/scripts/check-session-accessor-boundary.mjs @@ -537,6 +537,43 @@ export function findSessionLifecycleCleanupBoundaryViolations(content, fileName ); } +// 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/plugins", + "src/tui", +]; +const transcriptWriterSourceRootPaths = [ + "src/agents/command", + "src/agents/embedded-agent-runner", + "src/config/sessions", + "src/gateway/server-methods", + "src/sessions", +]; + function declarationName(node) { if (ts.isFunctionDeclaration(node) && node.name) { return node.name.text; @@ -617,42 +654,166 @@ export function findMemoryHostSessionCorpusBoundaryViolations(content, fileName 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); - const readSourceRoots = resolveSourceRoots(repoRoot, [ - "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 writeSourceRoots = resolveSourceRoots(repoRoot, [ - "src/acp", - "src/agents", - "src/auto-reply", - "src/commands", - "src/config/sessions", - "src/gateway", - "src/plugins", - "src/tui", - ]); - const transcriptWriterSourceRoots = resolveSourceRoots(repoRoot, [ - "src/agents/command", - "src/agents/embedded-agent-runner", - "src/config/sessions", - "src/gateway/server-methods", - "src/sessions", - ]); + 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, @@ -745,18 +906,50 @@ export async function main() { ...sessionStoreRuntimeCompatViolations, ]; - if (violations.length === 0) { + 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; } - 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}`); + 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); + } } - 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.", - ); process.exit(1); } diff --git a/scripts/lib/session-accessor-debt-baseline.json b/scripts/lib/session-accessor-debt-baseline.json new file mode 100644 index 000000000000..7e1e7e1b6251 --- /dev/null +++ b/scripts/lib/session-accessor-debt-baseline.json @@ -0,0 +1,81 @@ +{ + "embeddedAgentSessionTarget": {}, + "memoryHostSessionCorpus": {}, + "sessionAccessorRead": { + "extensions/telegram/src/bot-deps.ts": 1, + "extensions/telegram/src/bot-message-context.session-recreate.test-support.ts": 2, + "src/acp/control-plane/manager.background-task.ts": 2, + "src/acp/control-plane/manager.core.ts": 1, + "src/agents/agent-command.ts": 2, + "src/agents/bash-tools.exec-approval-followup.ts": 2, + "src/agents/command/session.ts": 5, + "src/agents/main-session-restart-recovery.ts": 2, + "src/agents/subagent-announce-delivery.ts": 3, + "src/agents/subagent-announce-output.ts": 3, + "src/agents/subagent-announce.test-support.ts": 1, + "src/agents/subagent-capabilities.ts": 2, + "src/agents/subagent-list.ts": 2, + "src/agents/subagent-orphan-recovery.ts": 2, + "src/agents/subagent-session-reconciliation.ts": 2, + "src/agents/subagent-spawn.ts": 2, + "src/agents/tools/transcripts-tool.ts": 2, + "src/auto-reply/reply.triggers.trigger-handling.filters-usage-summary-current-model-provider.cases.ts": 2, + "src/auto-reply/reply/commands-export-common.ts": 2, + "src/auto-reply/reply/commands-name.ts": 2, + "src/auto-reply/reply/commands-session-store.ts": 2, + "src/auto-reply/reply/dispatch-acp-transcript.runtime.ts": 4, + "src/auto-reply/reply/dispatch-from-config.ts": 6, + "src/auto-reply/reply/get-reply-fast-path.ts": 4, + "src/auto-reply/reply/get-reply-run.ts": 2, + "src/commands/doctor-heartbeat-session-target.ts": 2, + "src/commands/doctor-state-integrity.ts": 2, + "src/commands/doctor/shared/codex-route-warnings.ts": 3, + "src/config/sessions/cleanup-service.ts": 4, + "src/config/sessions/session-accessor.ts": 25, + "src/config/sessions/session-registry-maintenance.ts": 2, + "src/config/sessions/store-load.ts": 4, + "src/config/sessions/store.ts": 15, + "src/config/sessions/test-helpers.ts": 2, + "src/config/sessions/transcript.ts": 6, + "src/cron/isolated-agent/session.ts": 3, + "src/infra/approval-request-account-binding.ts": 2, + "src/infra/heartbeat-runner.ts": 4, + "src/plugins/runtime/runtime-agent.ts": 1 + }, + "sessionAccessorWrite": { + "src/agents/embedded-agent-subscribe.handlers.compaction.runtime.ts": 2, + "src/agents/session-suspension.ts": 2, + "src/agents/subagent-orphan-recovery.ts": 3, + "src/agents/subagent-spawn.ts": 2, + "src/auto-reply/reply/commands-name.ts": 2, + "src/auto-reply/reply/dispatch-from-config.ts": 2, + "src/auto-reply/reply/session-fork.ts": 2, + "src/commands/doctor-heartbeat-main-session-repair.ts": 2, + "src/commands/doctor-session-state-providers.ts": 2, + "src/commands/doctor-state-integrity.ts": 2, + "src/commands/doctor/shared/codex-route-warnings.ts": 2, + "src/config/sessions/plugin-host-cleanup.ts": 2, + "src/config/sessions/session-accessor.ts": 11, + "src/config/sessions/session-file.ts": 2, + "src/config/sessions/session-registry-maintenance.ts": 2, + "src/gateway/server-methods/agent.ts": 2, + "src/gateway/server-methods/chat.ts": 2, + "src/gateway/server-methods/sessions.ts": 3, + "src/gateway/session-lifecycle-state.ts": 2, + "src/gateway/test-helpers.mocks.ts": 1, + "src/plugins/runtime/runtime-agent.ts": 2 + }, + "sessionCompactManualTrim": {}, + "sessionLifecycleCleanup": { + "src/config/sessions/store-maintenance-operations.ts": 2, + "src/config/sessions/store.ts": 5 + }, + "transcriptWriter": { + "src/agents/embedded-agent-runner/compaction-hooks.ts": 2, + "src/agents/embedded-agent-runner/thinking-replay-repair.ts": 2, + "src/agents/embedded-agent-runner/tool-result-truncation.ts": 3, + "src/agents/embedded-agent-runner/transcript-rewrite.ts": 2, + "src/config/sessions/session-accessor.ts": 7, + "src/config/sessions/store.ts": 2 + } +} diff --git a/test/scripts/check-session-accessor-boundary.test.ts b/test/scripts/check-session-accessor-boundary.test.ts index c47e49ccda73..efa6ae896cf9 100644 --- a/test/scripts/check-session-accessor-boundary.test.ts +++ b/test/scripts/check-session-accessor-boundary.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { allowedSessionStoreRuntimeFileBackedCompatExports, collectSessionStoreRuntimeFileBackedCompatExports, + compareSessionAccessorDebt, findGatewaySessionCreateLifecycleViolations, findEmbeddedAgentSessionTargetViolations, findMemoryHostSessionCorpusBoundaryViolations, @@ -11,6 +12,7 @@ import { findSessionLifecycleCleanupBoundaryViolations, findSessionStoreRuntimeFileBackedCompatExportViolations, findTranscriptWriterBoundaryViolations, + formatSessionAccessorDebtImprovements, migratedBundledPluginSessionAccessorFiles, migratedEmbeddedAgentSessionTargetFiles, migratedMemoryHostSessionCorpusFiles, @@ -574,3 +576,55 @@ describe("session accessor boundary guard", () => { ).toEqual([]); }); }); + +describe("session accessor debt ratchet", () => { + it("flags unmigrated files whose legacy call-site count exceeds the baseline", () => { + expect( + compareSessionAccessorDebt( + { + sessionAccessorRead: { "src/a.ts": 3 }, + sessionAccessorWrite: { "src/new.ts": 1 }, + }, + { + sessionAccessorRead: { "src/a.ts": 2 }, + sessionAccessorWrite: {}, + }, + ), + ).toEqual({ + regressions: [ + { concern: "sessionAccessorRead", path: "src/a.ts", currentCount: 3, baselineCount: 2 }, + { concern: "sessionAccessorWrite", path: "src/new.ts", currentCount: 1, baselineCount: 0 }, + ], + improvements: [], + }); + }); + + it("passes when counts match the baseline", () => { + expect( + compareSessionAccessorDebt( + { sessionAccessorRead: { "src/a.ts": 2 } }, + { sessionAccessorRead: { "src/a.ts": 2 } }, + ), + ).toEqual({ regressions: [], improvements: [] }); + }); + + it("fails with a regen instruction when counts drop below the baseline", () => { + const debt = compareSessionAccessorDebt( + { sessionAccessorRead: { "src/a.ts": 1 } }, + { sessionAccessorRead: { "src/a.ts": 2, "src/gone.ts": 3 } }, + ); + expect(debt).toEqual({ + regressions: [], + improvements: [ + { concern: "sessionAccessorRead", path: "src/a.ts", currentCount: 1, baselineCount: 2 }, + { concern: "sessionAccessorRead", path: "src/gone.ts", currentCount: 0, baselineCount: 3 }, + ], + }); + expect(formatSessionAccessorDebtImprovements(debt.improvements)).toEqual([ + "Legacy session accessor debt dropped below scripts/lib/session-accessor-debt-baseline.json:", + "- src/a.ts [sessionAccessorRead]: 1 legacy call site(s), stale baseline allows 2", + "- src/gone.ts [sessionAccessorRead]: 0 legacy call site(s), stale baseline allows 3", + "Run `pnpm lint:tmp:session-accessor-boundary:gen` to ratchet the baseline down and commit it.", + ]); + }); +});