switchroom 0.18.31 → 0.18.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-scheduler/index.js +4 -2
- package/dist/auth-broker/index.js +21 -3
- package/dist/cli/notion-write-pretool.mjs +4 -2
- package/dist/cli/switchroom.js +1410 -852
- package/dist/host-control/main.js +22 -4
- package/dist/vault/approvals/kernel-server.js +21 -3
- package/dist/vault/broker/server.js +48 -4
- package/package.json +4 -3
- package/profiles/_base/start.sh.hbs +148 -23
- package/telegram-plugin/dist/gateway/gateway.js +62726 -58282
- package/telegram-plugin/gateway/agent-button-callback-handler.ts +237 -0
- package/telegram-plugin/gateway/ask-callback-handler.ts +92 -0
- package/telegram-plugin/gateway/attachment-message-handlers.ts +152 -0
- package/telegram-plugin/gateway/backstop-delivery.ts +223 -23
- package/telegram-plugin/gateway/boot-card.ts +169 -1
- package/telegram-plugin/gateway/bot-commands-model-effort.ts +209 -0
- package/telegram-plugin/gateway/bot-commands-start-info.ts +108 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +124 -0
- package/telegram-plugin/gateway/captured-answer-resume.ts +259 -0
- package/telegram-plugin/gateway/card-approval-keyboards.test.ts +28 -0
- package/telegram-plugin/gateway/card-tool-handlers.ts +639 -0
- package/telegram-plugin/gateway/checklist-message-handler.ts +107 -0
- package/telegram-plugin/gateway/delivery-confirm-wiring.ts +133 -0
- package/telegram-plugin/gateway/disconnect-flush.ts +6 -44
- package/telegram-plugin/gateway/gateway-import-clean.test.ts +188 -0
- package/telegram-plugin/gateway/gateway.ts +6403 -13456
- package/telegram-plugin/gateway/inbound-delivery-machine-dispatch.ts +7 -15
- package/telegram-plugin/gateway/inbound-delivery-machine-shadow.ts +35 -68
- package/telegram-plugin/gateway/inbound-interceptors.ts +1133 -0
- package/telegram-plugin/gateway/inbound-router.ts +400 -0
- package/telegram-plugin/gateway/liveness-wiring.ts +440 -0
- package/telegram-plugin/gateway/media-message-handlers.ts +256 -0
- package/telegram-plugin/gateway/mental-model-propose-card.ts +16 -0
- package/telegram-plugin/gateway/model-command.ts +23 -0
- package/telegram-plugin/gateway/narrative-lane.ts +865 -0
- package/telegram-plugin/gateway/obligation-ledger.ts +42 -0
- package/telegram-plugin/gateway/obligation-store.ts +37 -1
- package/telegram-plugin/gateway/obligation-wiring.ts +333 -0
- package/telegram-plugin/gateway/outbound-send-path.ts +2012 -0
- package/telegram-plugin/gateway/photo-message-handler.ts +80 -0
- package/telegram-plugin/gateway/pinned-message-handler.ts +86 -0
- package/telegram-plugin/gateway/secret-request-card.test.ts +46 -0
- package/telegram-plugin/gateway/secret-request-card.ts +45 -0
- package/telegram-plugin/gateway/stream-render.ts +2166 -0
- package/telegram-plugin/gateway/turn-end.ts +606 -0
- package/telegram-plugin/gateway/turn-start-surfaces.ts +298 -0
- package/telegram-plugin/gateway/vault-request-access-card.ts +16 -0
- package/telegram-plugin/gateway/vault-request-save-card.test.ts +49 -0
- package/telegram-plugin/gateway/vault-request-save-card.ts +52 -0
- package/telegram-plugin/gateway/voice-message-handler.ts +123 -0
- package/telegram-plugin/gateway/voice-ondemand-callback-handler.ts +204 -0
- package/telegram-plugin/gateway/worker-feed-dispatch.ts +40 -0
- package/telegram-plugin/narrative-dedup.ts +24 -1
- package/telegram-plugin/narrative-flush.ts +2 -2
- package/telegram-plugin/pending-user-notice.ts +59 -13
- package/telegram-plugin/render/render.ts +25 -1
- package/telegram-plugin/status-no-truncate.ts +13 -0
- package/telegram-plugin/subagent-watcher.ts +297 -31
- package/telegram-plugin/tests/activity-card-wiring.test.ts +8 -3
- package/telegram-plugin/tests/activity-ever-opened-sticky.test.ts +18 -3
- package/telegram-plugin/tests/agent-button-callback-handler.test.ts +149 -0
- package/telegram-plugin/tests/ask-callback-handler.test.ts +118 -0
- package/telegram-plugin/tests/attachment-message-handlers.test.ts +135 -0
- package/telegram-plugin/tests/backstop-delivery.test.ts +167 -0
- package/telegram-plugin/tests/backstop-readback-probe.test.ts +144 -0
- package/telegram-plugin/tests/boot-card-routing.test.ts +139 -0
- package/telegram-plugin/tests/bot-commands-model-effort.test.ts +189 -0
- package/telegram-plugin/tests/bot-commands-start-info.test.ts +240 -0
- package/telegram-plugin/tests/buffer-gate-broadened.test.ts +28 -9
- package/telegram-plugin/tests/busy-ack-wiring.test.ts +6 -1
- package/telegram-plugin/tests/button-tap-turn-gated.test.ts +21 -12
- package/telegram-plugin/tests/callback-query-handlers.test.ts +101 -0
- package/telegram-plugin/tests/captured-answer-resume.test.ts +358 -0
- package/telegram-plugin/tests/card-tool-handlers.test.ts +497 -0
- package/telegram-plugin/tests/catch-all-unhandled-message.test.ts +5 -2
- package/telegram-plugin/tests/checklist-message-handler.test.ts +160 -0
- package/telegram-plugin/tests/emission-authority-facade.test.ts +76 -29
- package/telegram-plugin/tests/emission-authority-ping-gate.test.ts +4 -1
- package/telegram-plugin/tests/emission-determinism-wiring.test.ts +45 -16
- package/telegram-plugin/tests/feed-heartbeat-liveness-open.test.ts +30 -7
- package/telegram-plugin/tests/gateway-boot-side-effect-gating.test.ts +270 -0
- package/telegram-plugin/tests/gateway-boot-smoke.test.ts +150 -0
- package/telegram-plugin/tests/gateway-bot-construction-deferral.test.ts +251 -0
- package/telegram-plugin/tests/gateway-disconnect-flush.test.ts +5 -128
- package/telegram-plugin/tests/gateway-handler-registration-wiring.test.ts +299 -0
- package/telegram-plugin/tests/gateway-loopback-paste-redact.test.ts +44 -29
- package/telegram-plugin/tests/gateway-outbound-redact.test.ts +18 -5
- package/telegram-plugin/tests/gateway-request-secret.test.ts +7 -3
- package/telegram-plugin/tests/gateway-secret-detect.test.ts +20 -10
- package/telegram-plugin/tests/gateway-session-model-relaunch.test.ts +8 -2
- package/telegram-plugin/tests/inbound-delivery-cutover-flip.test.ts +54 -150
- package/telegram-plugin/tests/inbound-delivery-cutover-gate.test.ts +10 -14
- package/telegram-plugin/tests/inbound-delivery-dispatch-equivalence.test.ts +6 -7
- package/telegram-plugin/tests/inbound-delivery-machine-dispatch.test.ts +0 -16
- package/telegram-plugin/tests/inbound-emit-after-intercepts.test.ts +18 -7
- package/telegram-plugin/tests/inbound-message-types.test.ts +52 -16
- package/telegram-plugin/tests/litellm-proxy-auth-misconfig.test.ts +69 -14
- package/telegram-plugin/tests/media-message-handlers.test.ts +276 -0
- package/telegram-plugin/tests/mental-model-propose-callback-gate.test.ts +8 -4
- package/telegram-plugin/tests/model-command.test.ts +30 -0
- package/telegram-plugin/tests/multitopic-routing-wiring.test.ts +36 -12
- package/telegram-plugin/tests/narrative-dedup.test.ts +32 -0
- package/telegram-plugin/tests/narrative-flush.test.ts +6 -2
- package/telegram-plugin/tests/narrative-lane-golden.test.ts +458 -0
- package/telegram-plugin/tests/no-reply-bounded-drain.test.ts +14 -3
- package/telegram-plugin/tests/obligation-ledger.test.ts +40 -0
- package/telegram-plugin/tests/obligation-store.test.ts +43 -0
- package/telegram-plugin/tests/pending-card-durability-wiring.test.ts +18 -8
- package/telegram-plugin/tests/per-topic-current-turn.test.ts +32 -8
- package/telegram-plugin/tests/permission-no-repeat-wiring.test.ts +9 -6
- package/telegram-plugin/tests/photo-message-handler.test.ts +114 -0
- package/telegram-plugin/tests/photo-reroute-wiring.test.ts +5 -2
- package/telegram-plugin/tests/pinned-message-handler.test.ts +108 -0
- package/telegram-plugin/tests/render/render.test.ts +42 -0
- package/telegram-plugin/tests/reply-terminal-reaction.test.ts +6 -2
- package/telegram-plugin/tests/secret-detect-delete-must-surface-failures.test.ts +8 -4
- package/telegram-plugin/tests/secret-detect-fail-closed.test.ts +38 -28
- package/telegram-plugin/tests/secret-detect-oauth-code.test.ts +31 -20
- package/telegram-plugin/tests/send-reply-golden.test.ts +571 -0
- package/telegram-plugin/tests/silence-liveness-wiring.test.ts +22 -8
- package/telegram-plugin/tests/status-pin-service-message-suppression.test.ts +42 -49
- package/telegram-plugin/tests/stop-command.test.ts +22 -12
- package/telegram-plugin/tests/stream-render-golden.test.ts +424 -0
- package/telegram-plugin/tests/subagent-watcher-boot-skip-dead.test.ts +218 -0
- package/telegram-plugin/tests/subagent-watcher-resume-reregister.test.ts +305 -0
- package/telegram-plugin/tests/subagent-watcher-resurrection.test.ts +32 -0
- package/telegram-plugin/tests/subagent-watcher.test.ts +35 -3
- package/telegram-plugin/tests/turn-end-gate-backstop.test.ts +8 -12
- package/telegram-plugin/tests/turn-flush-safety.test.ts +191 -11
- package/telegram-plugin/tests/turn-flush-suppression-wiring.test.ts +117 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +8 -2
- package/telegram-plugin/tests/vault-grant-inbound-builders.test.ts +2 -2
- package/telegram-plugin/tests/vault-grant-union.test.ts +4 -1
- package/telegram-plugin/tests/vault-key-regex-allows-slash.test.ts +16 -5
- package/telegram-plugin/tests/vault-request-access-tool.test.ts +10 -5
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +4 -1
- package/telegram-plugin/tests/vault-subcommands.test.ts +6 -1
- package/telegram-plugin/tests/voice-message-handler.test.ts +111 -0
- package/telegram-plugin/tests/voice-ondemand-callback-handler.test.ts +140 -0
- package/telegram-plugin/tests/worker-activity-feed.test.ts +86 -19
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +236 -20
- package/telegram-plugin/tests/worker-feed-resume-guard.test.ts +86 -0
- package/telegram-plugin/tool-activity-summary.ts +110 -38
- package/telegram-plugin/turn-flush-safety.ts +80 -14
- package/telegram-plugin/uat/restart-capability.ts +76 -0
- package/telegram-plugin/uat/scenarios/bg-sub-agent-dispatch-dm.test.ts +14 -4
- package/telegram-plugin/uat/scenarios/bridge-flap-resilience-dm.test.ts +11 -1
- package/telegram-plugin/uat/scenarios/cross-turn-pending-progress-dm.test.ts +19 -2
- package/telegram-plugin/uat/scenarios/jtbd-always-on-after-restart-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-deliberate-restart-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-interrupted-turn-resumes-dm.test.ts +6 -12
- package/telegram-plugin/uat/scenarios/jtbd-multipart-render-dm.test.ts +47 -13
- package/telegram-plugin/worker-activity-feed.ts +34 -4
- package/telegram-plugin/gateway/busy-key-reaper.ts +0 -113
- package/telegram-plugin/gateway/gate-parity-probe.ts +0 -102
- package/telegram-plugin/tests/busy-key-reaper.test.ts +0 -192
- package/telegram-plugin/tests/fixtures/cutover-killswitch-probe.ts +0 -75
- package/telegram-plugin/tests/gate-parity-probe.test.ts +0 -171
- package/telegram-plugin/tests/parallel-turns-deadlock-fix.test.ts +0 -217
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import-safety pin for the #2996 P0c move: the remaining module-load side
|
|
3
|
+
* effects of gateway.ts are gated behind `isGatewayMain` so importing the
|
|
4
|
+
* module performs NO boot side effects, and the boot sequence is the guarded
|
|
5
|
+
* `startGateway()` at the bottom of the file.
|
|
6
|
+
*
|
|
7
|
+
* WHY this asserts on the parsed SOURCE, not a real `import()`:
|
|
8
|
+
* the repo's oracle for "pin gateway structure without booting it" is
|
|
9
|
+
* source-parsing via `ts.createSourceFile` — the same pattern as
|
|
10
|
+
* gateway-bot-construction-deferral.test.ts (P0b) and
|
|
11
|
+
* gateway-handler-registration-wiring.test.ts (P0a). (Since P0e, #3318, a
|
|
12
|
+
* hermetic `await import()` of gateway.ts does complete cleanly; upgrading
|
|
13
|
+
* this oracle to a real runtime import is possible but deliberately out of
|
|
14
|
+
* scope here — the source parse stays the pinned pattern.) This test follows
|
|
15
|
+
* it and asserts OUTCOMES:
|
|
16
|
+
*
|
|
17
|
+
* 1. `isGatewayMain` is a module-scope const whose value is the argv[1]
|
|
18
|
+
* main-check (true only when this module is the process entrypoint).
|
|
19
|
+
* 2. The boot sequence is `async function startGateway()`, invoked EXACTLY
|
|
20
|
+
* once, and that sole invocation is guarded by `isGatewayMain`. The old
|
|
21
|
+
* top-level boot IIFE (`void (async () => …)()`) is gone.
|
|
22
|
+
* 3. No UNGUARDED module-scope side effect remains: every top-level
|
|
23
|
+
* `setInterval` / `setTimeout` / `<x>.startTimer(…)` / `mkdirSync` /
|
|
24
|
+
* `runHistoryReaperNow` / `openTurnsDb` / `initHistory` /
|
|
25
|
+
* `acquireStartupLock` / `startWebhookIngestServer` /
|
|
26
|
+
* `createPollHealthCheck` / `loadObligations` /
|
|
27
|
+
* `installPosthogErrorHandlers` / `sweepActiveReactions` /
|
|
28
|
+
* `process.on(…)` call, and every module-scope `await`, sits inside an
|
|
29
|
+
* `isGatewayMain` guard (an `if (isGatewayMain …)` or an
|
|
30
|
+
* `isGatewayMain ? … : …` ternary) — or inside a function body.
|
|
31
|
+
*
|
|
32
|
+
* A regression that re-hoists a timer, the startup-lock await, the boot IIFE,
|
|
33
|
+
* or the turn-registry open back to unguarded module scope fails (3).
|
|
34
|
+
*
|
|
35
|
+
* The process signal/error handlers (`process.on('SIGTERM'|'SIGINT'|
|
|
36
|
+
* 'beforeExit'|'unhandledRejection'|'uncaughtException')`,
|
|
37
|
+
* `installPosthogErrorHandlers()`) and the startup reaction sweep
|
|
38
|
+
* (`sweepActiveReactions`) — the "P0d" inventory from the P0c handback — were
|
|
39
|
+
* gated behind `isGatewayMain` by P0e (#3318, `ab34bb7b`) too; #2996 P0d was
|
|
40
|
+
* verified already-delivered and closed with no PR (plan Amendment 11). They
|
|
41
|
+
* ARE in the sentinel set below, so a regression that un-gates any of them
|
|
42
|
+
* back to bare module scope fails (3) as well.
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { describe, it, expect } from 'vitest'
|
|
46
|
+
import { readFileSync } from 'node:fs'
|
|
47
|
+
import { fileURLToPath } from 'node:url'
|
|
48
|
+
import { dirname, resolve } from 'node:path'
|
|
49
|
+
import ts from 'typescript'
|
|
50
|
+
|
|
51
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
52
|
+
const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
|
|
53
|
+
const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
|
|
54
|
+
|
|
55
|
+
const sourceFile = ts.createSourceFile(
|
|
56
|
+
GATEWAY_PATH,
|
|
57
|
+
GATEWAY_SRC,
|
|
58
|
+
ts.ScriptTarget.Latest,
|
|
59
|
+
true,
|
|
60
|
+
ts.ScriptKind.TS,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
function isFunctionLike(n: ts.Node): boolean {
|
|
64
|
+
return (
|
|
65
|
+
ts.isFunctionDeclaration(n) ||
|
|
66
|
+
ts.isFunctionExpression(n) ||
|
|
67
|
+
ts.isArrowFunction(n) ||
|
|
68
|
+
ts.isMethodDeclaration(n) ||
|
|
69
|
+
ts.isConstructorDeclaration(n) ||
|
|
70
|
+
ts.isGetAccessorDeclaration(n) ||
|
|
71
|
+
ts.isSetAccessorDeclaration(n)
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function lineOf(n: ts.Node): number {
|
|
76
|
+
return sourceFile.getLineAndCharacterOfPosition(n.getStart(sourceFile)).line + 1
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The side-effect sentinels. Each of these, at module scope, MUST be
|
|
80
|
+
// isGatewayMain-guarded: the first block since P0c, the last two — the "P0d"
|
|
81
|
+
// process-wiring inventory, gated by P0e (#3318) — since P0e.
|
|
82
|
+
const CALL_SENTINELS = new Set([
|
|
83
|
+
'setInterval',
|
|
84
|
+
'setTimeout',
|
|
85
|
+
'mkdirSync',
|
|
86
|
+
'runHistoryReaperNow',
|
|
87
|
+
'openTurnsDb',
|
|
88
|
+
'initHistory',
|
|
89
|
+
'acquireStartupLock',
|
|
90
|
+
'startWebhookIngestServer',
|
|
91
|
+
'createPollHealthCheck',
|
|
92
|
+
'loadObligations',
|
|
93
|
+
'installPosthogErrorHandlers',
|
|
94
|
+
'sweepActiveReactions',
|
|
95
|
+
])
|
|
96
|
+
|
|
97
|
+
function sentinelName(n: ts.Node): string | null {
|
|
98
|
+
if (ts.isCallExpression(n)) {
|
|
99
|
+
const e = n.expression
|
|
100
|
+
if (ts.isIdentifier(e) && CALL_SENTINELS.has(e.text)) return e.text
|
|
101
|
+
// `<obj>.startTimer(...)` — the silencePoke / pendingProgress timers.
|
|
102
|
+
if (ts.isPropertyAccessExpression(e) && e.name.text === 'startTimer') {
|
|
103
|
+
return `${e.expression.getText(sourceFile)}.startTimer`
|
|
104
|
+
}
|
|
105
|
+
// `process.on(...)` — the P0d signal/error-handler registrations
|
|
106
|
+
// (SIGTERM / SIGINT / beforeExit / unhandledRejection /
|
|
107
|
+
// uncaughtException), gated by P0e. Several route through shutdown() →
|
|
108
|
+
// process.exit, so an unguarded registration makes exit reachable from a
|
|
109
|
+
// bare import.
|
|
110
|
+
if (
|
|
111
|
+
ts.isPropertyAccessExpression(e) &&
|
|
112
|
+
e.name.text === 'on' &&
|
|
113
|
+
ts.isIdentifier(e.expression) &&
|
|
114
|
+
e.expression.text === 'process'
|
|
115
|
+
) {
|
|
116
|
+
return 'process.on'
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (n.kind === ts.SyntaxKind.AwaitExpression) return 'await'
|
|
120
|
+
return null
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function guardsOnMain(cond: ts.Expression): boolean {
|
|
124
|
+
return cond.getText(sourceFile).includes('isGatewayMain')
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Walk the module top level (never descending into function bodies) collecting
|
|
129
|
+
* unguarded sentinels. `guarded` becomes true beneath an isGatewayMain
|
|
130
|
+
* if/ternary branch.
|
|
131
|
+
*/
|
|
132
|
+
function collectUnguarded(): string[] {
|
|
133
|
+
const findings: string[] = []
|
|
134
|
+
function walk(node: ts.Node, guarded: boolean): void {
|
|
135
|
+
if (isFunctionLike(node)) return
|
|
136
|
+
if (ts.isIfStatement(node) && guardsOnMain(node.expression)) {
|
|
137
|
+
walk(node.thenStatement, true)
|
|
138
|
+
if (node.elseStatement) walk(node.elseStatement, guarded)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
if (ts.isConditionalExpression(node) && guardsOnMain(node.condition)) {
|
|
142
|
+
walk(node.whenTrue, true)
|
|
143
|
+
walk(node.whenFalse, guarded)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
const s = sentinelName(node)
|
|
147
|
+
if (s && !guarded) findings.push(`${s}@${lineOf(node)}`)
|
|
148
|
+
ts.forEachChild(node, c => walk(c, guarded))
|
|
149
|
+
}
|
|
150
|
+
for (const stmt of sourceFile.statements) walk(stmt, false)
|
|
151
|
+
return findings
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
describe('gateway #2996 P0c: module-load boot side effects are gated behind isGatewayMain', () => {
|
|
155
|
+
it('declares `isGatewayMain` as a module-scope const with the argv[1] main-check', () => {
|
|
156
|
+
let found = false
|
|
157
|
+
for (const stmt of sourceFile.statements) {
|
|
158
|
+
if (!ts.isVariableStatement(stmt)) continue
|
|
159
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
160
|
+
if (ts.isIdentifier(decl.name) && decl.name.text === 'isGatewayMain') {
|
|
161
|
+
found = true
|
|
162
|
+
const init = decl.initializer?.getText(sourceFile) ?? ''
|
|
163
|
+
// Guards on argv[1] ending in the source or the built bundle name.
|
|
164
|
+
expect(init).toContain('process.argv[1]')
|
|
165
|
+
expect(init).toContain("endsWith('gateway.ts')")
|
|
166
|
+
expect(init).toContain("endsWith('gateway.js')")
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
expect(found).toBe(true)
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('defines an async `startGateway()` boot function and NO leftover boot IIFE', () => {
|
|
174
|
+
let startGatewayFn: ts.FunctionDeclaration | undefined
|
|
175
|
+
for (const stmt of sourceFile.statements) {
|
|
176
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name?.text === 'startGateway') {
|
|
177
|
+
startGatewayFn = stmt
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
expect(startGatewayFn).toBeDefined()
|
|
181
|
+
const isAsync = (startGatewayFn!.modifiers ?? []).some(
|
|
182
|
+
m => m.kind === ts.SyntaxKind.AsyncKeyword,
|
|
183
|
+
)
|
|
184
|
+
expect(isAsync).toBe(true)
|
|
185
|
+
|
|
186
|
+
// The old top-level boot IIFE `void (async () => {…})()` must be gone: no
|
|
187
|
+
// module-scope immediately-invoked async arrow remains.
|
|
188
|
+
let bootIifes = 0
|
|
189
|
+
for (const stmt of sourceFile.statements) {
|
|
190
|
+
if (!ts.isExpressionStatement(stmt)) continue
|
|
191
|
+
let expr = stmt.expression
|
|
192
|
+
if (ts.isVoidExpression(expr)) expr = expr.expression
|
|
193
|
+
if (
|
|
194
|
+
ts.isCallExpression(expr) &&
|
|
195
|
+
(ts.isArrowFunction(expr.expression) || ts.isFunctionExpression(expr.expression))
|
|
196
|
+
) {
|
|
197
|
+
const inner = expr.expression
|
|
198
|
+
const asyncArrow =
|
|
199
|
+
(inner.modifiers ?? []).some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
200
|
+
if (asyncArrow) bootIifes++
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
expect(bootIifes).toBe(0)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('invokes startGateway() EXACTLY once, guarded by isGatewayMain', () => {
|
|
207
|
+
let total = 0
|
|
208
|
+
let guardedCalls = 0
|
|
209
|
+
function walk(node: ts.Node, guarded: boolean): void {
|
|
210
|
+
if (isFunctionLike(node)) return // only the module-scope invocation
|
|
211
|
+
if (ts.isIfStatement(node) && guardsOnMain(node.expression)) {
|
|
212
|
+
walk(node.thenStatement, true)
|
|
213
|
+
if (node.elseStatement) walk(node.elseStatement, guarded)
|
|
214
|
+
return
|
|
215
|
+
}
|
|
216
|
+
if (
|
|
217
|
+
ts.isCallExpression(node) &&
|
|
218
|
+
ts.isIdentifier(node.expression) &&
|
|
219
|
+
node.expression.text === 'startGateway'
|
|
220
|
+
) {
|
|
221
|
+
total++
|
|
222
|
+
if (guarded) guardedCalls++
|
|
223
|
+
}
|
|
224
|
+
ts.forEachChild(node, c => walk(c, guarded))
|
|
225
|
+
}
|
|
226
|
+
for (const stmt of sourceFile.statements) walk(stmt, false)
|
|
227
|
+
expect(total).toBe(1)
|
|
228
|
+
expect(guardedCalls).toBe(1)
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
it('leaves NO unguarded module-scope timer / boot-I/O / startup-lock side effect', () => {
|
|
232
|
+
// If this fails it prints exactly which sentinel (e.g. `setInterval@1234`,
|
|
233
|
+
// `await@9750`, `openTurnsDb@1700`) escaped the isGatewayMain guard.
|
|
234
|
+
expect(collectUnguarded()).toEqual([])
|
|
235
|
+
})
|
|
236
|
+
|
|
237
|
+
it('gates every module-scope timer handle (the 4 reaper/tick consts)', () => {
|
|
238
|
+
// pendingStateReaper / midSessionCardReaper / _deliveryMachineTick /
|
|
239
|
+
// _deliveryConfirmSweep are `const X = isGatewayMain ? setInterval(…) :
|
|
240
|
+
// undefined` — their setInterval must be inside an isGatewayMain ternary.
|
|
241
|
+
const timerConsts = new Set([
|
|
242
|
+
'pendingStateReaper',
|
|
243
|
+
'midSessionCardReaper',
|
|
244
|
+
'_deliveryMachineTick',
|
|
245
|
+
'_deliveryConfirmSweep',
|
|
246
|
+
])
|
|
247
|
+
const seen = new Set<string>()
|
|
248
|
+
for (const stmt of sourceFile.statements) {
|
|
249
|
+
if (!ts.isVariableStatement(stmt)) continue
|
|
250
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
251
|
+
if (
|
|
252
|
+
ts.isIdentifier(decl.name) &&
|
|
253
|
+
timerConsts.has(decl.name.text) &&
|
|
254
|
+
decl.initializer
|
|
255
|
+
) {
|
|
256
|
+
seen.add(decl.name.text)
|
|
257
|
+
const init = decl.initializer
|
|
258
|
+
// Must be a conditional whose condition mentions isGatewayMain and
|
|
259
|
+
// whose truthy branch is the setInterval call.
|
|
260
|
+
expect(ts.isConditionalExpression(init)).toBe(true)
|
|
261
|
+
if (ts.isConditionalExpression(init)) {
|
|
262
|
+
expect(guardsOnMain(init.condition)).toBe(true)
|
|
263
|
+
expect(init.whenTrue.getText(sourceFile)).toContain('setInterval')
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
expect([...seen].sort()).toEqual([...timerConsts].sort())
|
|
269
|
+
})
|
|
270
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime boot-smoke pin (finding M-1, full-stack adversarial review).
|
|
3
|
+
*
|
|
4
|
+
* The source-parse pin `gateway-boot-side-effect-gating.test.ts` proves the
|
|
5
|
+
* module-load side effects are `isGatewayMain`-guarded, but it NEVER actually
|
|
6
|
+
* boots the gateway — so it is structurally blind to a temporal-dead-zone
|
|
7
|
+
* ReferenceError inside a guarded builder. That exact class shipped on main:
|
|
8
|
+
* PR-C3 (#3392) made `gatewayLivenessWiringDeps()` eagerly read
|
|
9
|
+
* `pendingInboundBuffer` (a `const` declared ~200 lines LATER than the
|
|
10
|
+
* module-load `silencePoke.startTimer(...)` call that invokes the builder),
|
|
11
|
+
* crash-looping every real gateway boot with
|
|
12
|
+
* `uncaughtException: ReferenceError: Cannot access 'pendingInboundBuffer'
|
|
13
|
+
* before initialization`
|
|
14
|
+
* right after the boot lock — while CI stayed green because `isGatewayMain`
|
|
15
|
+
* is false under vitest (argv[1] is the runner).
|
|
16
|
+
*
|
|
17
|
+
* This test closes that blind spot the only way a source parse can't: it
|
|
18
|
+
* SPAWNS `bun telegram-plugin/gateway/gateway.ts` as the process entrypoint
|
|
19
|
+
* (so `isGatewayMain` is TRUE and every guarded module-load side effect
|
|
20
|
+
* actually runs), in a hermetic TELEGRAM_STATE_DIR with a fake bot token, and
|
|
21
|
+
* asserts the gateway reaches a POST-module-eval stderr marker
|
|
22
|
+
* (`ipc — listening`, emitted when `createIpcServer` binds its UDS at
|
|
23
|
+
* gateway.ts ~10454, well past the TDZ site) with NO
|
|
24
|
+
* `uncaughtException: ReferenceError` on the way. The 401-Unauthorized the
|
|
25
|
+
* fake token later triggers happens INSIDE `startGateway()` — after module
|
|
26
|
+
* eval — so it never precedes the marker and is not a boot-order failure.
|
|
27
|
+
*
|
|
28
|
+
* Parameterized over the two kill-switch postures so a future eager-read
|
|
29
|
+
* introduced on either the router-v2 or turn-end-funnel-v2 path is also
|
|
30
|
+
* caught:
|
|
31
|
+
* - both flags unset (production default today)
|
|
32
|
+
* - SWITCHROOM_INBOUND_ROUTER_V2=1 + SWITCHROOM_TURN_END_FUNNEL_V2=1
|
|
33
|
+
*
|
|
34
|
+
* Bun is the prod + CI gateway runtime (`bun test` is a required check), so
|
|
35
|
+
* spawning it here is CI-native, not CI-hostile.
|
|
36
|
+
*
|
|
37
|
+
* VERIFIED to red on pre-fix main: reverting the getter fix in the working
|
|
38
|
+
* tree makes both parameterizations fail — the child dies on the ReferenceError
|
|
39
|
+
* and never emits the marker (mutation evidence in the PR body).
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { describe, it, expect } from 'vitest'
|
|
43
|
+
import { spawn } from 'node:child_process'
|
|
44
|
+
import { mkdtempSync, rmSync } from 'node:fs'
|
|
45
|
+
import { tmpdir } from 'node:os'
|
|
46
|
+
import { join, resolve } from 'node:path'
|
|
47
|
+
import { fileURLToPath } from 'node:url'
|
|
48
|
+
import { dirname } from 'node:path'
|
|
49
|
+
|
|
50
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
51
|
+
const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
|
|
52
|
+
|
|
53
|
+
// The marker is emitted synchronously by createIpcServer() at gateway.ts
|
|
54
|
+
// (~10454), which is AFTER the module-load silence-poke wiring (~9653) that
|
|
55
|
+
// held the TDZ read. Reaching it proves module eval cleared the crash site.
|
|
56
|
+
const POST_EVAL_MARKER = 'ipc — listening'
|
|
57
|
+
// A fake bot token: valid Bot API shape (<digits>:<35 chars>) so grammy's
|
|
58
|
+
// constructor accepts it; the 401 only lands later during startGateway().
|
|
59
|
+
const FAKE_BOT_TOKEN = '123456:' + 'A'.repeat(35)
|
|
60
|
+
const BOOT_TIMEOUT_MS = 45_000
|
|
61
|
+
|
|
62
|
+
interface BootResult {
|
|
63
|
+
reachedMarker: boolean
|
|
64
|
+
sawReferenceError: boolean
|
|
65
|
+
stderr: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function bootGateway(extraEnv: Record<string, string>): Promise<BootResult> {
|
|
69
|
+
return new Promise<BootResult>((resolvePromise, rejectPromise) => {
|
|
70
|
+
const stateDir = mkdtempSync(join(tmpdir(), 'gw-boot-smoke-'))
|
|
71
|
+
const child = spawn('bun', [GATEWAY_PATH], {
|
|
72
|
+
env: {
|
|
73
|
+
...process.env,
|
|
74
|
+
TELEGRAM_STATE_DIR: stateDir,
|
|
75
|
+
SWITCHROOM_GATEWAY_PID_FILE: join(stateDir, 'gateway.pid.json'),
|
|
76
|
+
TELEGRAM_BOT_TOKEN: FAKE_BOT_TOKEN,
|
|
77
|
+
SWITCHROOM_AGENT_NAME: 'boot-smoke',
|
|
78
|
+
// Scrub the two kill switches to a known-OFF baseline so the
|
|
79
|
+
// "unset" posture is genuinely unset even if the CI shell exports
|
|
80
|
+
// them; the "enabled" posture re-sets them via extraEnv below.
|
|
81
|
+
SWITCHROOM_INBOUND_ROUTER_V2: '',
|
|
82
|
+
SWITCHROOM_TURN_END_FUNNEL_V2: '',
|
|
83
|
+
...extraEnv,
|
|
84
|
+
},
|
|
85
|
+
stdio: ['ignore', 'ignore', 'pipe'],
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
let stderr = ''
|
|
89
|
+
let settled = false
|
|
90
|
+
const finish = (): void => {
|
|
91
|
+
if (settled) return
|
|
92
|
+
settled = true
|
|
93
|
+
clearTimeout(timer)
|
|
94
|
+
try { child.kill('SIGKILL') } catch { /* already gone */ }
|
|
95
|
+
try { rmSync(stateDir, { recursive: true, force: true }) } catch { /* best-effort */ }
|
|
96
|
+
resolvePromise({
|
|
97
|
+
reachedMarker: stderr.includes(POST_EVAL_MARKER),
|
|
98
|
+
sawReferenceError: /uncaughtException:\s*ReferenceError/.test(stderr),
|
|
99
|
+
stderr,
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const timer = setTimeout(finish, BOOT_TIMEOUT_MS)
|
|
104
|
+
|
|
105
|
+
child.stderr.on('data', (chunk: Buffer) => {
|
|
106
|
+
stderr += chunk.toString()
|
|
107
|
+
// As soon as we see the marker OR a boot-order ReferenceError, we have
|
|
108
|
+
// our verdict — no need to keep the child alive.
|
|
109
|
+
if (stderr.includes(POST_EVAL_MARKER) || /uncaughtException:\s*ReferenceError/.test(stderr)) {
|
|
110
|
+
finish()
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
child.on('error', (err) => {
|
|
114
|
+
if (settled) return
|
|
115
|
+
settled = true
|
|
116
|
+
clearTimeout(timer)
|
|
117
|
+
try { rmSync(stateDir, { recursive: true, force: true }) } catch { /* best-effort */ }
|
|
118
|
+
rejectPromise(err)
|
|
119
|
+
})
|
|
120
|
+
// If the process exits before the marker (e.g. the TDZ crash aborts the
|
|
121
|
+
// process), resolve with whatever we captured so the assertion can fail
|
|
122
|
+
// with the real stderr.
|
|
123
|
+
child.on('exit', finish)
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const POSTURES: Array<{ name: string; env: Record<string, string> }> = [
|
|
128
|
+
{ name: 'both kill switches unset (prod default)', env: {} },
|
|
129
|
+
{
|
|
130
|
+
name: 'router-v2 + turn-end-funnel-v2 enabled',
|
|
131
|
+
env: { SWITCHROOM_INBOUND_ROUTER_V2: '1', SWITCHROOM_TURN_END_FUNNEL_V2: '1' },
|
|
132
|
+
},
|
|
133
|
+
]
|
|
134
|
+
|
|
135
|
+
describe('gateway boot-smoke (M-1): real bun boot reaches post-module-eval marker with no TDZ ReferenceError', () => {
|
|
136
|
+
it.each(POSTURES)('boots cleanly with %s', async ({ env }) => {
|
|
137
|
+
const result = await bootGateway(env)
|
|
138
|
+
// The load-bearing assertion: no temporal-dead-zone crash during module
|
|
139
|
+
// eval. If this reds, the stderr tail names the offending binding.
|
|
140
|
+
expect(
|
|
141
|
+
result.sawReferenceError,
|
|
142
|
+
`gateway boot emitted a ReferenceError before module eval finished:\n${result.stderr.slice(-2000)}`,
|
|
143
|
+
).toBe(false)
|
|
144
|
+
// And it actually got past the TDZ site to the IPC-listening marker.
|
|
145
|
+
expect(
|
|
146
|
+
result.reachedMarker,
|
|
147
|
+
`gateway boot never reached "${POST_EVAL_MARKER}"; stderr tail:\n${result.stderr.slice(-2000)}`,
|
|
148
|
+
).toBe(true)
|
|
149
|
+
}, BOOT_TIMEOUT_MS + 15_000)
|
|
150
|
+
})
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Import-safety pin for the #2996 P0b move: token materialization + `new Bot`
|
|
3
|
+
* construction are DEFERRED out of module import into the boot function
|
|
4
|
+
* `initGatewayBot()`.
|
|
5
|
+
*
|
|
6
|
+
* WHY this asserts on the parsed SOURCE, not a real `import()`:
|
|
7
|
+
* gateway.ts is NOT import-safe under vitest yet — the boot IIFE (`void (async
|
|
8
|
+
* () => { … })()`) still fires at module evaluation and P0b deliberately does
|
|
9
|
+
* NOT touch it (that guard is P0c/P0d scope). So a real `await import()` would
|
|
10
|
+
* still trigger the boot path. The oracle for "how the repo pins gateway
|
|
11
|
+
* structure without booting it" is source-parsing via `ts.createSourceFile`
|
|
12
|
+
* (see gateway-handler-registration-wiring.test.ts /
|
|
13
|
+
* gateway-pending-command-wiring.test.ts). This test follows that pattern and
|
|
14
|
+
* asserts OUTCOMES about where construction lives:
|
|
15
|
+
*
|
|
16
|
+
* 1. Module import (top-level, outside any function body) performs NO
|
|
17
|
+
* `new Bot(...)`, NO dynamic import of the materialize-bot-token helper,
|
|
18
|
+
* NO `materializeBotToken(...)` call, and has NO reachable `process.exit`
|
|
19
|
+
* and NO top-level `await`. i.e. importing gateway.ts constructs no bot,
|
|
20
|
+
* reads no token, and cannot exit the process.
|
|
21
|
+
* 2. `bot` is a deferred module-scope `let` (definite-assignment), NOT a
|
|
22
|
+
* `const bot = new Bot(...)`.
|
|
23
|
+
* 3. The boot function `initGatewayBot()` DOES all of the above: it is an
|
|
24
|
+
* async function whose body contains the `new Bot(...)`, the materialize
|
|
25
|
+
* dynamic import, the `materializeBotToken(...)` call, and the single
|
|
26
|
+
* `registerGatewayHandlers(bot, …)` wiring call.
|
|
27
|
+
*
|
|
28
|
+
* A count/handler-theater test would not catch a regression that re-hoists
|
|
29
|
+
* `new Bot` or the token read back to module scope; these node-location
|
|
30
|
+
* assertions do.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { describe, it, expect } from 'vitest'
|
|
34
|
+
import { readFileSync } from 'node:fs'
|
|
35
|
+
import { fileURLToPath } from 'node:url'
|
|
36
|
+
import { dirname, resolve } from 'node:path'
|
|
37
|
+
import ts from 'typescript'
|
|
38
|
+
|
|
39
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
40
|
+
const GATEWAY_PATH = resolve(__dirname, '..', 'gateway', 'gateway.ts')
|
|
41
|
+
const GATEWAY_SRC = readFileSync(GATEWAY_PATH, 'utf8')
|
|
42
|
+
|
|
43
|
+
const sourceFile = ts.createSourceFile(
|
|
44
|
+
GATEWAY_PATH,
|
|
45
|
+
GATEWAY_SRC,
|
|
46
|
+
ts.ScriptTarget.Latest,
|
|
47
|
+
true,
|
|
48
|
+
ts.ScriptKind.TS,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
const MATERIALIZE_MODULE_HINT = 'materialize-bot-token'
|
|
52
|
+
|
|
53
|
+
function isFunctionLike(n: ts.Node): boolean {
|
|
54
|
+
return (
|
|
55
|
+
ts.isFunctionDeclaration(n) ||
|
|
56
|
+
ts.isFunctionExpression(n) ||
|
|
57
|
+
ts.isArrowFunction(n) ||
|
|
58
|
+
ts.isMethodDeclaration(n) ||
|
|
59
|
+
ts.isConstructorDeclaration(n) ||
|
|
60
|
+
ts.isGetAccessorDeclaration(n) ||
|
|
61
|
+
ts.isSetAccessorDeclaration(n)
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Walk every node that executes at MODULE SCOPE — i.e. descend through the
|
|
67
|
+
* source file but STOP at any function-like boundary (function/arrow/method
|
|
68
|
+
* bodies run later, when called, not at import). A `new Bot` or token read
|
|
69
|
+
* found here would fire at import; the whole point of P0b is that none do.
|
|
70
|
+
*/
|
|
71
|
+
function walkModuleScope(visitor: (n: ts.Node) => void): void {
|
|
72
|
+
const recur = (node: ts.Node): void => {
|
|
73
|
+
ts.forEachChild(node, child => {
|
|
74
|
+
if (isFunctionLike(child)) return // do not descend into function bodies
|
|
75
|
+
visitor(child)
|
|
76
|
+
recur(child)
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
recur(sourceFile)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function isNewBot(n: ts.Node): boolean {
|
|
83
|
+
return ts.isNewExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'Bot'
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isMaterializeDynamicImport(n: ts.Node): boolean {
|
|
87
|
+
// `import('…materialize-bot-token…')`
|
|
88
|
+
if (!ts.isCallExpression(n)) return false
|
|
89
|
+
if (n.expression.kind !== ts.SyntaxKind.ImportKeyword) return false
|
|
90
|
+
const arg = n.arguments[0]
|
|
91
|
+
return arg != null && ts.isStringLiteralLike(arg) && arg.text.includes(MATERIALIZE_MODULE_HINT)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isMaterializeBotTokenCall(n: ts.Node): boolean {
|
|
95
|
+
return ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === 'materializeBotToken'
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isProcessExitCall(n: ts.Node): boolean {
|
|
99
|
+
return (
|
|
100
|
+
ts.isCallExpression(n) &&
|
|
101
|
+
ts.isPropertyAccessExpression(n.expression) &&
|
|
102
|
+
ts.isIdentifier(n.expression.expression) &&
|
|
103
|
+
n.expression.expression.text === 'process' &&
|
|
104
|
+
n.expression.name.text === 'exit'
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function findFunction(name: string): ts.FunctionDeclaration | undefined {
|
|
109
|
+
for (const s of sourceFile.statements) {
|
|
110
|
+
if (ts.isFunctionDeclaration(s) && s.name?.text === name) return s
|
|
111
|
+
}
|
|
112
|
+
return undefined
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Count nodes matching a predicate anywhere within a subtree. */
|
|
116
|
+
function countIn(root: ts.Node, pred: (n: ts.Node) => boolean): number {
|
|
117
|
+
let count = 0
|
|
118
|
+
const visit = (node: ts.Node): void => {
|
|
119
|
+
if (pred(node)) count++
|
|
120
|
+
ts.forEachChild(node, visit)
|
|
121
|
+
}
|
|
122
|
+
visit(root)
|
|
123
|
+
return count
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
describe('gateway #2996 P0b: token materialization + new Bot are deferred to boot, import is construction-clean', () => {
|
|
127
|
+
it('constructs NO Bot at module import (no top-level `new Bot`)', () => {
|
|
128
|
+
let count = 0
|
|
129
|
+
walkModuleScope(n => {
|
|
130
|
+
if (isNewBot(n)) count++
|
|
131
|
+
})
|
|
132
|
+
expect(count).toBe(0)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('reads NO token at module import (no top-level materialize import or materializeBotToken call)', () => {
|
|
136
|
+
let dynImports = 0
|
|
137
|
+
let materializeCalls = 0
|
|
138
|
+
walkModuleScope(n => {
|
|
139
|
+
if (isMaterializeDynamicImport(n)) dynImports++
|
|
140
|
+
if (isMaterializeBotTokenCall(n)) materializeCalls++
|
|
141
|
+
})
|
|
142
|
+
expect(dynImports).toBe(0)
|
|
143
|
+
expect(materializeCalls).toBe(0)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('performs NO token-refusal exit at module import (no top-level process.exit for token materialization)', () => {
|
|
147
|
+
// NOTE: the pre-existing startup-lock mutex (#…) still has one top-level
|
|
148
|
+
// process.exit + top-level await — that is P0c/P0d scope, deliberately NOT
|
|
149
|
+
// touched here. This asserts specifically that the TOKEN-materialization
|
|
150
|
+
// exit paths (exit(1) on materialize failure / missing token) are gone from
|
|
151
|
+
// module scope: there is at most the single startup-lock exit, and none of
|
|
152
|
+
// them sit in a materialize/`new Bot` context (covered by the tests above).
|
|
153
|
+
let exits = 0
|
|
154
|
+
walkModuleScope(n => {
|
|
155
|
+
if (isProcessExitCall(n)) exits++
|
|
156
|
+
})
|
|
157
|
+
// The two token-materialization exit(1) sites moved into initGatewayBot;
|
|
158
|
+
// only the pre-existing startup-lock exit remains at module scope.
|
|
159
|
+
expect(exits).toBeLessThanOrEqual(1)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('leaves NO module-scope reference to `bot` or `lockedBot` outside their declarations (all consumers deferred)', () => {
|
|
163
|
+
// Completeness pin: after P0b, the ONLY module-scope occurrences of `bot` /
|
|
164
|
+
// `lockedBot` are their `let` declarations. Every consumer — the api-tap /
|
|
165
|
+
// heartbeat wiring, checklist probes, chatLock.wrapBot, the /approvals
|
|
166
|
+
// registration, the callback-query factory, and registerGatewayHandlers —
|
|
167
|
+
// must live inside a function (initGatewayBot or a post-boot handler). A
|
|
168
|
+
// regression that re-hoists any consumer to import scope fails here.
|
|
169
|
+
const declLines = new Set<number>()
|
|
170
|
+
for (const stmt of sourceFile.statements) {
|
|
171
|
+
if (!ts.isVariableStatement(stmt)) continue
|
|
172
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
173
|
+
if (ts.isIdentifier(decl.name) && (decl.name.text === 'bot' || decl.name.text === 'lockedBot')) {
|
|
174
|
+
declLines.add(sourceFile.getLineAndCharacterOfPosition(decl.name.getStart(sourceFile)).line)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
const stray: string[] = []
|
|
179
|
+
walkModuleScope(n => {
|
|
180
|
+
if (ts.isIdentifier(n) && (n.text === 'bot' || n.text === 'lockedBot')) {
|
|
181
|
+
const line = sourceFile.getLineAndCharacterOfPosition(n.getStart(sourceFile)).line
|
|
182
|
+
if (!declLines.has(line)) stray.push(`${n.text}@${line + 1}`)
|
|
183
|
+
}
|
|
184
|
+
})
|
|
185
|
+
expect(stray).toEqual([])
|
|
186
|
+
})
|
|
187
|
+
|
|
188
|
+
it('declares `bot` as a deferred module-scope `let`, not `const bot = new Bot(...)`', () => {
|
|
189
|
+
let botDecls = 0
|
|
190
|
+
let botHasInitializer = false
|
|
191
|
+
for (const stmt of sourceFile.statements) {
|
|
192
|
+
if (!ts.isVariableStatement(stmt)) continue
|
|
193
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
194
|
+
if (ts.isIdentifier(decl.name) && decl.name.text === 'bot') {
|
|
195
|
+
botDecls++
|
|
196
|
+
if (decl.initializer != null) botHasInitializer = true
|
|
197
|
+
// `let`, not `const`
|
|
198
|
+
expect(stmt.declarationList.flags & ts.NodeFlags.Const).toBe(0)
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
expect(botDecls).toBe(1)
|
|
203
|
+
expect(botHasInitializer).toBe(false)
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('defines an async initGatewayBot() boot function', () => {
|
|
207
|
+
const fn = findFunction('initGatewayBot')
|
|
208
|
+
expect(fn).toBeDefined()
|
|
209
|
+
const isAsync = (fn!.modifiers ?? []).some(m => m.kind === ts.SyntaxKind.AsyncKeyword)
|
|
210
|
+
expect(isAsync).toBe(true)
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('initGatewayBot() performs the construction: new Bot, token materialization, and the registration wiring call', () => {
|
|
214
|
+
const fn = findFunction('initGatewayBot')
|
|
215
|
+
expect(fn?.body).toBeDefined()
|
|
216
|
+
const body = fn!.body!
|
|
217
|
+
|
|
218
|
+
// Exactly one `new Bot(...)` — the single constructed instance.
|
|
219
|
+
expect(countIn(body, isNewBot)).toBe(1)
|
|
220
|
+
|
|
221
|
+
// Token materialization lives here (dynamic import + the resolve call).
|
|
222
|
+
expect(countIn(body, isMaterializeDynamicImport)).toBe(1)
|
|
223
|
+
expect(countIn(body, isMaterializeBotTokenCall)).toBe(1)
|
|
224
|
+
|
|
225
|
+
// The two P0a-missed import-time bot consumers also moved here (P0b): the
|
|
226
|
+
// callback-query factory and the /approvals registration.
|
|
227
|
+
const isNamedCall = (name: string) => (n: ts.Node) =>
|
|
228
|
+
ts.isCallExpression(n) && ts.isIdentifier(n.expression) && n.expression.text === name
|
|
229
|
+
expect(countIn(body, isNamedCall('createCallbackQueryHandlers'))).toBe(1)
|
|
230
|
+
expect(countIn(body, isNamedCall('registerApprovalsCommands'))).toBe(1)
|
|
231
|
+
|
|
232
|
+
// The refuse-to-boot exits (process.exit(1)) live here, not at import.
|
|
233
|
+
expect(countIn(body, isProcessExitCall)).toBeGreaterThanOrEqual(1)
|
|
234
|
+
|
|
235
|
+
// The single registerGatewayHandlers(bot, …) wiring call moved here (P0b).
|
|
236
|
+
let regCalls = 0
|
|
237
|
+
const visit = (node: ts.Node): void => {
|
|
238
|
+
if (
|
|
239
|
+
ts.isCallExpression(node) &&
|
|
240
|
+
ts.isIdentifier(node.expression) &&
|
|
241
|
+
node.expression.text === 'registerGatewayHandlers'
|
|
242
|
+
) {
|
|
243
|
+
regCalls++
|
|
244
|
+
expect(node.arguments[0]?.getText(sourceFile)).toBe('bot')
|
|
245
|
+
}
|
|
246
|
+
ts.forEachChild(node, visit)
|
|
247
|
+
}
|
|
248
|
+
visit(body)
|
|
249
|
+
expect(regCalls).toBe(1)
|
|
250
|
+
})
|
|
251
|
+
})
|