fix(invariants): enforce runtime relationships

This commit is contained in:
Tianyi Cui
2026-07-20 23:16:08 +08:00
parent 023cde5d82
commit e92b34bd7e
22 changed files with 398 additions and 75 deletions

View File

@@ -138,6 +138,20 @@ export const apply = (ctx: { invariants: { register(name: string, install: typeo
.toContain('install function must use its bound failure reporter')
})
it('rejects registering a different installer than the checked local function', () => {
const decoy = fixture({
source: `
export const name = 'probe-invariant'
export const inject = ['invariants']
const install = (_ctx: unknown, fail: (message: string) => never) => { fail('checked decoy') }
export const apply = (ctx: { invariants: { register(name: string, install: () => void): () => void } }) =>
ctx.invariants.register('@deepseek-ai/dsh-probe', () => {})
`,
})
expect(collectPackageInvariantViolations(decoy).map(violation => violation.message))
.toContain('line 6: ctx.invariants.register must use the checked local install function')
})
it('accepts explained empty installers and rejects unexplained ones', () => {
const explained = `
export const name = 'probe-invariant'

View File

@@ -167,12 +167,18 @@ function checkSource(
const constants = topLevelStringConstants(sourceFile)
const registrations: string[] = []
const unresolved: number[] = []
const mismatchedInstallers: number[] = []
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node) && isInvariantRegistration(node.expression)) {
const line = sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1
const argument = node.arguments[0]
const packageName = argument === undefined ? undefined : stringValue(argument, constants)
if (packageName === undefined) unresolved.push(sourceFile.getLineAndCharacterOfPosition(node.getStart()).line + 1)
if (packageName === undefined) unresolved.push(line)
else registrations.push(packageName)
const installer = node.arguments[1]
if (installer === undefined || !ts.isIdentifier(installer) || installer.text !== 'install') {
mismatchedInstallers.push(line)
}
}
ts.forEachChild(node, visit)
}
@@ -185,6 +191,13 @@ function checkSource(
`line ${line}: ctx.invariants.register package name must resolve to a local string constant`,
)
}
for (const line of mismatchedInstallers) {
addViolation(
violations,
owner.sourcePath,
`line ${line}: ctx.invariants.register must use the checked local install function`,
)
}
if (registrations.length !== 1 || registrations[0] !== owner.packageName) {
addViolation(
violations,

View File

@@ -83,6 +83,7 @@ describe('global test invariant host', () => {
'/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',

View File

@@ -42,6 +42,7 @@ export const MANUAL_INVARIANT_TESTS = [
'/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',