mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 06:57:10 +00:00
Replace chat mutation lint with readonly state
This commit is contained in:
parent
3c21ef6485
commit
199a757618
5 changed files with 34 additions and 179 deletions
|
|
@ -14,7 +14,7 @@ Use this as the normal edit loop. `npm run format` applies Biome formatting and
|
|||
|
||||
Use focused scripts for tight loops: `npm run typecheck`, `npm run test`, `npm run build`, or the targeted `npm run check:*` scripts. CI and release preflight run the same `npm run check` command as local development.
|
||||
|
||||
Biome owns formatting, import organization, general JavaScript/TypeScript/JSON/CSS linting, GritQL source-shape plugins, GritQL import-boundary restrictions, and GritQL CSS source policy. Biome warnings fail `npm run check`; info diagnostics stay advisory, including accessibility while Obsidian-specific UI semantics are reviewed rule by rule. The CSS usage script still checks dead authored classes. TypeScript owns strict type checking, unused locals and parameters, implicit return coverage, and switch fallthrough prevention. ESLint remains for typed strict TypeScript rules that are not compiler options, Obsidian plugin policy, and Codex Panel responsibility-boundary rules that need TypeScript type information. The typed strict preset is used with `@typescript-eslint/require-await` disabled because Obsidian and panel-owned async-shaped boundaries sometimes return already-complete work.
|
||||
Biome owns formatting, import organization, general JavaScript/TypeScript/JSON/CSS linting, GritQL source-shape plugins, GritQL import-boundary restrictions, and GritQL CSS source policy. Biome warnings fail `npm run check`; info diagnostics stay advisory, including accessibility while Obsidian-specific UI semantics are reviewed rule by rule. The CSS usage script still checks dead authored classes. TypeScript owns strict type checking, unused locals and parameters, implicit return coverage, switch fallthrough prevention, and deep-readonly `ChatState` snapshots. ESLint remains for typed strict TypeScript rules that are not compiler options, Obsidian plugin policy, and the imperative-DOM responsibility-boundary rule that needs TypeScript type information. The typed strict preset is used with `@typescript-eslint/require-await` disabled because Obsidian and panel-owned async-shaped boundaries sometimes return already-complete work.
|
||||
|
||||
## Generated and Loaded Files
|
||||
|
||||
|
|
|
|||
|
|
@ -5,11 +5,7 @@ import tseslint from "typescript-eslint";
|
|||
|
||||
const sourceTypeScriptFiles = ["src/**/*.{ts,tsx}"];
|
||||
const strictTypeCheckedTypeScriptRules = Object.assign({}, ...tseslint.configs.strictTypeChecked.map((config) => config.rules ?? {}));
|
||||
const codexPanelRuleIds = {
|
||||
chatStateDirectMutation: "codex-panel/no-chat-state-direct-mutation",
|
||||
imperativeDom: "codex-panel/no-imperative-dom",
|
||||
};
|
||||
// These local rules need TypeScript type information. Keep them validated by
|
||||
// This local rule needs TypeScript type information. Keep it validated by
|
||||
// eslint over real source files instead of synthetic Vitest fixtures, which
|
||||
// would start the TypeScript project service during the test suite.
|
||||
const chatExternalDomBridgeFiles = [
|
||||
|
|
@ -153,28 +149,14 @@ export default defineConfig([
|
|||
files: sourceTypeScriptFiles,
|
||||
ignores: ["src/features/chat/**/*.{ts,tsx}", ...nonChatImperativeDomBridgeFiles],
|
||||
rules: {
|
||||
[codexPanelRuleIds.imperativeDom]: "error",
|
||||
"codex-panel/no-imperative-dom": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/features/chat/**/*.{ts,tsx}"],
|
||||
ignores: ["src/features/chat/panel/shell-state.tsx", ...chatImperativeDomBridgeFiles],
|
||||
ignores: chatImperativeDomBridgeFiles,
|
||||
rules: {
|
||||
[codexPanelRuleIds.imperativeDom]: "error",
|
||||
[codexPanelRuleIds.chatStateDirectMutation]: "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["src/features/chat/panel/shell-state.tsx"],
|
||||
rules: {
|
||||
[codexPanelRuleIds.imperativeDom]: "error",
|
||||
[codexPanelRuleIds.chatStateDirectMutation]: "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: chatImperativeDomBridgeFiles,
|
||||
rules: {
|
||||
[codexPanelRuleIds.chatStateDirectMutation]: "error",
|
||||
"codex-panel/no-imperative-dom": "error",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
|
@ -194,78 +176,7 @@ function obsidianRecommendedConfig(config) {
|
|||
function codexPanelEslintPlugin() {
|
||||
return {
|
||||
rules: {
|
||||
[localRuleName(codexPanelRuleIds.chatStateDirectMutation)]: chatStateDirectMutationRule(),
|
||||
[localRuleName(codexPanelRuleIds.imperativeDom)]: imperativeDomRule(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function localRuleName(ruleId) {
|
||||
return ruleId.replace("codex-panel/", "");
|
||||
}
|
||||
|
||||
function chatStateDirectMutationRule() {
|
||||
return {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "Disallow direct ChatState mutation in chat modules.",
|
||||
},
|
||||
messages: {
|
||||
assign: "Route ChatState updates through ChatStateStore.dispatch().",
|
||||
mutateCollection: "Clone ChatState collections and update them through ChatStateStore.dispatch().",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
const typed = typedContext(context);
|
||||
const mutatingCollectionMethods = new Set(["add", "clear", "delete", "push", "set"]);
|
||||
const chatStateAliasVariables = new WeakSet();
|
||||
const variableForIdentifier = (node) => {
|
||||
let scope = context.sourceCode.getScope(node);
|
||||
while (scope) {
|
||||
const variable = scope.variables.find((item) => item.name === node.name);
|
||||
if (variable) return variable;
|
||||
scope = scope.upper;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const markChatStateAlias = (node) => {
|
||||
const variable = variableForIdentifier(node);
|
||||
if (variable) chatStateAliasVariables.add(variable);
|
||||
};
|
||||
const isChatStateAlias = (node) => {
|
||||
if (node?.type !== "Identifier") return false;
|
||||
const variable = variableForIdentifier(node);
|
||||
return Boolean(variable && chatStateAliasVariables.has(variable));
|
||||
};
|
||||
const isChatStateValue = (node) => typeIncludesChatState(typed.typeAt(node), typed.typeChecker());
|
||||
const isChatStateAliasSource = (node) =>
|
||||
(isChatStateTarget(node) || isChatStateValue(node)) && typeCanCarryChatStateMutation(typed.typeAt(node));
|
||||
const isChatStateTarget = (node) => {
|
||||
if (isChatStateAlias(node)) return true;
|
||||
if (!isMemberExpression(node)) return false;
|
||||
if (isChatStateMember(node)) return true;
|
||||
const root = rootMemberObject(node);
|
||||
if (!root) return false;
|
||||
if (isChatStateAlias(root)) return true;
|
||||
return typeIncludesChatState(typed.typeAt(root), typed.typeChecker());
|
||||
};
|
||||
return {
|
||||
VariableDeclarator(node) {
|
||||
if (node.id.type !== "Identifier" || !node.init) return;
|
||||
if (isChatStateAliasSource(node.init)) markChatStateAlias(node.id);
|
||||
},
|
||||
AssignmentExpression(node) {
|
||||
if (isMemberExpression(node.left) && isChatStateTarget(node.left)) context.report({ node: node.left, messageId: "assign" });
|
||||
},
|
||||
CallExpression(node) {
|
||||
if (!isMemberExpression(node.callee)) return;
|
||||
const method = staticPropertyName(node.callee.property);
|
||||
if (!method || !mutatingCollectionMethods.has(method)) return;
|
||||
if (isChatStateTarget(node.callee.object)) context.report({ node: node.callee, messageId: "mutateCollection" });
|
||||
},
|
||||
};
|
||||
"no-imperative-dom": imperativeDomRule(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -336,37 +247,10 @@ function typedContext(context) {
|
|||
};
|
||||
}
|
||||
|
||||
function isChatStateMember(node) {
|
||||
if (!isMemberExpression(node)) return false;
|
||||
|
||||
let current = node;
|
||||
while (isMemberExpression(current)) {
|
||||
if (isIdentifier(current.object, "state")) return true;
|
||||
if (isThisStateMember(current.object)) return true;
|
||||
current = current.object;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isThisStateMember(node) {
|
||||
return isMemberExpression(node) && node.object?.type === "ThisExpression" && staticPropertyName(node.property) === "state";
|
||||
}
|
||||
|
||||
function rootMemberObject(node) {
|
||||
if (!isMemberExpression(node)) return null;
|
||||
let current = node;
|
||||
while (isMemberExpression(current)) current = current.object;
|
||||
return current;
|
||||
}
|
||||
|
||||
function isMemberExpression(node) {
|
||||
return node?.type === "MemberExpression";
|
||||
}
|
||||
|
||||
function isIdentifier(node, name) {
|
||||
return node?.type === "Identifier" && node.name === name;
|
||||
}
|
||||
|
||||
function staticPropertyName(node) {
|
||||
return node?.type === "Identifier" ? node.name : node?.type === "Literal" && typeof node.value === "string" ? node.value : null;
|
||||
}
|
||||
|
|
@ -399,49 +283,6 @@ function typeIncludesDom(type, checker, seen = new Set()) {
|
|||
return bases.some((base) => typeIncludesDom(base, checker, seen));
|
||||
}
|
||||
|
||||
function typeIncludesChatState(type, checker, seen = new Set()) {
|
||||
if (!type || seen.has(type.id)) return false;
|
||||
seen.add(type.id);
|
||||
|
||||
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false;
|
||||
if (type.isUnionOrIntersection()) return type.types.some((item) => typeIncludesChatState(item, checker, seen));
|
||||
|
||||
const typeName = checker.typeToString(type);
|
||||
if (chatStateTypeName(typeName)) return true;
|
||||
|
||||
const symbolName = type.getSymbol()?.getName() ?? type.aliasSymbol?.getName() ?? "";
|
||||
if (chatStateTypeName(symbolName)) return true;
|
||||
|
||||
const apparent = checker.getApparentType(type);
|
||||
if (apparent !== type && typeIncludesChatState(apparent, checker, seen)) return true;
|
||||
|
||||
const bases = typeof type.getBaseTypes === "function" ? (type.getBaseTypes() ?? []) : [];
|
||||
return bases.some((base) => typeIncludesChatState(base, checker, seen));
|
||||
}
|
||||
|
||||
function typeCanCarryChatStateMutation(type, seen = new Set()) {
|
||||
if (!type || seen.has(type.id)) return false;
|
||||
seen.add(type.id);
|
||||
|
||||
if (type.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown | ts.TypeFlags.Never)) return false;
|
||||
if (type.isUnionOrIntersection()) return type.types.some((item) => typeCanCarryChatStateMutation(item, seen));
|
||||
|
||||
const primitiveLikeFlags =
|
||||
ts.TypeFlags.StringLike |
|
||||
ts.TypeFlags.NumberLike |
|
||||
ts.TypeFlags.BooleanLike |
|
||||
ts.TypeFlags.BigIntLike |
|
||||
ts.TypeFlags.ESSymbolLike |
|
||||
ts.TypeFlags.Null |
|
||||
ts.TypeFlags.Undefined |
|
||||
ts.TypeFlags.Void;
|
||||
return (type.flags & primitiveLikeFlags) === 0;
|
||||
}
|
||||
|
||||
function domTypeName(name) {
|
||||
return /\b(?:Document|Element|HTML[A-Za-z]*Element|HTMLElement|Node|SVG[A-Za-z]*Element|SVGElement|Window)\b/.test(name);
|
||||
}
|
||||
|
||||
function chatStateTypeName(name) {
|
||||
return /\bChatState\b/.test(name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,14 +54,14 @@ export interface PendingUserInputQuestion {
|
|||
question: string;
|
||||
isOther: boolean;
|
||||
isSecret: boolean;
|
||||
options: PendingUserInputOption[] | null;
|
||||
options: readonly PendingUserInputOption[] | null;
|
||||
}
|
||||
|
||||
interface PendingUserInputParams {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
questions: PendingUserInputQuestion[];
|
||||
questions: readonly PendingUserInputQuestion[];
|
||||
autoResolutionMs: number | null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -118,18 +118,20 @@ interface ChatComposerState {
|
|||
readonly suggestionsDismissedSignature: string | null;
|
||||
}
|
||||
|
||||
export interface ChatState {
|
||||
readonly connection: ChatConnectionState;
|
||||
readonly threadList: ChatThreadListState;
|
||||
readonly activeThread: ChatActiveThreadState;
|
||||
readonly runtime: ChatRuntimeState;
|
||||
readonly turn: ChatTurnState;
|
||||
readonly messageStream: ChatMessageStreamState;
|
||||
readonly requests: ChatRequestState;
|
||||
readonly composer: ChatComposerState;
|
||||
readonly ui: ChatUiState;
|
||||
interface ChatStateData {
|
||||
connection: ChatConnectionState;
|
||||
threadList: ChatThreadListState;
|
||||
activeThread: ChatActiveThreadState;
|
||||
runtime: ChatRuntimeState;
|
||||
turn: ChatTurnState;
|
||||
messageStream: ChatMessageStreamState;
|
||||
requests: ChatRequestState;
|
||||
composer: ChatComposerState;
|
||||
ui: ChatUiState;
|
||||
}
|
||||
|
||||
export type ChatState = DeepReadonly<ChatStateData>;
|
||||
|
||||
type ConnectionAction =
|
||||
| { type: "connection/status-set"; statusText: string; phase?: ChatConnectionPhase }
|
||||
| ConnectionInitializedAction
|
||||
|
|
@ -691,3 +693,15 @@ function composerSuggestionsEqual(left: readonly ComposerSuggestion[], right: re
|
|||
function patchChatState(state: ChatState, patch: Partial<ChatState>): ChatState {
|
||||
return patchObject(state, patch);
|
||||
}
|
||||
|
||||
type DeepReadonly<T> = T extends (...args: never[]) => unknown
|
||||
? T
|
||||
: T extends ReadonlyMap<infer Key, infer Value>
|
||||
? ReadonlyMap<DeepReadonly<Key>, DeepReadonly<Value>>
|
||||
: T extends ReadonlySet<infer Value>
|
||||
? ReadonlySet<DeepReadonly<Value>>
|
||||
: T extends readonly (infer Value)[]
|
||||
? readonly DeepReadonly<Value>[]
|
||||
: T extends object
|
||||
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
|
||||
: T;
|
||||
|
|
|
|||
|
|
@ -52,14 +52,14 @@ export interface PendingUserInputQuestionViewModel {
|
|||
defaultAnswer: string;
|
||||
draftKey: string;
|
||||
otherDraftKey: string;
|
||||
options: PendingUserInputOptionViewModel[] | null;
|
||||
options: readonly PendingUserInputOptionViewModel[] | null;
|
||||
}
|
||||
|
||||
export interface PendingUserInputViewModel {
|
||||
requestId: PendingRequestId;
|
||||
title: string;
|
||||
body: string;
|
||||
questions: PendingUserInputQuestionViewModel[];
|
||||
questions: readonly PendingUserInputQuestionViewModel[];
|
||||
}
|
||||
|
||||
interface PendingMcpElicitationOptionViewModel {
|
||||
|
|
|
|||
Loading…
Reference in a new issue