fix(invariants): harden runtime contracts and gates
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
51
scripts/verify-built-package-invariants.mjs
Normal file
51
scripts/verify-built-package-invariants.mjs
Normal 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.`)
|
||||
Reference in New Issue
Block a user