Skip to content

✨ JSON 配置仅存储用户差异并与最新默认配置合并 - #1538

Open
cyfung1031 wants to merge 7 commits into
scriptscat:mainfrom
cyfung1031:pr/json-update-2
Open

✨ JSON 配置仅存储用户差异并与最新默认配置合并#1538
cyfung1031 wants to merge 7 commits into
scriptscat:mainfrom
cyfung1031:pr/json-update-2

Conversation

@cyfung1031

Copy link
Copy Markdown
Collaborator

Checklist / 检查清单

  • Fixes mentioned issues / 修复已提及的问题
  • Code reviewed by human / 代码通过人工检查
  • Changes tested / 已完成测试

Description / 描述

实现 #1517 讨论中确定的方案(合并 = 用户 + 默认,有变动以用户配置为准,只保存用户改动),针对目前受影响的两个 JSON 配置项 eslint_configeditor_config

问题:用户改动过配置后,整份 JSON 被完整写入 storage。之后 ScriptCat 升级默认配置(新增 ESLint 规则、调整编辑器编译选项等)对这些用户永远不生效。

方案(VSCode 式两层配置,存储层编解码):

  • 写入:与内置默认配置做深度比较,storage 只保存稀疏差异;内容与默认配置完全一致时直接清除存储键。
  • 读取:将存储的差异深度合并到最新默认配置上(用户值优先,数组与标量整体替换),返回完整配置。
  • 缓存与 MessageQueue 广播中始终是合并后的完整配置,各上下文(设置页编辑器、linter worker、monaco)行为不变。

兼容性(已上架用户无感升级)

  • 旧版全量存储的配置:读取时同样走合并逻辑——用户改过的值保留,新版本新增的默认字段自动生效;用户下次保存后自动收敛为稀疏差异,无需迁移脚本。
  • chrome.storage.sync 单键 8KB 配额压力显著降低(全量 ESLint 配置数 KB → 通常几十字节的差异)。
  • 语义说明:在编辑器中删除某个默认键 = 恢复该键的默认值(与 VSCode 一致);要禁用某条规则请显式设为 "off"

测试:新增 src/pkg/config/json_overrides.test.ts(合并/差异算法)与 config.test.ts 中「JSON 配置的稀疏存储与默认值合并」共 15 个用例(含旧版全量配置兼容场景),全部通过;pnpm test(3023 个用例)与 pnpm run lint 均通过。

close #1517

🤖 Generated with Claude Code

storage 中只保存 eslint_config / editor_config 与内置默认配置的稀疏差异,
读取时将差异合并到最新默认配置(用户值优先),使扩展升级带来的默认配置
更新能自动生效,同时完整保留用户改动。旧版全量存储的配置读取时自动兼容,
下次保存后自动收敛为稀疏差异。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cyfung1031

Copy link
Copy Markdown
Collaborator Author

Case Studies:JSON 配置只保存用户差异,并与最新默认配置合并

1. PR 核心目的

这个 PR 解决的是:当用户修改过 JSON 配置后,旧逻辑会把整份 JSON 写入 storage,导致后续 ScriptCat 升级默认配置时,新增的 ESLint 规则、编辑器选项等无法自动作用到这些用户身上。PR 当前针对两个 JSON 配置项生效:eslint_configeditor_config

新的方案类似 VSCode 的“两层配置”模型:

最终运行配置 = 最新默认配置 + 用户差异配置

也就是说:

Default Config   系统维护,随版本升级
User Overrides   只保存用户改过的部分
Final Config     运行时 merge 后得到完整配置

写入时,系统会把用户提交的完整 JSON 与默认 JSON 做深度 diff,只把差异保存到 storage;读取时,再把 storage 中的差异合并到最新默认配置上,返回完整 JSON 给设置页、linter worker、Monaco 等调用方。


2. 本 PR 涉及的主要文件

文件 作用
src/pkg/config/json_overrides.ts 新增 JSON merge / diff 工具:deepMergedeepDiffmergeJsonConfigdiffJsonConfig
src/pkg/config/config.ts SystemConfig 读写流程中接入 JSON 配置的 decode / encode 逻辑。
src/pkg/config/json_overrides.test.ts 新增 merge / diff / JSON 编解码单元测试。
src/pkg/config/config.test.ts 新增 eslint_config / editor_config 在真实配置读写流程中的测试。

