diff --git a/AGENTS.md b/AGENTS.md index c34f15c..6944407 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md — @ultime/studio > 给在本仓库工作的 AI 与协作者:入口、可写 / 只读、红线与验收。 -> 适用版本:0.2.x +> 适用版本:0.3.x ## 先读 diff --git a/CHANGELOG.md b/CHANGELOG.md index d74d2d5..a1b2718 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 0.3.0 - 2026-11-12 + +- `Diagnostic` 新增可选 `hint`;通用字段诊断补齐修复说明。 +- 契约测试新增 `inspect` / `preview` 的确定性、只读与抛错检查(`inspect/*` / `preview/*`)。 +- 补 `$version` 迁移与未知字段策略的导入测试;协议形状只增不改。 + ## 0.2.2 - 2026-11-11 - 发布产物默认压缩:`dist/*.js` 开启 `minify`,去掉格式与注释;`dist/*.d.ts` 保持可读,`sourcemap` 仍不随包发布。 diff --git a/README.md b/README.md index 50acf2d..c2ebd1d 100644 --- a/README.md +++ b/README.md @@ -78,8 +78,9 @@ const direct = await exportConfig(configurator, config) | `export()` | 规范化 → 校验 → 定义 `export()`;返回 `ExportOutcome`,无文件 IO / 下载 | | `serialize()` | 稳定 JSON(对象键排序),可直接落项目配置(字段由定义包决定),不含派生值 | | `toURL()` / `fromURL()` | 纯编解码,`#studio=v1.…`,版本随串走;不依赖 `btoa` / `Buffer` / localStorage | -| `inspect()` | 只读派生值,供上游展示「配置 → 派生」;不进 config、不进导出 | -| `preview()` | `PreviewModel.css` 是主路径,必须与 `export()` 同源;`vars` 只做兼容 | +| `inspect()` | 只读派生值,供上游展示「配置 → 派生」;不进 config、不进导出;契约测试检查确定性与不改写配置 | +| `preview()` | `PreviewModel.css` 是主路径,必须与 `export()` 同源;`vars` 只做兼容;契约测试检查确定性与不改写配置 | +| 诊断 | `{ level, code, message, path?, hint?, fix? }`;`hint` 是下一步怎么改,`fix` 是可审阅 patch | **四条硬约束**:先规范化再导出;导出确定性(同输入逐字节相同、lock 不含时间戳);预览与导出同源;运行时不决定布局与控件形态。 @@ -93,7 +94,7 @@ const direct = await exportConfig(configurator, config) ```ts import { assertContract } from '@ultime/studio' -await assertContract(configurator) // 字段自洽 / 默认值规范形 / 往返 / 默认值校验 / 导出确定性 +await assertContract(configurator) // 字段自洽 / 默认值规范形 / 往返 / 默认值校验 / inspect·preview 确定性与只读 / 导出确定性 ``` 定义包自己再补领域 golden 测试(同一份输入产物逐字节一致)。 @@ -131,8 +132,8 @@ pnpm test ## 版本与兼容 -- 当前 `0.2.x`;0.x 阶段 `ConfiguratorDefinition` 仍可能调整。 -- 0.2.2 发布产物默认压缩;协议与导出不变。 +- 当前 `0.3.x`;0.x 阶段 `ConfiguratorDefinition` 仍可能调整。 +- 0.3.0:诊断新增 `hint`,契约测试补 `inspect` / `preview` 的确定性与只读检查;协议形状只增不改。 - 破坏性变更写进 [CHANGELOG](./CHANGELOG.md),并在本节目说明迁移方式;协议变更先发 studio,再发依赖包。 - 零第三方运行时依赖。 diff --git a/SKILLS.md b/SKILLS.md index af524bf..ed4e158 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -1,8 +1,8 @@ # @ultime/studio · 可调用能力 > 定位:AI 可调用的协议、方法与错误语义。字段与预设内容由领域包定义,本文件不手抄。 -> 适用版本:0.2.x -> 同步基线:0.2.x(手写;导出面或协议变化时同批更新) +> 适用版本:0.3.x +> 同步基线:0.3.x(手写;导出面或协议变化时同批更新) --- @@ -24,14 +24,15 @@ import { | `createConfigurator(def)` | 定义 | 有状态控制器:`set` / `patch` / `reset` / `validate` / `export` / `serialize` / `toURL` / `fromURL` / `inspect` / `preview` | | `normalizeConfig(def, raw)` | 原始 JSON | 补默认、未知字段策略、迁移后的配置 | | `exportConfig(def, config)` | 配置 | `ExportOutcome`(无文件 IO) | -| `assertContract(def)` | 定义 | 契约断言:字段自洽 / 默认值 / 往返 / 确定性 | +| `assertContract(def)` | 定义 | 契约断言:字段自洽 / 默认值 / 往返 / `inspect`·`preview` 确定性与只读 / 导出确定性 | ## 错误语义 - `validate()` 返回诊断数组:类型 / 枚举 / 缺失 / `min`·`max` 合法域越界 / 领域阻断是 `error` 并阻断导出;`suggested` 建议区间与提示是 `warn` / `info`,不阻断。 +- 诊断形状 `{ level, code, message, path?, hint?, fix? }`;`hint` 是下一步怎么改,`fix` 是可审阅 patch。 - `export()` 不抛异常;失败时返回 `ok: false` 与诊断。 - 未知字段按 `unknownPolicy` 处理,只查对象键、不深入数组元素。 -- `$schema` / `$version` 视为元信息,不参与未知字段判定。 +- `$schema` / `$version` 视为元信息,不参与未知字段判定;`$version` 与定义不一致时必须有 `migrate`,否则抛 `config/version`。 ## 确定性 diff --git a/llms.txt b/llms.txt index 718f6e8..820afe6 100644 --- a/llms.txt +++ b/llms.txt @@ -1,6 +1,6 @@ # @ultime/studio · AI 索引 -适用版本:0.2.x +适用版本:0.3.x ## 去哪找 diff --git a/package.json b/package.json index 79cca97..180cd48 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@ultime/studio", - "version": "0.2.2", + "version": "0.3.0", "description": "Ultime · studio:配置器无头运行时(定义 / 状态 / 规范化 / 诊断 / 预览模型 / 导出清单),纯 TS、零依赖、不碰 DOM 与文件系统", "keywords": [ "configuration", @@ -42,8 +42,8 @@ }, "scripts": { "build": "vp pack", - "check": "vp check && checks run && pnpm cycles", - "checks": "checks run", + "check": "vp check && ultime-checks run && pnpm cycles", + "checks": "ultime-checks run", "cycles": "vp lint --import-plugin --deny=import/no-cycle src", "test": "vp test", "size": "size-limit --json", @@ -54,7 +54,7 @@ "@arethetypeswrong/core": "^0.18.5", "@size-limit/preset-small-lib": "^14.3.0", "@types/node": "^22.20.5", - "@ultime/checks": "^0.2.0", + "@ultime/checks": "^0.3.0", "publint": "^0.3.25", "size-limit": "^14.3.0", "typescript": "^5.9.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ded1342..ad61ffc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -176,8 +176,8 @@ importers: specifier: ^22.20.5 version: 22.20.5 '@ultime/checks': - specifier: ^0.2.0 - version: 0.2.0 + specifier: ^0.3.0 + version: 0.3.0 publint: specifier: ^0.3.25 version: 0.3.25 @@ -697,8 +697,8 @@ packages: '@types/node@22.20.5': resolution: {integrity: sha512-U2+DNr+wSjpsTS/wZGYHq7GcwfuSmKiKvoPvK22zwTlRhU91yOniN4qRR5KhIjvif7ysw/dz/hKmfDH0Ris4aA==} - '@ultime/checks@0.2.0': - resolution: {integrity: sha512-R3xPgd4AsHDrHUtCyzYNVgLg0ImdxpL5GSh5TpaY6x+Y+cX6tsbHQaX48fhPF5pHMOGAvgUjg008wxahEOjjNg==} + '@ultime/checks@0.3.0': + resolution: {integrity: sha512-0arXp70xHWA6iba9YaCctOku7hN8bMhNOH2EMHOS+z6rYdGb2tctGM9DUdAqJNH1JT49RFrXXKki7/l68SuVyA==} engines: {node: '>=22.18'} hasBin: true @@ -1707,7 +1707,7 @@ snapshots: dependencies: undici-types: 6.21.0 - '@ultime/checks@0.2.0': {} + '@ultime/checks@0.3.0': {} '@vitest/browser-preview@5.1.0(@voidzero-dev/vite-plus-core@1.5.1(@arethetypeswrong/core@0.18.5)(@types/node@22.20.5)(publint@0.3.25)(typescript@5.9.3)(unplugin-unused@0.6.0))(vitest@5.1.0)': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 3341914..c893174 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -10,4 +10,6 @@ minimumReleaseAgeExclude: - '@voidzero-dev/vite-plus-win32-arm64-msvc@1.5.1' - '@voidzero-dev/vite-plus-win32-x64-msvc@1.5.1' - vite-plus@1.5.1 - - '@ultime/checks@0.1.1 || 0.2.0' + - '@ultime/checks@0.1.1' + - '@ultime/checks@0.2.0' + - '@ultime/checks@0.3.0' diff --git a/rules/01-architecture.md b/rules/01-architecture.md index c65c1de..c24fa00 100644 --- a/rules/01-architecture.md +++ b/rules/01-architecture.md @@ -1,7 +1,7 @@ # 01 · 架构与依赖 > 定位:studio 的三层分工、依赖方向与目录职责。 -> 适用版本:0.2.x +> 适用版本:0.3.x > 关系:规则索引见 [`README`](./README.md);协议细节见 [`02-contract`](./02-contract.md)。 --- diff --git a/rules/02-contract.md b/rules/02-contract.md index 9d78bb4..a81e301 100644 --- a/rules/02-contract.md +++ b/rules/02-contract.md @@ -1,7 +1,7 @@ # 02 · 定义协议与确定性 > 定位:定义协议、字段校验、诊断与导出的稳定口径。 -> 适用版本:0.2.x +> 适用版本:0.3.x > 关系:架构见 [`01-architecture`](./01-architecture.md);测试见 [`03-testing`](./03-testing.md)。 --- @@ -27,6 +27,12 @@ | `inspect()` | 只读派生值;不进 config、不进导出 | | `preview()` | `PreviewModel.css` 是主路径,必须与 `export()` 同源 | +## 诊断 + +- 形状:`{ level, code, message, path?, hint?, fix? }`;`path` 是 JSON Pointer,`hint` 写下一步怎么改,`fix` 给可审阅的 patch。 +- 级别:类型 / 枚举 / 缺失 / 合法域越界 / 领域阻断是 `error`;建议区间与提示是 `warn` / `info`。 +- 通用字段诊断自带 `hint`;领域 `validate()` 有明确修法时也应给 `hint`,不确定就不编。 + ## 四条硬约束 1. 先规范化再导出; @@ -38,3 +44,4 @@ - 在 `set()` 里顺手重算诊断:调用方无法控制节奏,历史与 patch 也会失真。 - `preview()` 自己拼一套 CSS:与导出不同源,预览通过不代表导出可用。 +- `preview()` / `inspect()` 改写 config:只读入口不得有副作用。 diff --git a/rules/03-testing.md b/rules/03-testing.md index 3c555b2..8984b28 100644 --- a/rules/03-testing.md +++ b/rules/03-testing.md @@ -1,7 +1,7 @@ # 03 · 测试与验收 > 定位:改 studio 时跑什么、什么算通过。 -> 适用版本:0.2.x +> 适用版本:0.3.x > 关系:协议见 [`02-contract`](./02-contract.md);发布检查见 [`04-release`](./04-release.md)。 --- diff --git a/rules/04-release.md b/rules/04-release.md index e42213a..09281e8 100644 --- a/rules/04-release.md +++ b/rules/04-release.md @@ -1,7 +1,7 @@ # 04 · 版本与发布 > 定位:studio 的编译 ESM 形态、发布顺序与检查。 -> 适用版本:0.2.x +> 适用版本:0.3.x > 关系:测试见 [`03-testing`](./03-testing.md);发布由仓库 owner 按本清单执行。 --- diff --git a/rules/README.md b/rules/README.md index 1c9de2d..882cc2e 100644 --- a/rules/README.md +++ b/rules/README.md @@ -1,7 +1,7 @@ # studio · 开发规则 > 定位:本包开发规范的索引。只写判断、边界与反例;协议与 API 以 README 和 `src/definition.ts` 为准。 -> 适用版本:0.2.x +> 适用版本:0.3.x > owner:维护者(repo owner)· 复核:2026-11-11 · 到期降级为参考 > 关系:入口见 [`AGENTS.md`](../AGENTS.md);怎么用见 [`README.md`](../README.md)。 diff --git a/src/diagnostics.ts b/src/diagnostics.ts index a6a6712..c512cc7 100644 --- a/src/diagnostics.ts +++ b/src/diagnostics.ts @@ -22,48 +22,65 @@ function isOptionValue(field: FieldSpec, value: unknown): boolean { function checkField(field: FieldSpec, value: unknown): Diagnostic[] { const at = field.path - const push = (code: string, message: string, level: DiagnosticLevel = 'error'): Diagnostic => ({ + const push = (code: string, message: string, level: DiagnosticLevel = 'error', hint?: string): Diagnostic => ({ level, code: `field/${code}`, message: `${field.label}:${message}`, path: at, + ...(hint === undefined ? {} : { hint }), }) if (isMissing(value)) { - return field.optional ? [] : [push('missing', '必填项缺失')] + return field.optional ? [] : [push('missing', '必填项缺失', 'error', '填写该字段,或把字段标为 optional')] } switch (field.type) { case 'number': case 'slider': case 'hue': { - if (typeof value !== 'number' || !Number.isFinite(value)) return [push('type', '应为数字')] + if (typeof value !== 'number' || !Number.isFinite(value)) return [push('type', '应为数字', 'error', '改为数字')] + const bounds = `${field.min ?? '−∞'}–${field.max ?? '∞'}` const hard: Diagnostic[] = [] - if (field.min !== undefined && value < field.min) hard.push(push('range', `不得小于 ${field.min}`)) - if (field.max !== undefined && value > field.max) hard.push(push('range', `不得大于 ${field.max}`)) + if (field.min !== undefined && value < field.min) { + hard.push(push('range', `不得小于 ${field.min}`, 'error', `调整到 ${bounds}`)) + } + if (field.max !== undefined && value > field.max) { + hard.push(push('range', `不得大于 ${field.max}`, 'error', `调整到 ${bounds}`)) + } if (hard.length) return hard const advice: Diagnostic[] = [] const suggested = field.suggested if (suggested?.min !== undefined && value < suggested.min) { - advice.push(push('suggested', `建议不小于 ${suggested.min}`, 'warn')) + advice.push( + push('suggested', `建议不小于 ${suggested.min}`, 'warn', `参考区间 ${suggested.min}–${suggested.max ?? '∞'}`), + ) } if (suggested?.max !== undefined && value > suggested.max) { - advice.push(push('suggested', `建议不大于 ${suggested.max}`, 'warn')) + advice.push( + push( + 'suggested', + `建议不大于 ${suggested.max}`, + 'warn', + `参考区间 ${suggested.min ?? '−∞'}–${suggested.max}`, + ), + ) } return advice } case 'switch': - return typeof value === 'boolean' ? [] : [push('type', '应为布尔值')] + return typeof value === 'boolean' ? [] : [push('type', '应为布尔值', 'error', '改为 true / false')] case 'text': case 'color': - return typeof value === 'string' ? [] : [push('type', '应为字符串')] + return typeof value === 'string' ? [] : [push('type', '应为字符串', 'error', '改为字符串')] case 'select': - return isOptionValue(field, value) ? [] : [push('enum', '取值不在选项内')] + return isOptionValue(field, value) ? [] : [push('enum', '取值不在选项内', 'error', '从 options 中选一个')] case 'multi-select': { - if (!Array.isArray(value)) return [push('type', '应为数组')] + if (!Array.isArray(value)) return [push('type', '应为数组', 'error', '改为数组')] const invalid = value.filter((item) => !isOptionValue(field, item)) - return invalid.length ? [push('enum', `取值不在选项内:${invalid.join('、')}`)] : [] + return invalid.length + ? [push('enum', `取值不在选项内:${invalid.join('、')}`, 'error', '移除不在 options 中的取值')] + : [] } case 'custom': return [] diff --git a/src/testing.ts b/src/testing.ts index 48c51a6..20091c4 100644 --- a/src/testing.ts +++ b/src/testing.ts @@ -73,6 +73,35 @@ function collectDefinitionIssues(definition: ConfiguratorDefinition( + name: 'inspect' | 'preview', + read: ((config: TConfig) => unknown) | undefined, + config: TConfig, + issue: (code: string, message: string) => void, +): void { + if (!read) return + + const before = stableStringify(config) + let first: string + try { + first = stableStringify(read(config)) + } catch (error) { + issue(`${name}/error`, `${name}() 抛错:${(error as Error).message}`) + return + } + + if (stableStringify(config) !== before) issue(`${name}/mutation`, `${name}() 改写了配置`) + + try { + if (stableStringify(read(config)) !== first) { + issue(`${name}/deterministic`, `${name}() 同输入两次结果不一致`) + } + } catch (error) { + issue(`${name}/error`, `${name}() 第二次调用抛错:${(error as Error).message}`) + } +} + async function collectBehaviorIssues( definition: ConfiguratorDefinition, options: ContractOptions, @@ -122,6 +151,9 @@ async function collectBehaviorIssues( issue('validate/defaults', `默认值校验失败:${(error as Error).message}`) } + checkReadOnly('inspect', definition.inspect?.bind(definition), defaults, issue) + checkReadOnly('preview', definition.preview?.bind(definition), defaults, issue) + if (options.export !== false) { try { const first = await exportConfig(definition, defaults) diff --git a/src/types.ts b/src/types.ts index 7f7b426..408d719 100644 --- a/src/types.ts +++ b/src/types.ts @@ -38,6 +38,8 @@ export interface Diagnostic { message: string /** 字段锚点,JSON Pointer */ path?: string + /** 可执行的修复说明;比 message 更接近「下一步做什么」 */ + hint?: string fix?: DiagnosticFix } diff --git a/tests/contract.test.ts b/tests/contract.test.ts index 8d0e4b1..fb38096 100644 --- a/tests/contract.test.ts +++ b/tests/contract.test.ts @@ -69,6 +69,42 @@ describe('checkContract', () => { expect(report.issues.map((item) => item.code)).toContain('export/deterministic') }) + it('preview 不确定会被抓出来', async () => { + let calls = 0 + const definition = defineConfigurator({ + ...demoDefinition, + preview: (config) => ({ css: `calls:${(calls += 1)}; hue:${config.theme.accent.hue}` }), + }) + + const report = await checkContract(definition, { export: false }) + expect(report.issues.map((item) => item.code)).toContain('preview/deterministic') + }) + + it('preview 改写配置会被抓出来', async () => { + const definition = defineConfigurator({ + ...demoDefinition, + preview: (config) => { + config.meta.name = 'mutated' + return { css: '' } + }, + }) + + const report = await checkContract(definition, { export: false }) + expect(report.issues.map((item) => item.code)).toContain('preview/mutation') + }) + + it('inspect 抛错会被抓出来', async () => { + const definition = defineConfigurator({ + ...demoDefinition, + inspect: () => { + throw new Error('boom') + }, + }) + + const report = await checkContract(definition, { export: false }) + expect(report.issues.map((item) => item.code)).toContain('inspect/error') + }) + it('assertContract 在不合格时抛错', async () => { const definition = defineConfigurator({ ...demoDefinition, id: '' }) await expect(assertContract(definition, { export: false })).rejects.toThrow(/adapter 契约未通过/) diff --git a/tests/normalize.test.ts b/tests/normalize.test.ts index e4e8dac..75fce0c 100644 --- a/tests/normalize.test.ts +++ b/tests/normalize.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vite-plus/test' -import { defineConfigurator, normalizeConfig, StudioError, type ConfiguratorDefinition } from '../src/index.ts' +import { + defineConfigurator, + deserializeConfig, + normalizeConfig, + StudioError, + type ConfiguratorDefinition, +} from '../src/index.ts' import { demoDefaults, demoDefinition, type DemoConfig } from './fixtures/theme.ts' const withPolicy = (policy: 'reject' | 'strip' | 'allow'): ConfiguratorDefinition => ({ @@ -87,6 +93,23 @@ describe('normalizeConfig', () => { } }) + it('JSON 根上的 $version 与定义一致时不迁移,且不进入配置', () => { + const config = deserializeConfig(demoDefinition, JSON.stringify({ $version: '1.0.0', meta: { name: 'x' } })) + expect(config.meta.name).toBe('x') + expect('$version' in config).toBe(false) + }) + + it('JSON 根上的 $version 与定义不一致且无 migrate 时抛错', () => { + expect(() => deserializeConfig(demoDefinition, JSON.stringify({ $version: '0.9.0' }))).toThrow(StudioError) + }) + + it('反序列化按 unknownPolicy 处理未知字段', () => { + const text = JSON.stringify({ nope: 1 }) + expect(() => deserializeConfig(demoDefinition, text)).toThrow(StudioError) + expect('nope' in deserializeConfig(withPolicy('strip'), text)).toBe(false) + expect((deserializeConfig(withPolicy('allow'), text) as unknown as Record).nope).toBe(1) + }) + it('definition.normalize 在补默认之后执行', () => { const definition = defineConfigurator({ ...demoDefinition,