ci: add session accessor boundary ratchet

This commit is contained in:
Josh Lehman
2026-06-04 15:06:38 -07:00
parent 944e4f88da
commit 2323ebb770
5 changed files with 231 additions and 7 deletions

View File

@@ -1583,6 +1583,7 @@
"lint:tmp:no-random-messaging": "node scripts/check-no-random-messaging-tmp.mjs",
"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:tsgo-core-boundary": "node scripts/check-tsgo-core-boundary.mjs",
"lint:ui:no-raw-window-open": "node scripts/check-no-raw-window-open.mjs",
"lint:web-fetch-provider-boundaries": "node scripts/check-web-fetch-provider-boundaries.mjs",

View File

@@ -0,0 +1,146 @@
#!/usr/bin/env node
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"]);
export const migratedSessionAccessorFiles = new Set([
"src/config/sessions/combined-store-gateway.ts",
"src/gateway/session-utils.ts",
"src/gateway/sessions-resolve.ts",
"src/gateway/server-methods/sessions.ts",
]);
function normalizeRelativePath(filePath) {
return filePath.replaceAll(path.sep, "/");
}
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 bindingName(node) {
if (node.propertyName && ts.isIdentifier(node.propertyName)) {
return node.propertyName.text;
}
if (ts.isIdentifier(node.name)) {
return node.name.text;
}
return null;
}
export function findSessionAccessorBoundaryViolations(content, fileName = "source.ts") {
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 (legacyReaderNames.has(importedName)) {
violations.push({
line: toLine(sourceFile, specifier),
reason: `imports legacy session store reader "${importedName}"`,
});
}
}
}
}
if (ts.isBindingElement(node)) {
const name = bindingName(node);
if (name && legacyReaderNames.has(name)) {
violations.push({
line: toLine(sourceFile, node),
reason: `aliases legacy session store reader "${name}"`,
});
}
}
if (ts.isPropertyAccessExpression(node) && legacyReaderNames.has(node.name.text)) {
violations.push({
line: toLine(sourceFile, node.name),
reason: `references legacy session store reader "${node.name.text}"`,
});
}
if (
ts.isElementAccessExpression(node) &&
ts.isStringLiteral(node.argumentExpression) &&
legacyReaderNames.has(node.argumentExpression.text)
) {
violations.push({
line: toLine(sourceFile, node.argumentExpression),
reason: `references legacy session store reader "${node.argumentExpression.text}"`,
});
}
if (ts.isCallExpression(node)) {
const calleeName = propertyAccessName(node.expression);
if (
calleeName &&
legacyReaderNames.has(calleeName) &&
ts.isIdentifier(unwrapExpression(node.expression))
) {
violations.push({
line: toLine(sourceFile, node.expression),
reason: `calls legacy session store reader "${calleeName}"`,
});
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
return violations;
}
export async function main() {
const repoRoot = resolveRepoRoot(import.meta.url);
const sourceRoots = resolveSourceRoots(repoRoot, ["src/config/sessions", "src/gateway"]);
const violations = await collectFileViolations({
repoRoot,
sourceRoots,
skipFile: (filePath) =>
!migratedSessionAccessorFiles.has(normalizeRelativePath(path.relative(repoRoot, filePath))),
findViolations: findSessionAccessorBoundaryViolations,
});
if (violations.length === 0) {
console.log("session accessor boundary guard passed.");
return;
}
console.error("Found legacy session store reader 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/projection paths. Expand this ratchet only after a slice migrates more files.",
);
process.exit(1);
}
runAsScript(import.meta.url, main);

View File

@@ -14,6 +14,7 @@ export const BOUNDARY_CHECKS = [
["lint:tmp:tsgo-core-boundary", "pnpm", ["run", "lint:tmp:tsgo-core-boundary"]],
["lint:tmp:no-raw-channel-fetch", "pnpm", ["run", "lint:tmp:no-raw-channel-fetch"]],
["lint:tmp:no-raw-http2-imports", "pnpm", ["run", "lint:tmp:no-raw-http2-imports"]],
["lint:tmp:session-accessor-boundary", "pnpm", ["run", "lint:tmp:session-accessor-boundary"]],
["lint:agent:ingress-owner", "pnpm", ["run", "lint:agent:ingress-owner"]],
[
"lint:plugins:no-register-http-handler",
@@ -442,11 +443,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
process.env.OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY === undefined
? "OPENCLAW_EXTENSION_BOUNDARY_CONCURRENCY"
: "OPENCLAW_ADDITIONAL_BOUNDARY_CONCURRENCY";
const concurrency = resolveConcurrency(
concurrencyRaw,
4,
concurrencyLabel,
);
const concurrency = resolveConcurrency(concurrencyRaw, 4, concurrencyLabel);
const checkTimeoutMs = resolvePositiveInteger(
process.env.OPENCLAW_ADDITIONAL_BOUNDARY_TIMEOUT_MS,
DEFAULT_CHECK_TIMEOUT_MS,

View File

@@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import {
findSessionAccessorBoundaryViolations,
migratedSessionAccessorFiles,
} from "../../scripts/check-session-accessor-boundary.mjs";
describe("session accessor boundary guard", () => {
it("ratchets only the files migrated by the session accessor gateway slice", () => {
expect(migratedSessionAccessorFiles).toEqual(
new Set([
"src/config/sessions/combined-store-gateway.ts",
"src/gateway/session-utils.ts",
"src/gateway/sessions-resolve.ts",
"src/gateway/server-methods/sessions.ts",
]),
);
});
it("flags legacy reader imports", () => {
expect(
findSessionAccessorBoundaryViolations(`
import { loadSessionStore, readSessionEntries as readEntries } from "../config/sessions.js";
`),
).toEqual([
{ line: 2, reason: 'imports legacy session store reader "loadSessionStore"' },
{ line: 2, reason: 'imports legacy session store reader "readSessionEntries"' },
]);
});
it("flags direct and namespace legacy reader calls", () => {
expect(
findSessionAccessorBoundaryViolations(`
loadSessionStore(storePath);
sessions.readSessionEntries(storePath);
sessions["loadSessionStore"](storePath);
`),
).toEqual([
{ line: 2, reason: 'calls legacy session store reader "loadSessionStore"' },
{ line: 3, reason: 'references legacy session store reader "readSessionEntries"' },
{ line: 4, reason: 'references legacy session store reader "loadSessionStore"' },
]);
});
it("flags aliased namespace reader references", () => {
expect(
findSessionAccessorBoundaryViolations(`
const load = sessions.loadSessionStore;
const { readSessionEntries: readEntries } = sessions;
const { loadSessionStore } = sessions;
`),
).toEqual([
{ line: 2, reason: 'references legacy session store reader "loadSessionStore"' },
{ line: 3, reason: 'aliases legacy session store reader "readSessionEntries"' },
{ line: 4, reason: 'aliases legacy session store reader "loadSessionStore"' },
]);
});
it("allows migrated accessor reads", () => {
expect(
findSessionAccessorBoundaryViolations(`
import { listSessionEntries } from "../config/sessions/session-accessor.js";
listSessionEntries({ storePath });
`),
).toEqual([]);
});
it("ignores comments and strings that describe legacy readers", () => {
expect(
findSessionAccessorBoundaryViolations(`
// loadSessionStore and readSessionEntries used to be called here.
const description = "loadSessionStore";
`),
).toEqual([]);
});
});

View File

@@ -92,12 +92,17 @@ describe("run-additional-boundary-checks", () => {
expect(() => parseShardSpec("5/4")).toThrow("Invalid shard spec");
});
it("keeps the raw HTTP/2 import guard in source boundary checks", () => {
expect(BOUNDARY_CHECKS[6]).toEqual({
it("keeps the temporary ratchet guards in source boundary checks", () => {
expect(BOUNDARY_CHECKS).toContainEqual({
label: "lint:tmp:no-raw-http2-imports",
command: "pnpm",
args: ["run", "lint:tmp:no-raw-http2-imports"],
});
expect(BOUNDARY_CHECKS).toContainEqual({
label: "lint:tmp:session-accessor-boundary",
command: "pnpm",
args: ["run", "lint:tmp:session-accessor-boundary"],
});
});
it("keeps the Telegram grammY type import guard in source boundary checks", () => {