3. 行为规则总览

3.1 写入规则

用户在设置页保存时,前端仍然可以提交“完整 JSON”。但进入 storage 前,系统会计算:

stored = diff(userConfig, defaultConfig)

如果用户配置与默认配置完全一致:

stored = undefined

此时会删除对应 storage key,而不是存一份空 JSON 或完整默认 JSON。PR 中 encodeForStorage 会调用 diffJsonConfig,如果结果是 undefined 就执行 storage.remove(key)


3.2 读取规则

读取 storage 时,系统拿到的是用户差异配置:

{
  "rules": {
    "no-debugger": ["warn"]
  }
}

然后与当前版本内置的最新默认配置合并:

finalConfig = merge(defaultConfig, storedDiff)

最终返回给业务侧的仍然是完整 JSON,因此设置页、worker、Monaco 等上下文不需要改变使用方式。config.ts 中的 decodeStored 会对 JSON 配置调用 mergeJsonConfig,并把合并后的完整值放入缓存。


3.3 合并规则

deepMerge 的规则是:

类型 行为
普通对象 深度递归合并
数组 整体替换
标量值 整体替换
用户新增 key 保留
类型不一致 使用用户覆盖值

测试中覆盖了“嵌套对象合并”“数组整体替换”“覆盖配置独有 key 保留”“类型不同时使用覆盖值”等场景。


4. Case Studies

Case Study 1:用户没有修改过配置,始终使用最新默认值

场景

一个新用户安装 ScriptCat,或者一个老用户从未改过 eslint_config / editor_config

旧行为可能的问题

旧逻辑下,如果某次保存把完整默认 JSON 写进 storage,那么以后即使系统默认配置升级,用户也可能继续读到旧 storage 中的完整配置。

新行为

如果用户配置与默认值完全一致,storage 不保存任何差异。

storage 中没有 system_eslint_config
storage 中没有 system_editor_config

读取时:

getEslintConfig() -> 返回当前版本最新 eslintDefaultConfig
getEditorConfig() -> 返回当前版本最新 editorDefaultConfig

价值

用户没有表达过偏好时,系统默认配置可以持续演进。例如:

{
  "rules": {
    "no-empty": ["error"],
    "no-debugger": ["error"]
  }
}

以后 ScriptCat 新增默认规则:

{
  "rules": {
    "no-empty": ["error"],
    "no-debugger": ["error"],
    "no-eval": ["warn"]
  }
}

用户会自动获得 no-eval,因为他没有任何 override 阻止默认配置升级。

对应测试

config.test.ts 新增测试验证:未修改时,getEslintConfig()getEditorConfig() 返回默认配置。


Case Study 2:用户只修改一条 ESLint 规则,storage 只保存这一条差异

场景

默认 ESLint 配置中:

{
  "rules": {
    "no-debugger": ["error"],
    "no-empty": ["error"]
  }
}

用户把 no-debuggererror 改成 warn

{
  "rules": {
    "no-debugger": ["warn"],
    "no-empty": ["error"]
  }
}

新 storage 内容

系统不会保存整份 JSON,而是只保存差异:

{
  "rules": {
    "no-debugger": ["warn"]
  }
}

读取后的最终配置

运行时合并后,业务侧拿到的是完整配置:

{
  "rules": {
    "no-debugger": ["warn"],
    "no-empty": ["error"]
  }
}

价值

storage 只表达“用户真正改过什么”。这样系统之后更新其他默认规则时,不会被旧的完整 JSON 卡住。

对应测试

PR 中的测试验证了保存 ESLint 配置时,只会存储 rules.no-debugger 和用户新增规则这类差异,而不是整份默认配置。


Case Study 3:用户新增自定义 ESLint 规则,新增 key 会被保留

场景

用户希望加入项目自定义规则:

{
  "rules": {
    "no-debugger": ["warn"],
    "custom/added-rule": ["error"]
  }
}

其中:

no-debugger        默认已有,但用户改了值
custom/added-rule  默认没有,是用户新增 key

新 storage 内容

{
  "rules": {
    "no-debugger": ["warn"],
    "custom/added-rule": ["error"]
  }
}

为什么 custom/added-rule 会保留?

deepDiff 的规则是:如果某个 key 不存在于默认配置中,就认为它是用户自定义内容,需要保留。对应测试也验证了“用户新增的 key 应保留”。

