搭起项目骨架,验证配置文件往返

核心是确认「改配置而不弄乱文件」这件事成立:新增一路入站之后,其余 52 行逐字节不变。

搬进来的改写引擎用默认输出参数时会重排整个文件——行尾注释的对齐空格消失、流式数组被加上内边距——
连根本没改动的段落也一起波及。不修的话「改一处只动一处」这个承诺直接落空,所以关掉了它的自动排版,
并在写回之后补了一步逐行还原。
This commit is contained in:
im0o
2026-08-10 02:43:04 +08:00
commit f7db96fb0e
13 changed files with 1906 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# config-editor 的来源
复制自 **metacubexd**(GitHub `MetaCubeX/metacubexd`,MIT 许可),路径 `packages/config-editor/src/`,取自版本 **1.271.0**。
采用它的理由和已知缺陷记在决策记录 0002。决策记录**不在本仓库里**,它们放在仓库外面的工作区目录 `docs/adr/`(与 `patchbay/` 平级)。
## 本地改动
对照上游逐条记录,方便将来同步上游修复时区分「我们改的」和「上游变的」。
| 文件 | 改动 | 原因 |
|---|---|---|
| `index.test.ts` | 测试框架的导入从 `vitest` 换成 `bun:test` | 本项目用 Bun 自带的测试运行器,不引入 vitest。两者的 `describe` / `it` / `expect` 接口一致,用例本身一行没动 |
| `index.ts` | `stringifyTouched` 结尾的 `String(doc)` 换成 `doc.toString({ flowCollectionPadding: false, lineWidth: 0 })` | 上游用默认输出参数,会把 `[a, b]` 改写成 `[ a, b ]`,并且在超过 80 列时折行——目标机器的订阅地址就够长。这两样都会让没被改动的段落出现在 diff 里 |
## 配套的本项目代码
`shared/yaml-cosmetics.ts` 里的 `restoreInlineCommentAlignment` 不属于上游,是本项目补的一步。`yaml` 库把行尾注释的文字存在节点上,但不存注释前的对齐空格,所以重新输出时 `interval: 86400 # 说明` 会变成 `interval: 86400 # 说明`。那个函数在写回之后逐行比对,把只差对齐空格的行还原成原文。
加上这两处之后的实测结果记在决策记录 0002 的「实测」一节。要自己重跑一遍:`bun run scripts/roundtrip.ts <配置文件> <输出目录>`,再拿输出跟源文件比对。
## 同步上游
上游修了 bug 想同步时:拉取 metacubexd 对应版本的 `packages/config-editor/src/index.ts`,与本目录的同名文件比对,把上表里的本地改动重新应用一遍。
+197
View File
@@ -0,0 +1,197 @@
import { describe, expect, it } from 'bun:test'
import {
applyPatch,
ConfigDocumentError,
diffDocument,
findResourceReferences,
openDocument,
splitRuleFields,
validateDocument,
} from './index'
describe('config editor document', () => {
it('rejects invalid YAML and non-mapping roots', () => {
expect(() => openDocument('dns: [')).toThrow(ConfigDocumentError)
expect(() => openDocument('- one\n- two\n')).toThrow(ConfigDocumentError)
})
it('preserves untouched section comments while replacing touched sections', () => {
const base = `# header\nmode: rule # keep\ndns:\n enable: true\n`
const draft = `mode: rule\ndns:\n enable: false\n nameserver: [1.1.1.1]\n`
const result = applyPatch(base, diffDocument(base, draft))
expect(result.conflicts).toEqual([])
expect(result.document.yaml).toContain('# header')
expect(result.document.yaml).toContain('mode: rule # keep')
expect(result.document.data.dns).toEqual({
enable: false,
nameserver: ['1.1.1.1'],
})
})
it('upserts, deletes, and reorders named resources', () => {
const base = `proxies:\n - { name: a, type: ss, server: old }\n - { name: b, type: direct }\n`
const draft = `proxies:\n - { name: c, type: direct }\n - { name: a, type: ss, server: new }\n`
const patch = diffDocument(base, draft)
expect(patch.operations.map((op) => op.op)).toEqual([
'upsert',
'delete',
'upsert',
'order',
])
const result = applyPatch(base, patch)
expect(result.conflicts).toEqual([])
expect(result.document.data.proxies).toEqual([
{ name: 'c', type: 'direct' },
{ name: 'a', type: 'ss', server: 'new' },
])
})
it('rebases an independent change and reports an overlapping change', () => {
const base = `proxies:\n - { name: a, type: ss, server: one }\n - { name: b, type: direct }\n`
const draft = `proxies:\n - { name: a, type: ss, server: two }\n - { name: b, type: direct }\n`
const patch = diffDocument(base, draft)
const independent = applyPatch(`${base}dns:\n enable: true\n`, patch)
expect(independent.conflicts).toEqual([])
const changed = applyPatch(
`proxies:\n - { name: a, type: ss, server: upstream }\n - { name: b, type: direct }\n`,
patch,
)
expect(changed.conflicts).toHaveLength(1)
expect(changed.conflicts[0]?.reason).toBe('changed')
})
it('validates duplicate resources and broken references', () => {
const diagnostics = validateDocument({
proxies: [
{ name: 'same', type: 'direct' },
{ name: 'same', type: 'direct' },
],
'proxy-groups': [{ name: 'group', type: 'select', proxies: ['missing'] }],
rules: ['MATCH,missing'],
})
expect(diagnostics.map((item) => item.code)).toEqual([
'duplicate-name',
'missing-reference',
'missing-reference',
])
})
it('addresses rules by content identity instead of array index', () => {
const base = `rules:\n - DOMAIN,one.test,DIRECT\n - MATCH,DIRECT\n`
const draft = `rules:\n - DOMAIN,one.test,REJECT\n - MATCH,DIRECT\n`
const patch = diffDocument(base, draft)
expect(patch.operations[0]?.op).toBe('sequence-replace')
const refreshed = `rules:\n - DOMAIN,new.test,DIRECT\n - DOMAIN,one.test,DIRECT\n - MATCH,DIRECT\n`
const result = applyPatch(refreshed, patch)
expect(result.conflicts).toEqual([])
expect(result.document.data.rules).toEqual([
'DOMAIN,new.test,DIRECT',
'DOMAIN,one.test,REJECT',
'MATCH,DIRECT',
])
})
it('reports a conflict when a reorder anchor disappeared upstream', () => {
const base = `proxies:\n - { name: a, type: direct }\n - { name: b, type: direct }\n`
const draft = `proxies:\n - { name: b, type: direct }\n - { name: a, type: direct }\n`
const patch = diffDocument(base, draft)
const result = applyPatch(
`proxies:\n - { name: b, type: direct }\n`,
patch,
)
expect(result.conflicts).toEqual([
expect.objectContaining({ reason: 'missing' }),
])
})
it('splits logical and quoted rules only at top-level commas', () => {
expect(
splitRuleFields(
'AND,((DOMAIN,one.test),(NETWORK,UDP)),Proxy Group,no-resolve',
),
).toEqual([
'AND',
'((DOMAIN,one.test),(NETWORK,UDP))',
'Proxy Group',
'no-resolve',
])
expect(splitRuleFields('DOMAIN,"one,two.test",DIRECT')).toEqual([
'DOMAIN',
'"one,two.test"',
'DIRECT',
])
})
it('finds named proxy references across routing and network sections', () => {
const data = openDocument(`
proxies:
- { name: chained, type: direct, dialer-proxy: target }
proxy-groups:
- { name: group, type: select, proxies: [target] }
proxy-providers:
remote: { type: http, url: https://example.test, proxy: target }
rule-providers:
rules: { type: http, url: https://example.test, proxy: target }
listeners:
- { name: inbound, type: mixed, port: 7890, proxy: target }
tunnels:
- { network: tcp, address: 127.0.0.1:1, target: example.test:2, proxy: target }
ntp: { enable: true, dialer-proxy: target }
sub-rules:
child:
- MATCH,target
rules:
- AND,((DOMAIN,one.test),(NETWORK,UDP)),target
- SUB-RULE,(NETWORK,TCP),target
`).data
expect(
findResourceReferences(data, 'target').map((item) => item.kind),
).toEqual([
'proxy-group-member',
'rule-policy',
'rule-policy',
'dialer-proxy',
'dialer-proxy',
'provider-proxy',
'provider-proxy',
'listener-proxy',
'tunnel-proxy',
])
})
it('does not treat unrelated same-name strings as proxy references', () => {
const data = openDocument(`
proxies:
- { name: target, type: direct, server: target }
hosts: { target: 127.0.0.1 }
rules:
- DOMAIN,target,DIRECT
`).data
expect(findResourceReferences(data, 'target')).toEqual([])
})
it('validates missing references in sub-rules and dialer fields', () => {
const diagnostics = validateDocument({
proxies: [{ name: 'node', type: 'direct', 'dialer-proxy': 'missing' }],
'sub-rules': { child: ['MATCH,missing'] },
rules: ['SUB-RULE,(NETWORK,TCP),absent', 'MATCH,GLOBAL'],
})
expect(diagnostics).toEqual([
expect.objectContaining({
path: ['sub-rules', 'child', 0],
severity: 'warning',
}),
expect.objectContaining({
path: ['proxies', 0, 'dialer-proxy'],
severity: 'error',
}),
expect.objectContaining({
path: ['rules', 0],
message: 'Unknown sub-rule: absent',
severity: 'error',
}),
])
})
})
+1084
View File
File diff suppressed because it is too large Load Diff