@mnci/az-durable 0.1.1 → 0.1.3

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.
@@ -0,0 +1,369 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Minimal ESLint rule shapes, declared locally.
5
+ *
6
+ * @remarks
7
+ * Declared here rather than imported from `@types/eslint` or
8
+ * `@typescript-eslint/utils` because this package ships **zero runtime
9
+ * dependencies**, and a type-only dependency is still a dependency a consumer
10
+ * must be able to resolve. These cover exactly what the three rules use.
11
+ */
12
+ /**
13
+ * Whether a call expression registers an orchestration.
14
+ *
15
+ * @remarks
16
+ * Matches both `defineOrchestration(...)` and the raw
17
+ * `df.app.orchestration(...)`, since an orchestration written either way has
18
+ * the same determinism constraints.
19
+ *
20
+ * **Heuristic, by construction.** These rules match on call-site SHAPE, so an
21
+ * orchestration body extracted into a helper function is not caught. That is a
22
+ * documented limit rather than a defect: following the value would require type
23
+ * information the rule deliberately does not depend on.
24
+ *
25
+ * @param node - A `CallExpression` node.
26
+ * @returns `true` when the call registers an orchestration.
27
+ * @throws Never - pure inspection.
28
+ * @typeParam None - this function has no generic type parameters.
29
+ */
30
+ function isOrchestrationRegistration(node) {
31
+ const callee = node.callee;
32
+ if (callee === undefined) {
33
+ return false;
34
+ }
35
+ if (callee.type === 'Identifier' && callee.name === 'defineOrchestration') {
36
+ return true;
37
+ }
38
+ // df.app.orchestration(...) — match on the trailing property, so any local
39
+ // alias for the namespace still matches.
40
+ if (callee.type === 'MemberExpression') {
41
+ const property = callee.property;
42
+ return property?.type === 'Identifier' && property.name === 'orchestration';
43
+ }
44
+ return false;
45
+ }
46
+ /**
47
+ * The name a callee refers to, if it is a plain identifier or member access.
48
+ *
49
+ * @remarks
50
+ * For a member expression this returns the TRAILING property, so `df.now()`
51
+ * and `context.df.now()` both read as `now`. Callers that must distinguish a
52
+ * bare call from a member call check `node.type` themselves - `require-yield-star`
53
+ * does exactly that, because `yield c.df.callActivity(...)` is the correct raw
54
+ * SDK call and must not be flagged.
55
+ *
56
+ * @param node - A callee node.
57
+ * @returns The identifier or property name, or `undefined`.
58
+ * @throws Never - pure inspection.
59
+ * @typeParam None - this function has no generic type parameters.
60
+ */
61
+ function calleeName(node) {
62
+ if (node === undefined) {
63
+ return undefined;
64
+ }
65
+ if (node.type === 'Identifier') {
66
+ return node.name;
67
+ }
68
+ if (node.type === 'MemberExpression') {
69
+ const property = node.property;
70
+ if (property?.type === 'Identifier') {
71
+ return property.name;
72
+ }
73
+ }
74
+ return undefined;
75
+ }
76
+
77
+ /** Global reads that differ on every replay, with the replacement to suggest. */
78
+ const FORBIDDEN_CALLS = {
79
+ 'Date.now': 'now(context).getTime()',
80
+ 'Math.random': 'context.df.newGuid(...) or an activity',
81
+ 'crypto.randomUUID': 'context.df.newGuid(...)',
82
+ fetch: 'an activity — network calls must not run in an orchestrator',
83
+ axios: 'an activity — network calls must not run in an orchestrator'
84
+ };
85
+ /**
86
+ * Flags non-deterministic operations inside an orchestration body.
87
+ *
88
+ * @remarks
89
+ * The highest-value rule in the package. Each of these returns a DIFFERENT
90
+ * value on every replay, and the failure is silent: the orchestration still
91
+ * completes, it just produces output that disagrees with its own history.
92
+ * Nothing in the runtime reports it.
93
+ *
94
+ * Every message names the replacement, because "this is non-deterministic" is
95
+ * only half of what a reader needs.
96
+ *
97
+ * Heuristic by design — see {@link isOrchestrationRegistration}.
98
+ */
99
+ const noNondeterministicOrchestrator = {
100
+ meta: {
101
+ type: 'problem',
102
+ docs: {
103
+ description: 'Disallow non-deterministic operations inside an orchestration.'
104
+ },
105
+ schema: [],
106
+ messages: {
107
+ forbidden: '{{what}} is non-deterministic on replay. Use {{fix}} instead.',
108
+ newDate: 'new Date() is non-deterministic on replay. Use now(context) instead.',
109
+ processEnv: 'process.env is read at replay time and may differ between deploys. ' + 'Read it in an activity, or pass it as orchestration input.'
110
+ }
111
+ },
112
+ create(context) {
113
+ let depth = 0;
114
+ const enter = node => {
115
+ if (isOrchestrationRegistration(node)) {
116
+ depth += 1;
117
+ }
118
+ };
119
+ const exit = node => {
120
+ if (isOrchestrationRegistration(node)) {
121
+ depth -= 1;
122
+ }
123
+ };
124
+ return {
125
+ CallExpression: node => {
126
+ enter(node);
127
+ if (depth === 0) {
128
+ return;
129
+ }
130
+ const callee = node.callee;
131
+ const object = callee?.object;
132
+ const full = object?.type === 'Identifier' ? `${String(object.name)}.${String(calleeName(callee))}` : calleeName(callee) ?? '';
133
+ const fix = FORBIDDEN_CALLS[full];
134
+ if (fix !== undefined) {
135
+ context.report({
136
+ node,
137
+ messageId: 'forbidden',
138
+ data: {
139
+ what: full,
140
+ fix
141
+ }
142
+ });
143
+ }
144
+ },
145
+ 'CallExpression:exit': exit,
146
+ NewExpression: node => {
147
+ if (depth === 0) {
148
+ return;
149
+ }
150
+ const callee = node.callee;
151
+ const args = node.arguments;
152
+ // `new Date(someInstant)` is fine and common — only the argument-less
153
+ // form reads the wall clock.
154
+ if (callee?.type === 'Identifier' && callee.name === 'Date' && (args?.length ?? 0) === 0) {
155
+ context.report({
156
+ node,
157
+ messageId: 'newDate'
158
+ });
159
+ }
160
+ },
161
+ MemberExpression: node => {
162
+ if (depth === 0) {
163
+ return;
164
+ }
165
+ const object = node.object;
166
+ const property = node.property;
167
+ if (object?.type === 'Identifier' && object.name === 'process' && property?.type === 'Identifier' && property.name === 'env') {
168
+ context.report({
169
+ node,
170
+ messageId: 'processEnv'
171
+ });
172
+ }
173
+ }
174
+ };
175
+ }
176
+ };
177
+
178
+ /** SDK handler aliases that erase a handler's real signature. */
179
+ const ERASING_TYPES = new Set(['ActivityHandler', 'OrchestrationHandler', 'FunctionHandler']);
180
+ /**
181
+ * The annotation's type name, if it is a bare type reference.
182
+ *
183
+ * @param annotation - A node carrying a `typeAnnotation`, or `undefined`.
184
+ * @returns The referenced type's name, or `undefined` when it is not a bare reference.
185
+ * @throws Never - pure inspection.
186
+ * @typeParam None - this function has no generic type parameters.
187
+ */
188
+ function referencedName(annotation) {
189
+ const typeAnnotation = annotation?.typeAnnotation;
190
+ if (typeAnnotation?.type !== 'TSTypeReference') {
191
+ return undefined;
192
+ }
193
+ const typeName = typeAnnotation.typeName;
194
+ return typeName?.type === 'Identifier' ? typeName.name : undefined;
195
+ }
196
+ /**
197
+ * Flags annotations that collapse a typed handler back to `any`.
198
+ *
199
+ * @remarks
200
+ * **The rule that actually earns its keep**, because TypeScript cannot catch
201
+ * this: the annotation is legal, so nothing errors — the types simply stop
202
+ * meaning anything.
203
+ *
204
+ * `ActivityHandler` is an alias for `FunctionHandler`, which the SDK declares as
205
+ * `(triggerInput: any, context: InvocationContext) => FunctionResult<any>`. So
206
+ * annotating a handler with it discards the very signature `defineActivity`
207
+ * exists to capture, and the activity silently becomes `any` in, `any` out. The
208
+ * package then appears to work — every call compiles — while checking nothing.
209
+ *
210
+ * The second, nastier form is a middleware wrapper typed
211
+ * `(h: ActivityHandler) => ActivityHandler`. That collapses EVERY handler it
212
+ * wraps, so one `injectLogger` helper can quietly disable type safety across a
213
+ * whole Function App. The fix is to make the wrapper generic:
214
+ *
215
+ * ```ts
216
+ * const withLogging = <I, O>(h: (i: I, c: InvocationContext) => O) =>
217
+ * (i: I, c: InvocationContext): O => { c.log('...'); return h(i, c) }
218
+ * ```
219
+ */
220
+ const noUntypedActivityHandler = {
221
+ meta: {
222
+ type: 'problem',
223
+ docs: {
224
+ description: 'Disallow handler annotations that erase inferred types.'
225
+ },
226
+ schema: [],
227
+ messages: {
228
+ erased: "Annotating with '{{name}}' erases the handler's real signature — it is an alias for " + '(triggerInput: any, context) => any, so the activity becomes `any` in and `any` out. ' + 'Drop the annotation and let defineActivity infer it.',
229
+ erasedWrapper: "A wrapper typed '{{name}}' collapses every handler it wraps to `any`. " + 'Make it generic: <I, O>(h: (i: I, c: InvocationContext) => O) => (i: I, c: InvocationContext): O.'
230
+ }
231
+ },
232
+ create(context) {
233
+ return {
234
+ // const handler: ActivityHandler = ...
235
+ Identifier: node => {
236
+ const name = referencedName(node.typeAnnotation);
237
+ if (name === undefined || !ERASING_TYPES.has(name)) {
238
+ return;
239
+ }
240
+ // A function PARAMETER annotated this way is the middleware form, which
241
+ // is worse: it erases every handler passed through it, not just one.
242
+ const isParameter = node.parent?.type?.startsWith('TS') === false && node.parent?.params?.includes(node) === true;
243
+ context.report({
244
+ node,
245
+ messageId: isParameter ? 'erasedWrapper' : 'erased',
246
+ data: {
247
+ name
248
+ }
249
+ });
250
+ }
251
+ };
252
+ }
253
+ };
254
+
255
+ /** The wrapper calls that must be delegated to, not yielded. */
256
+ const DELEGATED = new Set(['callActivity', 'callSubOrchestration', 'all', 'any', 'waitForEvent', 'sleepFor', 'sleepUntil']);
257
+ /**
258
+ * Flags `yield callActivity(...)` where `yield *` is meant.
259
+ *
260
+ * @remarks
261
+ * **A convenience, not a safety net — and the build plan was wrong about this.**
262
+ * The plan described bare `yield` as compiling silently to `any`, "the
263
+ * difference between the package working and appearing to work". Measured
264
+ * against the real typings, it is a COMPILE ERROR in both registration paths:
265
+ * `TS2345` through `defineOrchestration` and `TS2322` through the SDK's own
266
+ * `OrchestrationHandler`, because these helpers return a `Generator` and
267
+ * yielding one where a `Task` is expected does not typecheck.
268
+ *
269
+ * The genuinely silent case is the RAW SDK — `yield context.df.callActivity(...)`
270
+ * returns `any` and compiles — which is the baseline this package replaces.
271
+ *
272
+ * So this rule earns its place only by reporting a clearer message than
273
+ * `TS2345` does. It is in `recommended` for that reason, not because anything
274
+ * depends on it.
275
+ */
276
+ const requireYieldStar = {
277
+ meta: {
278
+ type: 'suggestion',
279
+ docs: {
280
+ description: 'Require `yield *` when calling a delegating helper.'
281
+ },
282
+ schema: [],
283
+ messages: {
284
+ useYieldStar: "Use 'yield *' rather than 'yield' with {{name}}(). Delegation is what carries the " + 'result type; a bare yield does not typecheck, but the compiler error is obscure.'
285
+ }
286
+ },
287
+ create(context) {
288
+ return {
289
+ YieldExpression: node => {
290
+ if (node.delegate === true) {
291
+ return;
292
+ }
293
+ const argument = node.argument;
294
+ if (argument?.type !== 'CallExpression') {
295
+ return;
296
+ }
297
+ // A BARE IDENTIFIER only. `c.df.callActivity(...)` is the raw SDK call,
298
+ // which is correct code and must not be flagged — matching the trailing
299
+ // property of a member expression would flag it, which a negative
300
+ // fixture caught.
301
+ const callee = argument.callee;
302
+ if (callee?.type !== 'Identifier') {
303
+ return;
304
+ }
305
+ const name = callee.name;
306
+ if (DELEGATED.has(name)) {
307
+ context.report({
308
+ node,
309
+ messageId: 'useYieldStar',
310
+ data: {
311
+ name
312
+ }
313
+ });
314
+ }
315
+ }
316
+ };
317
+ }
318
+ };
319
+
320
+ /**
321
+ * Every rule this plugin ships, by name.
322
+ *
323
+ * @remarks
324
+ * The keys are the names a config writes after the `az-durable/` prefix, so
325
+ * renaming one is a breaking change for any consumer's config.
326
+ */
327
+ const rules = {
328
+ 'no-nondeterministic-orchestrator': noNondeterministicOrchestrator,
329
+ 'no-untyped-activity-handler': noUntypedActivityHandler,
330
+ 'require-yield-star': requireYieldStar
331
+ };
332
+ /**
333
+ * The plugin object, for a flat config's `plugins` map.
334
+ *
335
+ * @remarks
336
+ * Shipped from a separate entry point so lint rules are never a runtime import
337
+ * of the wrapper itself.
338
+ */
339
+ const plugin = {
340
+ rules
341
+ };
342
+ /**
343
+ * The recommended rule set.
344
+ *
345
+ * @remarks
346
+ * Two errors and one warning, and the split is deliberate.
347
+ * `no-nondeterministic-orchestrator` and `no-untyped-activity-handler` catch
348
+ * failures nothing else does — silent replay corruption, and a type collapse
349
+ * TypeScript accepts as legal. `require-yield-star` is a warning because the
350
+ * compiler already rejects the code it flags; it only improves the message.
351
+ */
352
+ const recommended = {
353
+ plugins: {
354
+ 'az-durable': plugin
355
+ },
356
+ rules: {
357
+ 'az-durable/no-nondeterministic-orchestrator': 'error',
358
+ 'az-durable/no-untyped-activity-handler': 'error',
359
+ 'az-durable/require-yield-star': 'warn'
360
+ }
361
+ };
362
+
363
+ exports.noNondeterministicOrchestrator = noNondeterministicOrchestrator;
364
+ exports.noUntypedActivityHandler = noUntypedActivityHandler;
365
+ exports.plugin = plugin;
366
+ exports.recommended = recommended;
367
+ exports.requireYieldStar = requireYieldStar;
368
+ exports.rules = rules;
369
+ //# sourceMappingURL=eslint-plugin.cjs.js.map