价值

这个设计不会把用户自定义规则当成“未知字段”丢掉。它适合 ESLint 这类允许插件规则、项目自定义规则的配置。


Case Study 4:ScriptCat 升级新增默认规则,老用户自动获得新默认字段

场景

某用户旧版本已经保存过配置:

{
  "rules": {
    "no-debugger": ["warn"]
  }
}

之后 ScriptCat 新版本默认配置新增:

{
  "rules": {
    "no-empty": ["error"]
  }
}

新行为

读取时会执行:

finalConfig = latestDefaultConfig + userOverrides

最终得到:

{
  "rules": {
    "no-debugger": ["warn"],
    "no-empty": ["error"]
  }
}

关键点

用户改过的 no-debugger 保留为 warn;系统新增的 no-empty 自动生效。

价值

这正是本 PR 的核心收益:用户偏好不会丢,系统默认值也可以持续升级

对应测试

测试中模拟了 storage 只保存 { rules: { "no-debugger": ["warn"] } } 的场景,并验证读取结果既保留用户值,又包含默认配置中的其他规则与 globals。


Case Study 5:旧版“全量配置”也可以懒兼容

场景

PR 上线前,老用户 storage 里可能已经存了完整 JSON:

{
  "rules": {
    "no-debugger": ["off"]
  }
}

并且这份旧 JSON 可能缺少新版本后来新增的默认字段,例如:

{
  "rules": {
    "no-empty": ["error"]
  }
}

新行为

旧版全量配置读取时也走合并逻辑:

finalConfig = latestDefaultConfig + legacyStoredConfig

最终:

{
  "rules": {
    "no-debugger": ["off"],
    "no-empty": ["error"]
  }
}

用户下次保存后

如果用户再次保存,系统会重新计算 diff。原本的全量配置会自动收敛成稀疏差异:

{
  "rules": {
    "no-debugger": ["off"]
  }
}

价值

不需要额外迁移脚本,也不会打断老用户。PR 描述中明确提到旧版全量配置读取时会兼容,用户下次保存后会自动收敛为稀疏差异。


Case Study 6:用户把规则设为 "off",表示明确禁用,而不是删除 key

场景

默认配置:

{
  "rules": {
    "no-debugger": ["error"]
  }
}

用户想禁用这条规则。

正确做法

用户应显式设置:

{
  "rules": {
    "no-debugger": ["off"]
  }
}

storage diff:

{
  "rules": {
    "no-debugger": ["off"]
  }
}

错误理解

如果用户只是从编辑器里删除这个 key:

{
  "rules": {}
}

新机制会把“缺失默认 key”解释成“恢复默认值”,而不是“禁用规则”。

为什么这样设计?

deepDiff 的注释说明:value 中缺失的默认 key 不会被记录,语义是“恢复默认值”。PR 描述也说明,在编辑器中删除某个默认 key 等于恢复该 key 的默认值;要禁用规则应显式设为 "off"


Case Study 7:数组整体替换,避免 ESLint rule options 被错误合并

场景

ESLint 规则通常是数组:

{
  "rules": {
    "some-rule": ["error", { "allow": true }]
  }
}

用户改成:

{
  "rules": {
    "some-rule": ["warn"]
  }
}

新行为

数组不会逐项 merge,而是整体替换。

最终结果:

{
  "rules": {
    "some-rule": ["warn"]
  }
}

不会出现这种错误结果:

{
  "rules": {
    "some-rule": ["warn", { "allow": true }]
  }
}

价值

这对 ESLint 规则非常重要,因为 ESLint rule array 的每个位置都有语义。数组逐项合并可能产生无效或误导性配置。

对应测试

json_overrides.test.ts 中验证了数组应整体替换,而不是合并。


Case Study 8:editor_config 也走同样机制

场景

默认 editor config:

{
  "strict": true,
  "target": "ES2020"
}

用户只修改:

{
  "strict": false,
  "target": "ES2020"
}

新 storage 内容

{
  "strict": false
}

读取后的最终配置

{
  "strict": false,
  "target": "ES2020"
}

价值

editor_config 后续新增默认编译选项时,用户也可以自动获得。例如未来默认配置新增:

{
  "moduleResolution": "bundler"
}

只要用户没有覆盖这个字段,它就会在读取时自动进入最终配置。

