fix(invariants): replay histories and pack companions
This commit is contained in:
+2
-2
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-19-package-invariant-runtime-contracts.md: 4063acedfae1586e6a85e3b14a45bafe894a7a1f
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: a74d47329116438c7adadb9c05bd0f3ca9549f56
|
||||
2026-07-19-package-invariant-runtime-contracts.md: 86064ed36866675424826f60db15831879e86ea1
|
||||
2026-07-19-package-invariant-runtime-contracts.zh.md: 6194e05d75dfce8cb5f7833b1d2e6f86abeb46f3
|
||||
+2
-2
@@ -53,13 +53,13 @@ The current 100-package workspace has 21 executable companions and 79 justified
|
||||
| `dsh-tool-todo` | Durable whole-list snapshots use unique trimmed items, closed statuses, and at most one active item. |
|
||||
| `dsh-time-context` | Plugin-attributed clock readings agree with the session's open turn, next pre-step position, and elapsed baseline; rendered time parses and does not postdate its event. |
|
||||
|
||||
Session-backed companions reconstruct their trace from existing durable events when they load. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state.
|
||||
Session-backed companions validate existing durable events when they load, using the prefix preceding each candidate where the relationship depends on event order. Other checks observe the authoritative live event boundary or mutable service result. Validation runs before publication where accepting an invalid event would otherwise commit bad state.
|
||||
|
||||
### Repository gate and tests
|
||||
|
||||
`verify-package-invariants` discovers every workspace package and enforces companion source, exact-name registration, named-only Loader shape, `./invariant` exports, publication files, dependencies, TypeScript references, and bundle entries. Its AST rule rejects generated markers, default exports, and unexplained empty installers. A non-empty installer must accept and use the failure reporter, and registration must pass that checked local `install` function. The gate deliberately does not infer semantic quality from method names or helper calls.
|
||||
|
||||
Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate imports every compiled `./invariant` self-reference under plain Node and repeats that Loader-shape check. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
|
||||
Vitest mounts `InvariantService` with `{ enabled: true }` for every package test topology and loads the owning companion. The invariant subpath path mapping resolves source companions instead of stale built output. Focused suites cover every executable companion's valid and invalid observations, and the exhaustive topology runs every source companion through the real Loader namespace normalization. An artifact gate stages each package's exact `npm pack` file inventory, imports its compiled `./invariant` self-reference under plain Node, and repeats that Loader-shape check, so an unpublished shared runtime chunk fails before release. Tests that synthesize event streams must produce a valid surrounding lifecycle unless the test is intentionally asserting a violation.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
+2
-2
@@ -53,13 +53,13 @@ Status: implemented
|
||||
| `dsh-tool-todo` | 持久化全量 snapshot 使用唯一且已 trim 的条目、封闭 status,并且最多有一个活动条目。 |
|
||||
| `dsh-time-context` | 标注插件来源的时钟 reading 必须匹配 session 当前打开的 turn、下一个 step 开始前的位置和 elapsed baseline;渲染时间必须可解析,且不得晚于对应事件。 |
|
||||
|
||||
基于 session 的 companion 在加载时从已有持久化事件重建 trace。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。
|
||||
基于 session 的 companion 在加载时验证已有持久化事件;关系依赖事件顺序时,会使用每个候选事件之前的事件前缀。其他检查观测权威 live event 边界或可变服务结果。如果接受无效事件会提交错误状态,验证就在发布前执行。
|
||||
|
||||
### 仓库门禁与测试
|
||||
|
||||
`verify-package-invariants` 发现每个 workspace 包,并强制 companion 源文件、完整名称注册、仅含具名 export 的 Loader 形状、`./invariant` export、发布文件、依赖、TypeScript reference 和 bundle entry 完整。其 AST 规则拒绝生成标记、默认导出和没有解释的空安装器。非空安装器必须接收并使用失败报告器,注册时还必须传入该经检查的本地 `install` 函数。门禁不会通过方法名或 helper 调用推断语义质量。
|
||||
|
||||
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁在 plain Node 下导入每个已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
|
||||
Vitest 为每个包测试拓扑使用 `{ enabled: true }` 挂载 `InvariantService`,并加载所有者 companion。不变量 subpath 的 path mapping 会解析源 companion,而不是陈旧的构建输出。聚焦 suite 覆盖每个可执行 companion 的有效和无效观测;穷举拓扑通过真实 Loader 命名空间归一化运行每个源 companion。产物门禁会按每个包的精确 `npm pack` 文件清单暂存文件,在 plain Node 下导入该包已编译的 `./invariant` 自引用,并重复执行该 Loader 形状检查;这样,未发布的共享运行时分片会在正式发布前导致门禁失败。合成事件流的测试必须构造有效的外围生命周期,除非测试本身就是在断言违规。
|
||||
|
||||
## 考虑过的替代方案
|
||||
|
||||
|
||||
@@ -306,6 +306,7 @@ flowchart TD
|
||||
pkg_user_interaction --> pkg_llm
|
||||
pkg_time_context --> pkg_agent
|
||||
pkg_time_context --> pkg_invariants
|
||||
pkg_time_context --> pkg_session
|
||||
pkg_tasks --> pkg_agent
|
||||
pkg_tasks --> pkg_brand
|
||||
pkg_tasks --> pkg_invariants
|
||||
@@ -660,7 +661,7 @@ flowchart TD
|
||||
| [`commands`](../packages/ui/commands) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope) |
|
||||
| [`user-approval`](../packages/ui/user-approval) | `ui` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) |
|
||||
| [`user-interaction`](../packages/ui/user-interaction) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants) |
|
||||
| [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) |
|
||||
| [`tasks`](../packages/tasks/tasks) | `tasks` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) |
|
||||
| [`workflow`](../packages/workflow/workflow) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) |
|
||||
| [`tools`](../packages/core/tools) | `core` | [`agent`](../packages/core/agent), [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`user-approval`](../packages/ui/user-approval) |
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-session": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -19,10 +19,10 @@ export const name = 'time-context-invariant'
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Derive the pre-step position at which a time-context reading may append. */
|
||||
function preparationPosition(session: Session, fail: InvariantFailure): { turn: number; step: number } {
|
||||
function preparationPosition(history: readonly SessionEvent[], fail: InvariantFailure): { turn: number; step: number } {
|
||||
const currentTurnEvents: SessionEvent[] = []
|
||||
let openTurn: number | undefined
|
||||
for (const event of session.events.slice().reverse()) {
|
||||
for (const event of history.slice().reverse()) {
|
||||
if (event.type === 'turn/end') {
|
||||
fail('time-context reading must be appended inside an open turn')
|
||||
}
|
||||
@@ -47,7 +47,7 @@ function preparationPosition(session: Session, fail: InvariantFailure): { turn:
|
||||
|
||||
/** Validate one plugin-attributed time reading against its session position and timestamp. */
|
||||
function validateReading(
|
||||
session: Session,
|
||||
history: readonly SessionEvent[],
|
||||
event: SessionEvent<'context/message'>,
|
||||
fail: InvariantFailure,
|
||||
): void {
|
||||
@@ -62,7 +62,7 @@ function validateReading(
|
||||
if (!Number.isSafeInteger(turn) || turn < 1 || !Number.isSafeInteger(step) || step < 1) {
|
||||
fail('time-context turn and step must be positive safe integers')
|
||||
}
|
||||
const expected = preparationPosition(session, fail)
|
||||
const expected = preparationPosition(history, fail)
|
||||
if (turn !== expected.turn || step !== expected.step) {
|
||||
fail(`time-context reading names turn ${turn}/step ${step}, expected turn ${expected.turn}/step ${expected.step}`)
|
||||
}
|
||||
@@ -80,17 +80,30 @@ function validateReading(
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation for plugin-attributed context readings. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Validate all package-owned readings already present in one session. */
|
||||
function validateSession(session: Session, fail: InvariantFailure): void {
|
||||
for (const [index, event] of session.events.entries()) {
|
||||
if (event.type !== 'context/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) continue
|
||||
validateReading(session.events.slice(0, index), event, fail)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation for loaded and newly appended context readings. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) validateSession(session, fail)
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const [session, event] = args as [Session, SessionEvent]
|
||||
if (event.type !== 'context/message'
|
||||
|| event.data.source.kind !== 'plugin'
|
||||
|| event.data.source.plugin !== SOURCE_NAME) return
|
||||
validateReading(session, event, fail)
|
||||
validateReading(session.events, event, fail)
|
||||
}, { global: true })
|
||||
}
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the time-context invariant companion.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { ContentBlock } from '@deepseek-ai/dsh-llm'
|
||||
import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
@@ -9,7 +9,8 @@ const SECOND = Date.parse('2026-07-14T00:00:00Z')
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(TimeInvariant)
|
||||
return ctx
|
||||
}
|
||||
@@ -54,6 +55,13 @@ function preparing(turn: number, step: number): Session {
|
||||
return session
|
||||
}
|
||||
|
||||
function appendReading(session: Session, text: string): void {
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text }],
|
||||
source: { kind: 'plugin', plugin: 'time-context' },
|
||||
}, { surfaceOp: 'append' })
|
||||
}
|
||||
|
||||
describe('time-context invariants', () => {
|
||||
it('accepts a reading whose turn, step, baseline, and timestamp agree', async () => {
|
||||
const ctx = await setup()
|
||||
@@ -69,6 +77,37 @@ describe('time-context invariants', () => {
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('validates each existing reading against its preceding durable prefix', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('time-invariant-late-valid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'prepare' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendReading(session, reading())
|
||||
session.append('step/start', { turn: 1, step: 1 })
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(TimeInvariant)).resolves.toBeDefined()
|
||||
})
|
||||
|
||||
it('rejects an invalid existing reading on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('time-invariant-late-invalid'))
|
||||
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
|
||||
session.append('user/message', {
|
||||
content: [{ type: 'text', text: 'prepare' }],
|
||||
source: { kind: 'user' },
|
||||
}, { surfaceOp: 'append' })
|
||||
appendReading(session, reading('1', '2', 'step context'))
|
||||
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await expect(ctx.plugin(TimeInvariant).then(() => undefined)).rejects.toThrow(/expected turn 1\/step 1/)
|
||||
})
|
||||
|
||||
it.each([
|
||||
[reading('1', '3', 'step context'), /expected turn 2\/step 3/],
|
||||
[reading('2', '2', 'step context'), /expected turn 2\/step 3/],
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../core/session"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { SANDBOX_MODES } from './session-mode.ts'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -12,16 +12,26 @@ export const name = 'sandbox-policy-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Install validation for the durable sandbox-mode vocabulary. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Validate the package-owned event shape and ignore unrelated events. */
|
||||
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
|
||||
if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) {
|
||||
fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation for loaded and newly appended sandbox modes. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(event, fail)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const event = (args as [Session, SessionEvent])[1]
|
||||
if (event.type === 'sandbox/mode' && !SANDBOX_MODES.includes(event.data.mode)) {
|
||||
fail(`sandbox/mode carries unknown mode ${JSON.stringify(event.data.mode)}`)
|
||||
}
|
||||
validateEvent(event, fail)
|
||||
}, { global: true })
|
||||
}
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register this package's invariant companion.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
|
||||
import * as SandboxPolicyInvariant from '@deepseek-ai/dsh-sandbox-policy/invariant'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(SandboxPolicyInvariant)
|
||||
return ctx
|
||||
}
|
||||
@@ -37,4 +38,16 @@ describe('sandbox-policy invariants', () => {
|
||||
expect(() => { ctx.emit('session/event', {} as Session, modeEvent('host-root')) })
|
||||
.toThrow(new InvariantError('@deepseek-ai/dsh-sandbox-policy', 'sandbox/mode carries unknown mode "host-root"'))
|
||||
})
|
||||
|
||||
it('rejects an unknown mode already present on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.sessions.create().append('sandbox/mode', { mode: 'host-root' as never })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(SandboxPolicyInvariant).then(() => undefined)).rejects.toMatchObject({
|
||||
code: 'INVARIANT',
|
||||
packageName: '@deepseek-ai/dsh-sandbox-policy',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'tsdown'
|
||||
|
||||
/** Build the package root and invariant companion as independent bundles. */
|
||||
export default defineConfig([
|
||||
{
|
||||
entry: ['lib/types/index.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
{
|
||||
entry: ['lib/types/invariant.js'],
|
||||
outDir: 'lib',
|
||||
format: ['esm'],
|
||||
platform: 'node',
|
||||
target: 'es2024',
|
||||
fixedExtension: false,
|
||||
dts: false,
|
||||
clean: false,
|
||||
},
|
||||
])
|
||||
@@ -33,14 +33,24 @@ function validateTodos(value: unknown, fail: InvariantFailure): void {
|
||||
if (active > 1) fail(`todo/write contains ${active} in-progress entries; at most one is allowed`)
|
||||
}
|
||||
|
||||
/** Install validation for durable whole-list todo snapshots. */
|
||||
const install: InvariantInstaller = (ctx, fail) => {
|
||||
/* jscpd:ignore-start -- package companions share replay and dispatch plumbing */
|
||||
/** Validate the package-owned event shape and ignore unrelated events. */
|
||||
function validateEvent(event: SessionEvent, fail: InvariantFailure): void {
|
||||
if (event.type === 'todo/write') validateTodos(event.data.todos, fail)
|
||||
}
|
||||
|
||||
/** Install validation for loaded and newly appended whole-list todo snapshots. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(event, fail)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const event = (args as [Session, SessionEvent])[1]
|
||||
if (event.type === 'todo/write') validateTodos(event.data.todos, fail)
|
||||
validateEvent(event, fail)
|
||||
}, { global: true })
|
||||
}
|
||||
}, { inject: ['sessions'] })
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/**
|
||||
* Register the todo invariant companion.
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as TodoInvariant from '@deepseek-ai/dsh-tool-todo/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(TodoInvariant)
|
||||
return ctx
|
||||
}
|
||||
@@ -50,4 +51,18 @@ describe('todo snapshot invariants', () => {
|
||||
})
|
||||
}).not.toThrow()
|
||||
})
|
||||
|
||||
it('rejects an invalid existing snapshot on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
ctx.sessions.create().append('todo/write', {
|
||||
todos: [
|
||||
{ content: 'duplicate', status: 'pending' },
|
||||
{ content: 'duplicate', status: 'completed' },
|
||||
],
|
||||
})
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(TodoInvariant).then(() => undefined)).rejects.toThrow(/repeats content "duplicate"/)
|
||||
})
|
||||
})
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-permission'
|
||||
|
||||
@@ -11,16 +11,24 @@ export const name = 'permission-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** Install validation that durable preset events remain resolvable. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: (message: string) => never) => {
|
||||
/** Validate the package-owned event shape and ignore unrelated events. */
|
||||
function validateEvent(ctx: Context, event: SessionEvent, fail: InvariantFailure): void {
|
||||
if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) {
|
||||
fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Install validation that loaded and newly appended preset events remain resolvable. */
|
||||
const install: InvariantInstaller = Object.assign((ctx: Context, fail: InvariantFailure) => {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
for (const event of session.events) validateEvent(ctx, event, fail)
|
||||
}
|
||||
ctx.on('internal/dispatch', (_mode, eventName, args) => {
|
||||
if (eventName !== 'session/event') return
|
||||
const event = (args as [Session, SessionEvent])[1]
|
||||
if (event.type === 'permission/preset' && !ctx.permission.names.includes(event.data.preset)) {
|
||||
fail(`permission/preset names unknown preset ${JSON.stringify(event.data.preset)}`)
|
||||
}
|
||||
validateEvent(ctx, event, fail)
|
||||
}, { global: true })
|
||||
}, { inject: ['permission'] })
|
||||
}, { inject: ['permission', 'sessions'] })
|
||||
|
||||
/**
|
||||
* Register the permission invariant companion.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
@@ -14,8 +14,9 @@ class PermissionProbe extends Service {
|
||||
|
||||
async function setup(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(PermissionProbe)
|
||||
await ctx.plugin(InvariantService)
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(PermissionInvariant)
|
||||
return ctx
|
||||
}
|
||||
@@ -39,4 +40,14 @@ describe('permission invariants', () => {
|
||||
expect(() => { ctx.emit('session/event', {} as Session, presetEvent('missing')) })
|
||||
.toThrow(/unknown preset "missing"/)
|
||||
})
|
||||
|
||||
it('rejects an unknown preset already present on late registration', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SessionStore)
|
||||
await ctx.plugin(PermissionProbe)
|
||||
ctx.sessions.create().append('permission/preset', { preset: 'missing' })
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
|
||||
await expect(ctx.plugin(PermissionInvariant).then(() => undefined)).rejects.toThrow(/unknown preset "missing"/)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,17 @@
|
||||
/** Verify every compiled companion through its package self-reference under plain Node. */
|
||||
/** Verify every packed companion through its package self-reference under plain Node. */
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { globSync, readFileSync } from 'node:fs'
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
globSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
symlinkSync,
|
||||
} from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
@@ -9,43 +19,82 @@ const root = resolve(import.meta.dirname, '..')
|
||||
const loaderUrl = pathToFileURL(resolve(root, 'vendor/loader/lib/index.js')).href
|
||||
const failures = []
|
||||
const manifests = globSync('packages/*/*/package.json', { cwd: root }).sort()
|
||||
const stagingRoot = mkdtempSync(resolve(tmpdir(), 'dsh-built-package-invariants-'))
|
||||
|
||||
for (const manifestPath of manifests) {
|
||||
const packageDir = dirname(resolve(root, manifestPath))
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8'))
|
||||
const packageName = manifest.name
|
||||
if (typeof packageName !== 'string' || packageName.length === 0) {
|
||||
failures.push(`${manifestPath}: missing package name`)
|
||||
continue
|
||||
}
|
||||
|
||||
const probe = `
|
||||
const companion = await import(${JSON.stringify(`${packageName}/invariant`)});
|
||||
const { default: Loader } = await import(${JSON.stringify(loaderUrl)});
|
||||
if ('default' in companion) throw new Error('companion has a default export');
|
||||
const loader = Object.create(Loader.prototype);
|
||||
const unwrapped = loader.unwrapExports(companion);
|
||||
if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace');
|
||||
if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing');
|
||||
if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) {
|
||||
throw new Error('companion does not inject invariants');
|
||||
try {
|
||||
for (const [index, manifestPath] of manifests.entries()) {
|
||||
const packageDir = dirname(resolve(root, manifestPath))
|
||||
const manifest = JSON.parse(readFileSync(resolve(root, manifestPath), 'utf8'))
|
||||
const packageName = manifest.name
|
||||
if (typeof packageName !== 'string' || packageName.length === 0) {
|
||||
failures.push(`${manifestPath}: missing package name`)
|
||||
continue
|
||||
}
|
||||
if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing');
|
||||
`
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], {
|
||||
cwd: packageDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (result.status === 0) continue
|
||||
const detail = result.error?.message
|
||||
?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`)
|
||||
failures.push(`${packageName}: ${detail}`)
|
||||
|
||||
const pack = spawnSync('npm', ['pack', '--dry-run', '--json', '--ignore-scripts'], {
|
||||
cwd: packageDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (pack.status !== 0) {
|
||||
const detail = pack.error?.message
|
||||
?? (pack.stderr.trim() || pack.stdout.trim() || `npm pack exited ${pack.status}`)
|
||||
failures.push(`${packageName}: ${detail}`)
|
||||
continue
|
||||
}
|
||||
|
||||
let files
|
||||
try {
|
||||
const result = JSON.parse(pack.stdout)
|
||||
files = result[0]?.files
|
||||
if (!Array.isArray(files)) throw new Error('npm pack returned no file inventory')
|
||||
} catch (error) {
|
||||
failures.push(`${packageName}: cannot parse npm pack inventory: ${String(error)}`)
|
||||
continue
|
||||
}
|
||||
|
||||
const stagedPackageDir = resolve(stagingRoot, String(index))
|
||||
for (const file of files) {
|
||||
if (typeof file.path !== 'string'
|
||||
|| (file.path !== 'package.json' && !file.path.startsWith('lib/'))) continue
|
||||
const target = resolve(stagedPackageDir, file.path)
|
||||
mkdirSync(dirname(target), { recursive: true })
|
||||
copyFileSync(resolve(packageDir, file.path), target)
|
||||
}
|
||||
const packageNodeModules = resolve(packageDir, 'node_modules')
|
||||
if (existsSync(packageNodeModules)) {
|
||||
symlinkSync(packageNodeModules, resolve(stagedPackageDir, 'node_modules'), 'dir')
|
||||
}
|
||||
|
||||
const probe = `
|
||||
const companion = await import(${JSON.stringify(`${packageName}/invariant`)});
|
||||
const { default: Loader } = await import(${JSON.stringify(loaderUrl)});
|
||||
if ('default' in companion) throw new Error('companion has a default export');
|
||||
const loader = Object.create(Loader.prototype);
|
||||
const unwrapped = loader.unwrapExports(companion);
|
||||
if (unwrapped !== companion) throw new Error('Loader collapsed the companion namespace');
|
||||
if (typeof unwrapped.name !== 'string') throw new Error('companion name is missing');
|
||||
if (!Array.isArray(unwrapped.inject) || !unwrapped.inject.includes('invariants')) {
|
||||
throw new Error('companion does not inject invariants');
|
||||
}
|
||||
if (typeof unwrapped.apply !== 'function') throw new Error('companion apply is missing');
|
||||
`
|
||||
const result = spawnSync(process.execPath, ['--input-type=module', '--eval', probe], {
|
||||
cwd: stagedPackageDir,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
if (result.status === 0) continue
|
||||
const detail = result.error?.message
|
||||
?? (result.stderr.trim() || result.stdout.trim() || `node exited ${result.status}`)
|
||||
failures.push(`${packageName}: ${detail}`)
|
||||
}
|
||||
} finally {
|
||||
rmSync(stagingRoot, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
console.error('verify-built-package-invariants: compiled companion failures:')
|
||||
console.error('verify-built-package-invariants: packed companion failures:')
|
||||
for (const failure of failures) console.error(` ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log(`verify-built-package-invariants: ${manifests.length} compiled companion(s) passed plain-Node Loader checks.`)
|
||||
console.log(`verify-built-package-invariants: ${manifests.length} packed companion(s) passed plain-Node Loader checks.`)
|
||||
Reference in New Issue
Block a user