From 19be43215ee81a101eb6a5c981edbaaea2eb9018 Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 7 Jun 2026 14:00:27 +0900 Subject: [PATCH] Clarify lint guardrail configuration --- eslint.config.mjs | 85 ++++++++++++++++++++++++++++++-------------- scripts/check.mjs | 19 +++++++--- stylelint.config.mjs | 81 ++++++----------------------------------- 3 files changed, 84 insertions(+), 101 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index 69046151..38661c75 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -43,26 +43,6 @@ const unsafeIteratorRestrictions = [ message: "Avoid reading iterator.next().value directly; use for...of or inspect the typed IteratorResult first.", }, ]; -const chatStateRestrictions = [ - { - selector: "AssignmentExpression[left.type='MemberExpression'][left.object.name='state']", - message: "Route ChatState updates through ChatStateStore.dispatch().", - }, - { - selector: "AssignmentExpression[left.type='MemberExpression'][left.object.type='MemberExpression'][left.object.property.name='state']", - message: "Route ChatState updates through ChatStateStore.dispatch().", - }, - { - selector: - "CallExpression[callee.property.name=/^(push|set|delete|clear|add)$/][callee.object.type='MemberExpression'][callee.object.object.name='state']", - message: "Clone ChatState collections and update them through ChatStateStore.dispatch().", - }, - { - selector: - "CallExpression[callee.property.name=/^(push|set|delete|clear|add)$/][callee.object.type='MemberExpression'][callee.object.object.type='MemberExpression'][callee.object.object.property.name='state']", - message: "Clone ChatState collections and update them through ChatStateStore.dispatch().", - }, -]; const pureChatModelRestrictions = [ { selector: "CallExpression[callee.object.name='Date'][callee.property.name='now']", @@ -90,11 +70,9 @@ const pureChatModelRestrictions = [ }, ]; const chatImperativeDomBridgeFiles = [ + "src/features/chat/ui/message-stream/**/*.{ts,tsx}", "src/features/chat/ui/composer.tsx", "src/features/chat/ui/goal-banner.tsx", - "src/features/chat/ui/message-stream/markdown-renderer.ts", - "src/features/chat/ui/message-stream/rendered-markdown-links.ts", - "src/features/chat/ui/message-stream/**/*.tsx", "src/features/chat/ui/scroll.ts", "src/features/chat/ui/shell.tsx", "src/features/chat/ui/tool-result.tsx", @@ -136,9 +114,64 @@ const codexPanelEslintPlugin = { }; }, }, + "no-chat-state-direct-mutation": { + 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 mutatingCollectionMethods = new Set(["add", "clear", "delete", "push", "set"]); + return { + AssignmentExpression(node) { + if (isChatStateMember(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 (isChatStateMember(node.callee.object)) context.report({ node: node.callee, messageId: "mutateCollection" }); + }, + }; + }, + }, }, }; +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 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; +} + function findInitializerCallbackReference(root, name) { let reference = null; @@ -300,8 +333,8 @@ export default defineConfig([ ...unsafeIteratorRestrictions, ...imperativeDomRestrictions, ...preactFormRestrictions, - ...chatStateRestrictions, ], + "codex-panel/no-chat-state-direct-mutation": "error", }, }, { @@ -312,8 +345,8 @@ export default defineConfig([ ...removedChatStateEscapeHatchRestrictions, ...unsafeIteratorRestrictions, ...preactFormRestrictions, - ...chatStateRestrictions, ], + "codex-panel/no-chat-state-direct-mutation": "error", }, }, { @@ -336,9 +369,9 @@ export default defineConfig([ ...unsafeIteratorRestrictions, ...imperativeDomRestrictions, ...preactFormRestrictions, - ...chatStateRestrictions, ...pureChatModelRestrictions, ], + "codex-panel/no-chat-state-direct-mutation": "error", }, }, { diff --git a/scripts/check.mjs b/scripts/check.mjs index a245bdeb..eb634466 100644 --- a/scripts/check.mjs +++ b/scripts/check.mjs @@ -2,6 +2,16 @@ import { spawnSync } from "node:child_process"; const args = new Set(process.argv.slice(2)); const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const checks = [ + { local: "typecheck", ci: "typecheck:ci", localPhase: "parallel" }, + { local: "test", ci: "test:ci", localPhase: "parallel" }, + { local: "lint:ts", ci: "lint:ts:ci", localPhase: "parallel" }, + { local: "lint:css", ci: "lint:css", localPhase: "parallel" }, + { local: "format:check", ci: "format:check:ci", localPhase: "parallel" }, + { local: "build:styles:check", ci: "build:styles:check", localPhase: "parallel" }, + { local: "unused", ci: "unused", localPhase: "parallel" }, + { local: "build", ci: "build", localPhase: "sequential" }, +]; for (const arg of args) { if (arg !== "--ci") { @@ -11,12 +21,11 @@ for (const arg of args) { } if (args.has("--ci")) { - for (const script of ["typecheck:ci", "test:ci", "lint:ts:ci", "lint:css", "format:check:ci", "build:styles:check", "unused", "build"]) { - run(npmCommand, ["run", script]); - } + for (const check of checks) run(npmCommand, ["run", check.ci]); } else { - run("node", ["scripts/run-parallel.mjs", "typecheck", "test", "lint:ts", "lint:css", "format:check", "build:styles:check", "unused"]); - run(npmCommand, ["run", "build"]); + const parallelScripts = checks.filter((check) => check.localPhase === "parallel").map((check) => check.local); + run("node", ["scripts/run-parallel.mjs", ...parallelScripts]); + for (const check of checks.filter((check) => check.localPhase === "sequential")) run(npmCommand, ["run", check.local]); } function run(command, args) { diff --git a/stylelint.config.mjs b/stylelint.config.mjs index 4620befe..7153f807 100644 --- a/stylelint.config.mjs +++ b/stylelint.config.mjs @@ -4,33 +4,13 @@ export default { extends: ["stylelint-config-standard"], plugins: [noSpecificityWhere], rules: { - "codex-panel/no-specificity-where": [ - true, - { - severity: "warning", - }, - ], - "color-named": [ - "never", - { - severity: "warning", - }, - ], - "color-no-hex": [ - true, - { - severity: "warning", - }, - ], + "codex-panel/no-specificity-where": true, + "color-named": "never", + "color-no-hex": true, "custom-property-empty-line-before": null, "declaration-block-no-redundant-longhand-properties": null, "declaration-empty-line-before": null, - "declaration-no-important": [ - true, - { - severity: "warning", - }, - ], + "declaration-no-important": true, "declaration-property-value-disallowed-list": [ { "/^(?:background(?:-color)?|border(?:-(?:block|inline|top|right|bottom|left))?(?:-color)?|box-shadow|caret-color|color|fill|outline(?:-color)?|stroke|text-shadow)$/": @@ -45,7 +25,6 @@ export default { }, { message: "Use Obsidian or Codex Panel design tokens instead of hardcoded colors or typography.", - severity: "warning", }, ], "declaration-property-unit-disallowed-list": [ @@ -55,67 +34,29 @@ export default { }, { message: "Prefer Obsidian or Codex Panel spacing/size tokens for layout dimensions.", - severity: "warning", - }, - ], - "function-disallowed-list": [ - ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch"], - { - severity: "warning", - }, - ], - "keyframes-name-pattern": [ - "^codex-panel-[a-z0-9-]+$", - { - severity: "warning", - }, - ], - "max-nesting-depth": [ - 2, - { - severity: "warning", }, ], + "function-disallowed-list": ["rgb", "rgba", "hsl", "hsla", "hwb", "lab", "lch", "oklab", "oklch"], + "keyframes-name-pattern": "^codex-panel-[a-z0-9-]+$", + "max-nesting-depth": 2, "no-descending-specificity": null, "selector-class-pattern": null, - "selector-max-compound-selectors": [ - 4, - { - severity: "warning", - }, - ], - "selector-max-specificity": [ - "0,4,0", - { - severity: "warning", - }, - ], + "selector-max-compound-selectors": 4, + "selector-max-specificity": "0,4,0", "selector-max-type": [ 0, { ignoreTypes: ["dd", "dt", "pre", "span", "summary", "svg", "ul"], - severity: "warning", - }, - ], - "selector-max-id": [ - 0, - { - severity: "warning", }, ], + "selector-max-id": 0, "selector-disallowed-list": [ ["/:has\\(/"], { message: "Avoid :has() because it can cause broad selector invalidation.", - severity: "warning", - }, - ], - "selector-max-universal": [ - 0, - { - severity: "warning", }, ], + "selector-max-universal": 0, "selector-not-notation": null, }, };