Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AGENTS.md — @ultime/studio

> 给在本仓库工作的 AI 与协作者:入口、可写 / 只读、红线与验收。
> 适用版本:0.2.x
> 适用版本:0.3.x

## 先读

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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` 仍不随包发布。
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 不含时间戳);预览与导出同源;运行时不决定布局与控件形态。

Expand All @@ -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 测试(同一份输入产物逐字节一致)。
Expand Down Expand Up @@ -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,再发依赖包。
- 零第三方运行时依赖。

Expand Down
9 changes: 5 additions & 4 deletions SKILLS.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# @ultime/studio · 可调用能力

> 定位:AI 可调用的协议、方法与错误语义。字段与预设内容由领域包定义,本文件不手抄。
> 适用版本:0.2.x
> 同步基线:0.2.x(手写;导出面或协议变化时同批更新)
> 适用版本:0.3.x
> 同步基线:0.3.x(手写;导出面或协议变化时同批更新)

---

Expand All @@ -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`。

## 确定性

Expand Down
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# @ultime/studio · AI 索引

适用版本:0.2.x
适用版本:0.3.x

## 去哪找

Expand Down
8 changes: 4 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@ultime/studio",
"version": "0.2.2",
"version": "0.3.0",
"description": "Ultime · studio:配置器无头运行时(定义 / 状态 / 规范化 / 诊断 / 预览模型 / 导出清单),纯 TS、零依赖、不碰 DOM 与文件系统",
"keywords": [
"configuration",
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
2 changes: 1 addition & 1 deletion rules/01-architecture.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 01 · 架构与依赖

> 定位:studio 的三层分工、依赖方向与目录职责。
> 适用版本:0.2.x
> 适用版本:0.3.x
> 关系:规则索引见 [`README`](./README.md);协议细节见 [`02-contract`](./02-contract.md)。

---
Expand Down
9 changes: 8 additions & 1 deletion rules/02-contract.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 02 · 定义协议与确定性

> 定位:定义协议、字段校验、诊断与导出的稳定口径。
> 适用版本:0.2.x
> 适用版本:0.3.x
> 关系:架构见 [`01-architecture`](./01-architecture.md);测试见 [`03-testing`](./03-testing.md)。

---
Expand All @@ -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. 先规范化再导出;
Expand All @@ -38,3 +44,4 @@

- 在 `set()` 里顺手重算诊断:调用方无法控制节奏,历史与 patch 也会失真。
- `preview()` 自己拼一套 CSS:与导出不同源,预览通过不代表导出可用。
- `preview()` / `inspect()` 改写 config:只读入口不得有副作用。
2 changes: 1 addition & 1 deletion rules/03-testing.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 03 · 测试与验收

> 定位:改 studio 时跑什么、什么算通过。
> 适用版本:0.2.x
> 适用版本:0.3.x
> 关系:协议见 [`02-contract`](./02-contract.md);发布检查见 [`04-release`](./04-release.md)。

---
Expand Down
2 changes: 1 addition & 1 deletion rules/04-release.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# 04 · 版本与发布

> 定位:studio 的编译 ESM 形态、发布顺序与检查。
> 适用版本:0.2.x
> 适用版本:0.3.x
> 关系:测试见 [`03-testing`](./03-testing.md);发布由仓库 owner 按本清单执行。

---
Expand Down
2 changes: 1 addition & 1 deletion rules/README.md
Original file line number Diff line number Diff line change
@@ -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)。

Expand Down
41 changes: 29 additions & 12 deletions src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 []
Expand Down
32 changes: 32 additions & 0 deletions src/testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,35 @@ function collectDefinitionIssues<TConfig>(definition: ConfiguratorDefinition<TCo
return issues
}

/** 只读派生入口(inspect / preview)必须确定、且不改写配置。 */
function checkReadOnly<TConfig>(
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<TConfig>(
definition: ConfiguratorDefinition<TConfig>,
options: ContractOptions,
Expand Down Expand Up @@ -122,6 +151,9 @@ async function collectBehaviorIssues<TConfig>(
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)
Expand Down
2 changes: 2 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface Diagnostic {
message: string
/** 字段锚点,JSON Pointer */
path?: string
/** 可执行的修复说明;比 message 更接近「下一步做什么」 */
hint?: string
fix?: DiagnosticFix
}

Expand Down
36 changes: 36 additions & 0 deletions tests/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<DemoConfig>({
...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<DemoConfig>({
...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<DemoConfig>({
...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<DemoConfig>({ ...demoDefinition, id: '' })
await expect(assertContract(definition, { export: false })).rejects.toThrow(/adapter 契约未通过/)
Expand Down
Loading
Loading