对应测试

PR 中新增测试验证了 editor_config 会只保存 { strict: false } 这样的差异,并在读取时与默认 editor config 合并。


Case Study 9:用户恢复默认配置时,storage key 会被清除

场景

用户之前修改过:

{
  "rules": {
    "no-debugger": ["warn"]
  }
}

之后用户把配置改回默认值:

{
  "rules": {
    "no-debugger": ["error"]
  }
}

新行为

diff 结果为空:

diff = undefined

因此系统删除 storage key:

remove("system_eslint_config")

价值

这能避免 storage 中残留一份“与默认值完全一样”的 JSON。它也降低了 chrome.storage.sync 的配额压力。

对应实现

config.ts 中的写入逻辑会先 encodeForStorage,如果结果是 undefined 就 remove,否则才 set。


Case Study 10:保存空字符串等同于恢复默认配置

场景

某些设置入口可能用空字符串表示“清空配置”或“恢复默认”。

setEslintConfig("")
setEditorConfig("")

新行为

PR 中将空字符串转换成对应默认配置,再走统一的 JSON parse 与 _set 流程:

"" -> defaultConfig -> diff(defaultConfig, defaultConfig) -> undefined -> remove storage key

最终效果

storage key 被删除
下一次读取返回最新默认配置

价值

这让“清空配置”和“恢复默认配置”语义一致,同时不会绕过新的 diff / merge 机制。

对应实现与测试

setEslintConfig / setEditorConfig 对空字符串进行了默认值替换;测试也验证了保存空字符串会清除 storage,并在读取时返回默认配置。


5. Before / After 对比

Before:完整 JSON 存储

用户只改 1 个字段
↓
storage 保存整份 JSON
↓
未来默认配置新增字段
↓
用户继续读取旧完整 JSON
↓
新默认字段无法自动生效

问题:

  • storage 体积大
  • 无法判断字段是用户改的,还是旧默认值遗留
  • 默认配置升级难以触达老用户
  • 后续迁移成本高

这些问题与 #1517 中描述的背景一致:旧机制会导致用户偏好可能被覆盖、新默认字段无法同步、难以判断用户主动修改与旧默认遗留值。


After:稀疏 diff 存储

用户只改 1 个字段
↓
storage 只保存这个字段
↓
未来默认配置新增字段
↓
读取时 latest defaults + user diff
↓
用户保留偏好,同时获得新默认字段

收益:

  • 用户改动可精确表达
  • 默认配置可持续升级
  • storage 更小
  • 老用户可懒兼容
  • 业务侧仍拿完整配置,调用方式不变

PR 描述中也提到,chrome.storage.sync 单键 8KB 配额压力会显著降低,完整 ESLint 配置从数 KB 变成通常几十字节的差异。


6. 需要注意的边界

6.1 删除默认 key 不是禁用

删除默认 key 表示恢复默认值。要禁用 ESLint 规则,应该显式写:

{
  "rules": {
    "some-rule": ["off"]
  }
}

这点需要在设置 UI 或文档中说明,避免用户误以为删除 key 就能关闭规则。


6.2 当前实现不是完整三方合并

#1517 的概念方案中提到“三方合并”、字段重命名、字段删除、迁移脚本、冲突提示等机制;但 PR #1538 当前代码主要实现的是:

diff 当前用户配置 vs 当前默认配置
merge 最新默认配置 + storage 中的用户差异

也就是说,它已经解决“只存用户差异”和“新默认值自动生效”的核心问题,但并没有实现复杂版本迁移或冲突提示。#1517 中的三方合并与迁移机制属于更完整的后续演进方向。


6.3 数组整体替换是有意设计

不要把数组当对象逐项合并。对于 ESLint rule config:

["error", { "allow": true }]

和:

["warn"]

它们是两个完整语义单元。整体替换比逐项合并更安全。测试中已经覆盖了数组整体替换。


7. 可作为 PR 说明的精简版总结

本 PR 为 eslint_configeditor_config 引入“默认配置 + 用户差异”的 JSON 配置存储机制。

  • 写入时:将用户提交的完整 JSON 与当前默认配置做深度 diff,只保存差异。
  • 读取时:将 storage 中的差异 merge 到最新默认配置,返回完整配置。
  • 如果用户配置与默认配置一致,则删除 storage key。
  • 普通对象深度合并,数组和标量整体替换。
  • 用户新增 key 会保留。
  • 删除默认 key 表示恢复默认值;禁用 ESLint 规则应显式设为 "off"
  • 旧版全量配置读取时可兼容,并在下次保存后自动收敛为稀疏 diff。

