fix(invariants): harden runtime contracts and gates

This commit is contained in:
Tianyi Cui
2026-07-21 00:25:38 +08:00
parent a4be4b5e52
commit 0c08e34a4a
27 changed files with 288 additions and 111 deletions

View File

@@ -152,6 +152,15 @@ export const apply = (ctx: { invariants: { register(name: string, install: () =>
.toContain('line 6: ctx.invariants.register must use the checked local install function')
})
it.each([
'export default { name, inject, apply }',
"export * as default from './probe.ts'",
])('rejects a default export that would collapse the Loader namespace', (defaultExport) => {
const source = `${handwrittenInvariant('@deepseek-ai/dsh-probe')}\n${defaultExport}\n`
expect(collectPackageInvariantViolations(fixture({ source })).map(violation => violation.message))
.toContain('must not default-export; Loader must retain the companion namespace')
})
it('accepts explained empty installers and rejects unexplained ones', () => {
const explained = `
export const name = 'probe-invariant'

View File

@@ -210,6 +210,9 @@ function checkSource(
addViolation(violations, owner.sourcePath, `must named-export ${exportedName}`)
}
}
if (hasDefaultExport(sourceFile)) {
addViolation(violations, owner.sourcePath, 'must not default-export; Loader must retain the companion namespace')
}
checkInstaller(owner, sourceFile, sourceText, violations)
}
@@ -314,6 +317,19 @@ function hasNamedExport(sourceFile: ts.SourceFile, name: string): boolean {
})
}
function hasDefaultExport(sourceFile: ts.SourceFile): boolean {
return sourceFile.statements.some((statement) => {
if (ts.isExportAssignment(statement)) return true
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined
if (modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
if (!ts.isExportDeclaration(statement) || statement.exportClause === undefined) return false
if (ts.isNamespaceExport(statement.exportClause)) {
return statement.exportClause.name.text === 'default'
}
return statement.exportClause.elements.some(element => element.name.text === 'default')
})
}
/** Format violations for the command-line gate. */
export function formatPackageInvariantViolation(
root: string,

View File

@@ -224,6 +224,7 @@ function ciPrimaryGates(): Gate[] {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
}
@@ -248,6 +249,7 @@ function ciArtifactGates(): Gate[] {
label: 'node-next types',
needs: ['build'],
}),
builtPackageInvariantsGate(['build']),
builtBinSmokeGate(),
]
}
@@ -295,6 +297,13 @@ function snapshotGate(): Gate {
})
}
function builtPackageInvariantsGate(needs?: string[]): Gate {
return pnpmScript('built-package-invariants', 'verify-built-package-invariants', {
label: 'built package invariants',
...needs === undefined ? {} : { needs },
})
}
function positiveIntArg(envName: string, flag: string): string[] {
const raw = process.env[envName]
if (raw === undefined || raw === '') return []
@@ -312,6 +321,7 @@ function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] {
pnpmScript('publint', 'publint', artifactOptions),
pnpmScript('constraints', 'constraints'),
pnpmScript('package-invariants', 'verify-package-invariants', { label: 'package invariants' }),
builtPackageInvariantsGate(options.artifactNeeds),
pnpmScript('node-next-types', 'verify-node-next-types', {
label: 'node-next types',
...artifactOptions,

View File

@@ -1,11 +1,12 @@
import { describe, expect, it, vi } from 'vitest'
import { Context, Service } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { packageInvariantOwners } from './package-invariants.ts'
import {
MANUAL_INVARIANT_TESTS,
testInvariantCompanionPaths,
testInvariantCompanions,
usesManualInvariantTree,
} from './test-invariants.ts'
declare module 'cordis' {
@@ -51,9 +52,10 @@ describe('global test invariant host', () => {
.toEqual(Object.keys(testInvariantCompanions).sort())
})
it('executes each companion registration with its owning package name', async () => {
it('loads and executes every source companion through the real Loader shape', async () => {
const owners = new Map(packageInvariantOwners(process.cwd()).map(owner => [owner.sourcePath, owner.packageName]))
const registrations = new Map<string, string>()
const loader = Object.create(Loader.prototype) as Loader
const register = vi.fn((_packageName: string, installer: InvariantInstaller) => {
expect(typeof installer).toBe('function')
return () => {}
@@ -61,7 +63,13 @@ describe('global test invariant host', () => {
const fakeContext = { invariants: { register } } as unknown as Context
for (const [rawPath, companion] of Object.entries(testInvariantCompanions)) {
const path = rawPath.replace(/^\.\.\//, '')
await companion.apply(fakeContext)
expect(companion.default, path).toBeUndefined()
const unwrapped = loader.unwrapExports(companion) as typeof companion
expect(unwrapped, path).toBe(companion)
expect(typeof unwrapped.name, path).toBe('string')
expect(unwrapped.inject, path).toContain('invariants')
expect(typeof unwrapped.apply, path).toBe('function')
await unwrapped.apply(fakeContext)
const call = register.mock.calls.at(-1)
if (call === undefined) throw new Error(`${path}: companion did not register`)
registrations.set(path, call[0])
@@ -69,29 +77,11 @@ describe('global test invariant host', () => {
expect(registrations).toEqual(owners)
})
it('limits manual composition to focused invariant topology tests', () => {
expect(MANUAL_INVARIANT_TESTS).toEqual([
'/packages/support/invariants/tests/service.spec.ts',
'/packages/compact/compact/tests/invariant.spec.ts',
'/packages/context/time-context/tests/invariant.spec.ts',
'/packages/core/session/tests/invariant.spec.ts',
'/packages/core/agent/tests/invariant.spec.ts',
'/packages/core/scope/tests/invariant.spec.ts',
'/packages/core/agent-loop/tests/invariant.spec.ts',
'/packages/core/system-prompt/tests/invariant.spec.ts',
'/packages/core/tools/tests/invariant.spec.ts',
'/packages/fs/fs/tests/invariant.spec.ts',
'/packages/hooks/hook-protocol/tests/invariant.spec.ts',
'/packages/llm/llm/tests/invariant.spec.ts',
'/packages/llm/llm-retry/tests/invariant.spec.ts',
'/packages/sandbox/sandbox-policy/tests/invariant.spec.ts',
'/packages/subagent/subagent/tests/invariant.spec.ts',
'/packages/tasks/tasks/tests/invariant.spec.ts',
'/packages/todo/tool-todo/tests/invariant.spec.ts',
'/packages/ui/permission/tests/invariant.spec.ts',
'/packages/ui/user-approval/tests/invariant.spec.ts',
'/packages/workflow/workflow/tests/invariant.spec.ts',
'/packages/examples/agent-spine-demo/tests/agent-core.spec.ts',
])
it('recognizes focused invariant suites without a package inventory', () => {
expect(usesManualInvariantTree('/repo/packages/core/session/tests/invariant.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/request-invariant-hmr.spec.ts')).toBe(true)
expect(usesManualInvariantTree('C:\\repo\\packages\\support\\invariants\\tests\\service.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true)
expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false)
})
})

View File

@@ -21,6 +21,7 @@ declare global {
export interface TestInvariantCompanion {
readonly name: string
readonly inject: readonly string[]
readonly default?: unknown
apply(ctx: Context): Promise<() => void>
}
@@ -28,28 +29,9 @@ export interface TestInvariantCompanion {
export const testInvariantCompanions: Readonly<Record<string, TestInvariantCompanion>> =
import.meta.glob<TestInvariantCompanion>('../packages/*/*/src/invariant.ts', { eager: true })
/** Tests that exercise selection or companion lifecycle with a deliberately hand-built service tree. */
export const MANUAL_INVARIANT_TESTS = [
/** Manual-topology suites whose names cannot follow the focused invariant convention. */
const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
'/packages/support/invariants/tests/service.spec.ts',
'/packages/compact/compact/tests/invariant.spec.ts',
'/packages/context/time-context/tests/invariant.spec.ts',
'/packages/core/session/tests/invariant.spec.ts',
'/packages/core/agent/tests/invariant.spec.ts',
'/packages/core/scope/tests/invariant.spec.ts',
'/packages/core/agent-loop/tests/invariant.spec.ts',
'/packages/core/system-prompt/tests/invariant.spec.ts',
'/packages/core/tools/tests/invariant.spec.ts',
'/packages/fs/fs/tests/invariant.spec.ts',
'/packages/hooks/hook-protocol/tests/invariant.spec.ts',
'/packages/llm/llm/tests/invariant.spec.ts',
'/packages/llm/llm-retry/tests/invariant.spec.ts',
'/packages/sandbox/sandbox-policy/tests/invariant.spec.ts',
'/packages/subagent/subagent/tests/invariant.spec.ts',
'/packages/tasks/tasks/tests/invariant.spec.ts',
'/packages/todo/tool-todo/tests/invariant.spec.ts',
'/packages/ui/permission/tests/invariant.spec.ts',
'/packages/ui/user-approval/tests/invariant.spec.ts',
'/packages/workflow/workflow/tests/invariant.spec.ts',
'/packages/examples/agent-spine-demo/tests/agent-core.spec.ts',
] as const
@@ -66,7 +48,8 @@ const hosts = new WeakMap<Context, InvariantHost>()
const originalPlugin = RegistryService.prototype.plugin
RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, getOuterStack?: () => string[]) {
if (usesManualInvariantTree()) return originalPlugin.call(this, plugin, config, getOuterStack)
const testPath = expect.getState().testPath ?? ''
if (usesManualInvariantTree(testPath)) return originalPlugin.call(this, plugin, config, getOuterStack)
const root = this.ctx.root
const host = hosts.get(root) ?? startInvariantHost(root)
@@ -83,9 +66,15 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge
return joinInvariantStartup(fiber, host.ready)
}
function usesManualInvariantTree(): boolean {
const testPath = expect.getState().testPath?.replaceAll('\\', '/') ?? ''
return MANUAL_INVARIANT_TESTS.some(path => testPath.endsWith(path))
/**
* Detect focused suites that construct service selection or companion lifecycle explicitly.
* @param testPath - absolute or repo-relative Vitest file path.
* @returns whether the global invariant host must leave the root untouched.
*/
export function usesManualInvariantTree(testPath: string): boolean {
const normalized = testPath.replaceAll('\\', '/')
if (/\/packages\/[^/]+\/[^/]+\/tests\/[^/]*invariant[^/]*\.spec\.ts$/.test(normalized)) return true
return MANUAL_INVARIANT_TEST_EXCEPTIONS.some(path => normalized.endsWith(path))
}
const ALL_COMPANION_TESTS = ['/scripts/test-invariants.spec.ts'] as const

View File

@@ -0,0 +1,51 @@
/** Verify every compiled companion through its package self-reference under plain Node. */
import { spawnSync } from 'node:child_process'
import { globSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
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()
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');
}
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}`)
}
if (failures.length > 0) {
console.error('verify-built-package-invariants: compiled 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.`)