这样可以在保留用户个性化配置的同时,让 ScriptCat 后续新增或调整默认 JSON 配置时自动对用户生效,并减少 chrome.storage.sync 的存储压力。

@cyfung1031
cyfung1031 marked this pull request as draft July 7, 2026 22:11
cyfung1031 and others added 3 commits July 11, 2026 21:58
【5】大量任务、递回不死锁 用例串行 await 3000 个任务,在共享 CI runner 并发争抢下单次
实测超过全局 340ms 预算触发误报超时;本地隔离运行与全量覆盖率运行均稳定通过(~88ms),
非逻辑缺陷。比照 encoding.test.ts 的既有做法,为该重量级用例单独给予 850ms 预算,
不放宽全局超时。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

Copy link
Copy Markdown
Collaborator Author

这里有一个会破坏 #1517 核心语义的迁移边界问题(与 merge / 落后 main 无关)。

按 PickInvariant 的“重建充分性”检查,当前表示并不充分:storage 中同一个 JSON 既可能是旧版全量快照,也可能是新版稀疏 override,但目前没有任何格式版本 / provenance 标记;decodeStored() 对两者都直接执行 deepMerge(latestDefault, stored)。因此会出现“表示相同,但正确决策不同”的情况。

一个最小反例:

v1 默认: { a: 1, b: 1 }
用户只修改 b -> 2
旧版全量 storage: { a: 1, b: 2 }

v2 默认: { a: 3, b: 1 }

用户从未修改 a,所以按本 PR 的目标,升级后期望得到:

{ a: 3, b: 2 }

但当前读取逻辑是:

deepMerge({ a: 3, b: 1 }, { a: 1, b: 2 })
=> { a: 1, b: 2 }

也就是说,旧全量快照里的旧默认值 a: 1 会被误判成用户 override,后续对既有字段的默认值调整不会生效。数组和标量同样存在这个问题。

现有 legacy 测试只覆盖了“新版本新增一个旧快照里不存在的 key”,这个场景确实能通过 merge 自动补齐,但没有覆盖“已有 key 的默认值发生变化”。建议至少补一个上面的回归用例。

要满足 系统修改默认值 + 用户未修改该字段 => 使用新默认值 这个不变量,legacy -> sparse 这个 seam 需要补一个最小的来源/版本区别,例如:

  1. 新 sparse 格式加 schema/version 标记;
  2. 对未标记的 legacy 全量值,用发布时冻结的旧默认配置做一次 deepDiff(legacy, oldDefault),再持久化成带版本的 sparse override;或者保留 baseVersion / 可取得的旧 default 做三方判断。

如果没有旧默认值或版本/provenance 信息,那么仅凭当前的全量 JSON,原则上无法判断某个值是“用户主动改成这样”还是“只是旧默认遗留”,因此目前“旧版全量配置可无迁移兼容,并让后续默认调整自动生效”的表述并不成立。

Copy link
Copy Markdown
Collaborator Author

已修复这条迁移边界问题(commit a49e30930260b16d24de893dd4d6670b452ef914)。

  • 新写入的 JSON 配置使用带 format/version/overrides 的存储格式,旧版全量配置不会再与新版稀疏 override 混淆。
  • 旧版全量配置首次读取时,使用稀疏格式发布前冻结的默认配置计算 deepDiff,再串行迁移为稀疏 override;因此用户修改过的值会保留,新默认值也能生效。
  • 未知的稀疏格式版本会拒绝解码,避免静默产生错误配置。

已验证:pnpm test --run src/pkg/config/json_overrides.test.ts src/pkg/config/config.test.ts(39/39)和 pnpm run lint

@cyfung1031
cyfung1031 marked this pull request as ready for review August 11, 2026 07:20
@cyfung1031 cyfung1031 added the P1 🔥 重要但是不紧急的内容 label Aug 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

P1 🔥 重要但是不紧急的内容

Projects

None yet

Development

Successfully merging this pull request may close these issues.

配置 JSON 更新与用户偏好保留改善计划

1 participant