@pikku/inspector 0.12.27 → 0.12.28
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/CHANGELOG.md +34 -0
- package/dist/add/add-functions.js +9 -3
- package/dist/add/add-rpc-invocations.js +27 -0
- package/dist/error-codes.d.ts +2 -1
- package/dist/error-codes.js +1 -0
- package/dist/types.d.ts +1 -0
- package/dist/utils/filter-inspector-state.js +7 -6
- package/dist/utils/post-process.js +8 -1
- package/dist/utils/resolve-deploy-target.d.ts +3 -2
- package/dist/utils/resolve-deploy-target.js +4 -3
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +0 -2
- package/package.json +2 -2
- package/src/add/add-functions.ts +9 -3
- package/src/add/add-rpc-invocations.ts +41 -0
- package/src/add/rpc-type-cast.test.ts +123 -0
- package/src/error-codes.ts +2 -0
- package/src/types.ts +4 -0
- package/src/utils/filter-inspector-state.ts +13 -7
- package/src/utils/post-process.ts +8 -1
- package/src/utils/resolve-deploy-target.test.ts +30 -0
- package/src/utils/resolve-deploy-target.ts +5 -3
- package/src/utils/workflow/graph/workflow-graph.types.ts +0 -2
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
+
## 0.12.28
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 66d43d1: Add `deploy.defaultTarget` to `pikku.config.json` to override the default deploy target ('serverless') for functions without an explicit `deploy` flag.
|
|
6
|
+
- a8c9e6d: feat(inspector): add PKU940 — block type casts on rpc.invoke() calls
|
|
7
|
+
|
|
8
|
+
The inspector now emits a critical PKU940 error when `rpc.invoke()` is called
|
|
9
|
+
with an `as` cast on an argument (`rpc.invoke('fn', data as any)`) or when its
|
|
10
|
+
result is cast (`rpc.invoke('fn', data) as any`). Both patterns defeat Pikku's
|
|
11
|
+
generated type safety and are rejected at build time.
|
|
12
|
+
|
|
13
|
+
- ba1ab08: refactor(workflow): replace `inline: false` with `workflowQueued: true` on function meta
|
|
14
|
+
|
|
15
|
+
The per-function workflow dispatch flag has been renamed from the confusing
|
|
16
|
+
negative `inline: false` to the explicit positive `workflowQueued: true`.
|
|
17
|
+
Two companion fields are also added: `workflowRetries` and `workflowTimeout`
|
|
18
|
+
as function-level equivalents of the per-call-site `NodeOptions` fields.
|
|
19
|
+
|
|
20
|
+
**Breaking change (patch — flag was undocumented):** rename `inline: false`
|
|
21
|
+
to `workflowQueued: true` on any `pikkuSessionlessFunc` / `pikkuFunc` that
|
|
22
|
+
dispatches its workflow steps via the queue.
|
|
23
|
+
|
|
24
|
+
**Behaviour change:** a step marked `workflowQueued: true` now throws if no
|
|
25
|
+
queue service is configured, instead of silently falling back to inline
|
|
26
|
+
execution.
|
|
27
|
+
|
|
28
|
+
**Bug fix:** `post-process.ts` was registering `wf-step-*` queues for every
|
|
29
|
+
workflow step node; it now only registers them for steps that are actually
|
|
30
|
+
`workflowQueued: true`, avoiding spurious queue resource allocation.
|
|
31
|
+
|
|
32
|
+
- Updated dependencies [ba1ab08]
|
|
33
|
+
- @pikku/core@0.12.40
|
|
34
|
+
|
|
1
35
|
## 0.12.27
|
|
2
36
|
|
|
3
37
|
### Patch Changes
|
|
@@ -277,7 +277,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
277
277
|
let deploy;
|
|
278
278
|
let approvalRequired;
|
|
279
279
|
let approvalDescription;
|
|
280
|
-
let
|
|
280
|
+
let workflowQueued;
|
|
281
|
+
let workflowRetries;
|
|
282
|
+
let workflowTimeout;
|
|
281
283
|
let version;
|
|
282
284
|
let objectNode;
|
|
283
285
|
let nodeDisplayName = null;
|
|
@@ -345,7 +347,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
345
347
|
readonly_ = getPropertyValue(firstArg, 'readonly');
|
|
346
348
|
deploy = getPropertyValue(firstArg, 'deploy');
|
|
347
349
|
approvalRequired = getPropertyValue(firstArg, 'approvalRequired');
|
|
348
|
-
|
|
350
|
+
workflowQueued = getPropertyValue(firstArg, 'workflowQueued');
|
|
351
|
+
workflowRetries = getPropertyValue(firstArg, 'workflowRetries');
|
|
352
|
+
workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout');
|
|
349
353
|
// Extract approvalDescription identifier reference
|
|
350
354
|
for (const prop of firstArg.properties) {
|
|
351
355
|
if (ts.isPropertyAssignment(prop) &&
|
|
@@ -740,7 +744,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
|
|
|
740
744
|
deploy: deploy || undefined,
|
|
741
745
|
approvalRequired: approvalRequired || undefined,
|
|
742
746
|
approvalDescription: approvalDescription || undefined,
|
|
743
|
-
|
|
747
|
+
workflowQueued: workflowQueued === true ? true : undefined,
|
|
748
|
+
workflowRetries: workflowRetries ?? undefined,
|
|
749
|
+
workflowTimeout: workflowTimeout ?? undefined,
|
|
744
750
|
implementationHash,
|
|
745
751
|
version,
|
|
746
752
|
title,
|
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
|
+
import { ErrorCode } from '../error-codes.js';
|
|
3
|
+
function hasTypeCast(node) {
|
|
4
|
+
return ts.isAsExpression(node) || ts.isTypeAssertionExpression(node);
|
|
5
|
+
}
|
|
6
|
+
function outerParent(node) {
|
|
7
|
+
let p = node.parent;
|
|
8
|
+
while (p && (ts.isAwaitExpression(p) || ts.isParenthesizedExpression(p))) {
|
|
9
|
+
p = p.parent;
|
|
10
|
+
}
|
|
11
|
+
return p;
|
|
12
|
+
}
|
|
13
|
+
function findCastArg(args) {
|
|
14
|
+
return args.find(hasTypeCast);
|
|
15
|
+
}
|
|
2
16
|
/**
|
|
3
17
|
* Helper to extract namespace from a namespaced function reference like 'ext:hello'
|
|
4
18
|
*/
|
|
@@ -52,6 +66,19 @@ export function addRPCInvocations(node, state, logger) {
|
|
|
52
66
|
expression.name.text === 'invoke' &&
|
|
53
67
|
ts.isIdentifier(expression.expression) &&
|
|
54
68
|
expression.expression.text === 'rpc') {
|
|
69
|
+
// Skip PKU940 for generated files — they may contain intentional casts
|
|
70
|
+
// (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
|
|
71
|
+
const sourceFileName = node.getSourceFile().fileName;
|
|
72
|
+
const isGenerated = sourceFileName.endsWith('.gen.ts') || sourceFileName.endsWith('.gen.js');
|
|
73
|
+
if (!isGenerated) {
|
|
74
|
+
if (hasTypeCast(outerParent(node))) {
|
|
75
|
+
logger.critical(ErrorCode.RPC_INVOCATION_TYPE_CAST, `rpc.invoke() result is type-cast — remove the 'as' expression and rely on Pikku's generated types`);
|
|
76
|
+
}
|
|
77
|
+
const castArg = findCastArg(args);
|
|
78
|
+
if (castArg) {
|
|
79
|
+
logger.critical(ErrorCode.RPC_INVOCATION_TYPE_CAST, `rpc.invoke() has a type cast on an argument — remove the 'as' expression and rely on Pikku's generated types`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
55
82
|
const [firstArg] = args;
|
|
56
83
|
if (firstArg) {
|
|
57
84
|
if (ts.isStringLiteral(firstArg)) {
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -59,7 +59,8 @@ export declare enum ErrorCode {
|
|
|
59
59
|
WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901",
|
|
60
60
|
PII_IN_OUTPUT = "PKU910",
|
|
61
61
|
ADDON_WIRING_NOT_ALLOWED = "PKU920",
|
|
62
|
-
ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921"
|
|
62
|
+
ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = "PKU921",
|
|
63
|
+
RPC_INVOCATION_TYPE_CAST = "PKU940"
|
|
63
64
|
}
|
|
64
65
|
/**
|
|
65
66
|
* Severity of a tracked, coded diagnostic. `critical` always blocks the build;
|
package/dist/error-codes.js
CHANGED
package/dist/types.d.ts
CHANGED
|
@@ -196,6 +196,7 @@ export type InspectorFilters = {
|
|
|
196
196
|
target?: Array<'serverless' | 'server'>;
|
|
197
197
|
excludeTarget?: Array<'serverless' | 'server'>;
|
|
198
198
|
serverlessIncompatible?: string[];
|
|
199
|
+
defaultTarget?: 'serverless' | 'server';
|
|
199
200
|
};
|
|
200
201
|
export type AddonConfig = {
|
|
201
202
|
package: string;
|
|
@@ -256,9 +256,10 @@ export function filterInspectorState(state, filters, logger) {
|
|
|
256
256
|
? new Set(filters.excludeTarget)
|
|
257
257
|
: null;
|
|
258
258
|
const incompatible = new Set(filters.serverlessIncompatible ?? []);
|
|
259
|
+
const defaultTarget = filters.defaultTarget ?? 'serverless';
|
|
259
260
|
keptByDeploy = new Set();
|
|
260
261
|
for (const [funcId, funcMeta] of Object.entries(state.functions.meta)) {
|
|
261
|
-
const target = resolveDeployTarget(funcMeta, incompatible, funcId);
|
|
262
|
+
const target = resolveDeployTarget(funcMeta, incompatible, funcId, defaultTarget);
|
|
262
263
|
if (allowed && !allowed.has(target))
|
|
263
264
|
continue;
|
|
264
265
|
if (excluded && excluded.has(target))
|
|
@@ -761,10 +762,10 @@ export function filterInspectorState(state, filters, logger) {
|
|
|
761
762
|
filteredState.requiredSchemas = prunedSchemas;
|
|
762
763
|
}
|
|
763
764
|
// Step dispatch is decided purely per-function: a workflow step runs via the
|
|
764
|
-
// queue only when its function
|
|
765
|
-
//
|
|
766
|
-
//
|
|
767
|
-
//
|
|
765
|
+
// queue only when its function is marked `workflowQueued: true`. Such a unit
|
|
766
|
+
// needs workflowService + queueService injected even though the function
|
|
767
|
+
// itself doesn't reference them. Check the ORIGINAL graph meta (before
|
|
768
|
+
// filtering pruned it).
|
|
768
769
|
const survivingFuncIds = new Set(Object.keys(filteredState.functions.meta));
|
|
769
770
|
const resolveFuncId = (rpcName) => filteredState.rpc.internalMeta[rpcName] ??
|
|
770
771
|
filteredState.rpc.exposedMeta[rpcName] ??
|
|
@@ -782,7 +783,7 @@ export function filterInspectorState(state, filters, logger) {
|
|
|
782
783
|
continue;
|
|
783
784
|
const funcMeta = (filteredState.functions.meta[funcId] ??
|
|
784
785
|
filteredState.functions.meta[rpcName]);
|
|
785
|
-
if (funcMeta?.
|
|
786
|
+
if (funcMeta?.workflowQueued === true) {
|
|
786
787
|
filteredState.serviceAggregation.requiredServices.add('workflowService');
|
|
787
788
|
filteredState.serviceAggregation.requiredServices.add('queueService');
|
|
788
789
|
}
|
|
@@ -220,11 +220,18 @@ export function aggregateRequiredServices(state) {
|
|
|
220
220
|
pikkuFuncId: `pikkuWorkflowOrchestrator:${graph.name}`,
|
|
221
221
|
};
|
|
222
222
|
}
|
|
223
|
-
// Per-step queues
|
|
223
|
+
// Per-step queues — only for steps explicitly marked workflowQueued: true
|
|
224
224
|
for (const node of Object.values(graph.nodes)) {
|
|
225
225
|
if (!('rpcName' in node) || !node.rpcName)
|
|
226
226
|
continue;
|
|
227
227
|
const rpcName = node.rpcName;
|
|
228
|
+
const funcId = state.rpc?.internalMeta?.[rpcName] ??
|
|
229
|
+
state.rpc?.exposedMeta?.[rpcName] ??
|
|
230
|
+
rpcName;
|
|
231
|
+
const funcMeta = (state.functions.meta[funcId] ??
|
|
232
|
+
state.functions.meta[rpcName]);
|
|
233
|
+
if (funcMeta?.workflowQueued !== true)
|
|
234
|
+
continue;
|
|
228
235
|
const stepQueueName = `wf-step-${toKebab(rpcName)}`;
|
|
229
236
|
if (!state.queueWorkers.meta[stepQueueName]) {
|
|
230
237
|
state.queueWorkers.meta[stepQueueName] = {
|
|
@@ -18,11 +18,12 @@ export declare class IncompatibleDeployTargetError extends Error {
|
|
|
18
18
|
* - throw if the function explicitly declares `deploy: 'serverless'`
|
|
19
19
|
* - otherwise target is 'server'
|
|
20
20
|
* 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
|
|
21
|
-
* 3.
|
|
21
|
+
* 3. `defaultTarget` (sourced from `pikku.config.json` →
|
|
22
|
+
* `deploy.defaultTarget`, falling back to 'serverless')
|
|
22
23
|
*
|
|
23
24
|
* Used both by the per-unit deploy analyzer (when bucketing functions
|
|
24
25
|
* into deployment units) and by `filterInspectorState` (when
|
|
25
26
|
* `pikku all --deploy <target>` is used to emit a target-scoped set
|
|
26
27
|
* of gen files).
|
|
27
28
|
*/
|
|
28
|
-
export declare function resolveDeployTarget(funcMeta: Pick<FunctionMeta, 'deploy' | 'services'>, serverlessIncompatible: Set<string>, functionName?: string): 'serverless' | 'server';
|
|
29
|
+
export declare function resolveDeployTarget(funcMeta: Pick<FunctionMeta, 'deploy' | 'services'>, serverlessIncompatible: Set<string>, functionName?: string, defaultTarget?: 'serverless' | 'server'): 'serverless' | 'server';
|
|
@@ -25,14 +25,15 @@ export class IncompatibleDeployTargetError extends Error {
|
|
|
25
25
|
* - throw if the function explicitly declares `deploy: 'serverless'`
|
|
26
26
|
* - otherwise target is 'server'
|
|
27
27
|
* 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
|
|
28
|
-
* 3.
|
|
28
|
+
* 3. `defaultTarget` (sourced from `pikku.config.json` →
|
|
29
|
+
* `deploy.defaultTarget`, falling back to 'serverless')
|
|
29
30
|
*
|
|
30
31
|
* Used both by the per-unit deploy analyzer (when bucketing functions
|
|
31
32
|
* into deployment units) and by `filterInspectorState` (when
|
|
32
33
|
* `pikku all --deploy <target>` is used to emit a target-scoped set
|
|
33
34
|
* of gen files).
|
|
34
35
|
*/
|
|
35
|
-
export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionName = '<unknown>') {
|
|
36
|
+
export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionName = '<unknown>', defaultTarget = 'serverless') {
|
|
36
37
|
// Service compatibility wins over the explicit flag — a serverless
|
|
37
38
|
// bundle of a function that needs (e.g.) node:fs would crash at runtime.
|
|
38
39
|
const incompatibleHits = [];
|
|
@@ -52,5 +53,5 @@ export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionNa
|
|
|
52
53
|
return 'server';
|
|
53
54
|
if (funcMeta.deploy === 'serverless')
|
|
54
55
|
return 'serverless';
|
|
55
|
-
return
|
|
56
|
+
return defaultTarget;
|
|
56
57
|
}
|
|
@@ -62,8 +62,6 @@ export interface NodeOptions {
|
|
|
62
62
|
retryDelay?: string;
|
|
63
63
|
/** Timeout for node execution (e.g., '30s', '5m') */
|
|
64
64
|
timeout?: string;
|
|
65
|
-
/** If true, execute via queue (async). Default: false (inline) */
|
|
66
|
-
async?: boolean;
|
|
67
65
|
}
|
|
68
66
|
/**
|
|
69
67
|
* Flow node types for control flow (no RPC call)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pikku/inspector",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.28",
|
|
4
4
|
"author": "yasser.fadl@gmail.com",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"type": "module",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
|
|
38
|
-
"@pikku/core": "^0.12.
|
|
38
|
+
"@pikku/core": "^0.12.40",
|
|
39
39
|
"openapi-types": "^12.1.3",
|
|
40
40
|
"path-to-regexp": "^8.3.0",
|
|
41
41
|
"ts-json-schema-generator": "^2.5.0",
|
package/src/add/add-functions.ts
CHANGED
|
@@ -392,7 +392,9 @@ export const addFunctions: AddWiring = (
|
|
|
392
392
|
let deploy: 'serverless' | 'server' | 'auto' | undefined
|
|
393
393
|
let approvalRequired: boolean | undefined
|
|
394
394
|
let approvalDescription: string | undefined
|
|
395
|
-
let
|
|
395
|
+
let workflowQueued: boolean | undefined
|
|
396
|
+
let workflowRetries: number | undefined
|
|
397
|
+
let workflowTimeout: string | undefined
|
|
396
398
|
let version: number | undefined
|
|
397
399
|
let objectNode: ts.ObjectLiteralExpression | undefined
|
|
398
400
|
let nodeDisplayName: string | null = null
|
|
@@ -488,7 +490,9 @@ export const addFunctions: AddWiring = (
|
|
|
488
490
|
approvalRequired = getPropertyValue(firstArg, 'approvalRequired') as
|
|
489
491
|
| boolean
|
|
490
492
|
| undefined
|
|
491
|
-
|
|
493
|
+
workflowQueued = getPropertyValue(firstArg, 'workflowQueued') as boolean | undefined
|
|
494
|
+
workflowRetries = getPropertyValue(firstArg, 'workflowRetries') as number | undefined
|
|
495
|
+
workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout') as string | undefined
|
|
492
496
|
|
|
493
497
|
// Extract approvalDescription identifier reference
|
|
494
498
|
for (const prop of firstArg.properties) {
|
|
@@ -1027,7 +1031,9 @@ export const addFunctions: AddWiring = (
|
|
|
1027
1031
|
deploy: deploy || undefined,
|
|
1028
1032
|
approvalRequired: approvalRequired || undefined,
|
|
1029
1033
|
approvalDescription: approvalDescription || undefined,
|
|
1030
|
-
|
|
1034
|
+
workflowQueued: workflowQueued === true ? true : undefined,
|
|
1035
|
+
workflowRetries: workflowRetries ?? undefined,
|
|
1036
|
+
workflowTimeout: workflowTimeout ?? undefined,
|
|
1031
1037
|
implementationHash,
|
|
1032
1038
|
version,
|
|
1033
1039
|
title,
|
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
2
|
import type { InspectorState, InspectorLogger } from '../types.js'
|
|
3
|
+
import { ErrorCode } from '../error-codes.js'
|
|
4
|
+
|
|
5
|
+
function hasTypeCast(node: ts.Node): boolean {
|
|
6
|
+
return ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function outerParent(node: ts.Node): ts.Node {
|
|
10
|
+
let p = node.parent
|
|
11
|
+
while (p && (ts.isAwaitExpression(p) || ts.isParenthesizedExpression(p))) {
|
|
12
|
+
p = p.parent
|
|
13
|
+
}
|
|
14
|
+
return p
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function findCastArg(
|
|
18
|
+
args: ts.NodeArray<ts.Expression>
|
|
19
|
+
): ts.Expression | undefined {
|
|
20
|
+
return args.find(hasTypeCast)
|
|
21
|
+
}
|
|
3
22
|
|
|
4
23
|
/**
|
|
5
24
|
* Helper to extract namespace from a namespaced function reference like 'ext:hello'
|
|
@@ -67,6 +86,28 @@ export function addRPCInvocations(
|
|
|
67
86
|
ts.isIdentifier(expression.expression) &&
|
|
68
87
|
expression.expression.text === 'rpc'
|
|
69
88
|
) {
|
|
89
|
+
// Skip PKU940 for generated files — they may contain intentional casts
|
|
90
|
+
// (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
|
|
91
|
+
const sourceFileName = node.getSourceFile().fileName
|
|
92
|
+
const isGenerated =
|
|
93
|
+
sourceFileName.endsWith('.gen.ts') || sourceFileName.endsWith('.gen.js')
|
|
94
|
+
if (!isGenerated) {
|
|
95
|
+
if (hasTypeCast(outerParent(node))) {
|
|
96
|
+
logger.critical(
|
|
97
|
+
ErrorCode.RPC_INVOCATION_TYPE_CAST,
|
|
98
|
+
`rpc.invoke() result is type-cast — remove the 'as' expression and rely on Pikku's generated types`
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const castArg = findCastArg(args)
|
|
103
|
+
if (castArg) {
|
|
104
|
+
logger.critical(
|
|
105
|
+
ErrorCode.RPC_INVOCATION_TYPE_CAST,
|
|
106
|
+
`rpc.invoke() has a type cast on an argument — remove the 'as' expression and rely on Pikku's generated types`
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
70
111
|
const [firstArg] = args
|
|
71
112
|
if (firstArg) {
|
|
72
113
|
if (ts.isStringLiteral(firstArg)) {
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
|
|
10
|
+
function makeLogger() {
|
|
11
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
12
|
+
const logger: InspectorLogger = {
|
|
13
|
+
debug: () => {},
|
|
14
|
+
info: () => {},
|
|
15
|
+
warn: () => {},
|
|
16
|
+
error: () => {},
|
|
17
|
+
diagnostic: ({ code, message }) => criticals.push({ code, message }),
|
|
18
|
+
critical: (code, message) => criticals.push({ code, message }),
|
|
19
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
20
|
+
}
|
|
21
|
+
return { logger, criticals }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function runInspect(sourceCode: string) {
|
|
25
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-rpc-cast-test-'))
|
|
26
|
+
const file = join(tmpDir, 'funcs.ts')
|
|
27
|
+
await writeFile(file, sourceCode)
|
|
28
|
+
const { logger, criticals } = makeLogger()
|
|
29
|
+
try {
|
|
30
|
+
await inspect(logger, [file], { rootDir: tmpDir })
|
|
31
|
+
} finally {
|
|
32
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
33
|
+
}
|
|
34
|
+
return criticals
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('RPC type-cast check — PKU940', () => {
|
|
38
|
+
test('flags rpc.invoke() with an as-cast on an argument', async () => {
|
|
39
|
+
const criticals = await runInspect(`
|
|
40
|
+
declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
|
|
41
|
+
export async function doWork() {
|
|
42
|
+
return rpc.invoke('someFunction', { id: 1 } as any)
|
|
43
|
+
}
|
|
44
|
+
`)
|
|
45
|
+
const hit = criticals.find(
|
|
46
|
+
(c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
|
|
47
|
+
)
|
|
48
|
+
assert.ok(hit, `Expected PKU940 but got: ${JSON.stringify(criticals)}`)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test('flags rpc.invoke() with an angle-bracket cast on an argument', async () => {
|
|
52
|
+
const criticals = await runInspect(`
|
|
53
|
+
declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
|
|
54
|
+
export async function doWork() {
|
|
55
|
+
return rpc.invoke('someFunction', <any>{ id: 1 })
|
|
56
|
+
}
|
|
57
|
+
`)
|
|
58
|
+
const hit = criticals.find(
|
|
59
|
+
(c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
|
|
60
|
+
)
|
|
61
|
+
assert.ok(hit, `Expected PKU940 but got: ${JSON.stringify(criticals)}`)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('flags rpc.invoke() result cast with as any', async () => {
|
|
65
|
+
const criticals = await runInspect(`
|
|
66
|
+
declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
|
|
67
|
+
export async function doWork() {
|
|
68
|
+
return (rpc.invoke('someFunction', { id: 1 }) as any)
|
|
69
|
+
}
|
|
70
|
+
`)
|
|
71
|
+
const hit = criticals.find(
|
|
72
|
+
(c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
|
|
73
|
+
)
|
|
74
|
+
assert.ok(hit, `Expected PKU940 but got: ${JSON.stringify(criticals)}`)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
test('flags rpc.invoke() result cast with as never', async () => {
|
|
78
|
+
const criticals = await runInspect(`
|
|
79
|
+
declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
|
|
80
|
+
export async function doWork() {
|
|
81
|
+
return (rpc.invoke('someFunction', { id: 1 }) as never)
|
|
82
|
+
}
|
|
83
|
+
`)
|
|
84
|
+
const hit = criticals.find(
|
|
85
|
+
(c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
|
|
86
|
+
)
|
|
87
|
+
assert.ok(hit, `Expected PKU940 but got: ${JSON.stringify(criticals)}`)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
test('does not flag a clean rpc.invoke() call', async () => {
|
|
91
|
+
const criticals = await runInspect(`
|
|
92
|
+
declare const rpc: { invoke: (name: string, data: unknown) => Promise<unknown> }
|
|
93
|
+
export async function doWork() {
|
|
94
|
+
return rpc.invoke('someFunction', { id: 1 })
|
|
95
|
+
}
|
|
96
|
+
`)
|
|
97
|
+
const hit = criticals.find(
|
|
98
|
+
(c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
|
|
99
|
+
)
|
|
100
|
+
assert.equal(
|
|
101
|
+
hit,
|
|
102
|
+
undefined,
|
|
103
|
+
`Expected no PKU940 but got: ${JSON.stringify(hit)}`
|
|
104
|
+
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
test('does not flag as-casts on unrelated calls', async () => {
|
|
108
|
+
const criticals = await runInspect(`
|
|
109
|
+
declare function otherFn(data: unknown): Promise<unknown>
|
|
110
|
+
export async function doWork() {
|
|
111
|
+
return otherFn({ id: 1 } as any)
|
|
112
|
+
}
|
|
113
|
+
`)
|
|
114
|
+
const hit = criticals.find(
|
|
115
|
+
(c) => c.code === ErrorCode.RPC_INVOCATION_TYPE_CAST
|
|
116
|
+
)
|
|
117
|
+
assert.equal(
|
|
118
|
+
hit,
|
|
119
|
+
undefined,
|
|
120
|
+
`Expected no PKU940 but got: ${JSON.stringify(hit)}`
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
})
|
package/src/error-codes.ts
CHANGED
package/src/types.ts
CHANGED
|
@@ -263,6 +263,10 @@ export type InspectorFilters = {
|
|
|
263
263
|
// to 'server'. Sourced from `pikku.config.json` →
|
|
264
264
|
// `deploy.serverlessIncompatible`. Used only when deploy filters are set.
|
|
265
265
|
serverlessIncompatible?: string[]
|
|
266
|
+
// Default deploy target for functions without an explicit `deploy` flag.
|
|
267
|
+
// Sourced from `pikku.config.json` → `deploy.defaultTarget`. Used only
|
|
268
|
+
// when deploy filters are set. Defaults to 'serverless'.
|
|
269
|
+
defaultTarget?: 'serverless' | 'server'
|
|
266
270
|
}
|
|
267
271
|
|
|
268
272
|
export type AddonConfig = {
|
|
@@ -340,9 +340,15 @@ export function filterInspectorState(
|
|
|
340
340
|
? new Set(filters.excludeTarget)
|
|
341
341
|
: null
|
|
342
342
|
const incompatible = new Set(filters.serverlessIncompatible ?? [])
|
|
343
|
+
const defaultTarget = filters.defaultTarget ?? 'serverless'
|
|
343
344
|
keptByDeploy = new Set<string>()
|
|
344
345
|
for (const [funcId, funcMeta] of Object.entries(state.functions.meta)) {
|
|
345
|
-
const target = resolveDeployTarget(
|
|
346
|
+
const target = resolveDeployTarget(
|
|
347
|
+
funcMeta as any,
|
|
348
|
+
incompatible,
|
|
349
|
+
funcId,
|
|
350
|
+
defaultTarget
|
|
351
|
+
)
|
|
346
352
|
if (allowed && !allowed.has(target)) continue
|
|
347
353
|
if (excluded && excluded.has(target)) continue
|
|
348
354
|
keptByDeploy.add(funcId)
|
|
@@ -1062,10 +1068,10 @@ export function filterInspectorState(
|
|
|
1062
1068
|
}
|
|
1063
1069
|
|
|
1064
1070
|
// Step dispatch is decided purely per-function: a workflow step runs via the
|
|
1065
|
-
// queue only when its function
|
|
1066
|
-
//
|
|
1067
|
-
//
|
|
1068
|
-
//
|
|
1071
|
+
// queue only when its function is marked `workflowQueued: true`. Such a unit
|
|
1072
|
+
// needs workflowService + queueService injected even though the function
|
|
1073
|
+
// itself doesn't reference them. Check the ORIGINAL graph meta (before
|
|
1074
|
+
// filtering pruned it).
|
|
1069
1075
|
const survivingFuncIds = new Set(Object.keys(filteredState.functions.meta))
|
|
1070
1076
|
const resolveFuncId = (rpcName: string): string =>
|
|
1071
1077
|
filteredState.rpc.internalMeta[rpcName] ??
|
|
@@ -1081,8 +1087,8 @@ export function filterInspectorState(
|
|
|
1081
1087
|
if (!survivingFuncIds.has(funcId) && !survivingFuncIds.has(rpcName))
|
|
1082
1088
|
continue
|
|
1083
1089
|
const funcMeta = (filteredState.functions.meta[funcId] ??
|
|
1084
|
-
filteredState.functions.meta[rpcName]) as {
|
|
1085
|
-
if (funcMeta?.
|
|
1090
|
+
filteredState.functions.meta[rpcName]) as { workflowQueued?: boolean }
|
|
1091
|
+
if (funcMeta?.workflowQueued === true) {
|
|
1086
1092
|
filteredState.serviceAggregation.requiredServices.add('workflowService')
|
|
1087
1093
|
filteredState.serviceAggregation.requiredServices.add('queueService')
|
|
1088
1094
|
}
|
|
@@ -290,10 +290,17 @@ export function aggregateRequiredServices(
|
|
|
290
290
|
}
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
// Per-step queues
|
|
293
|
+
// Per-step queues — only for steps explicitly marked workflowQueued: true
|
|
294
294
|
for (const node of Object.values(graph.nodes)) {
|
|
295
295
|
if (!('rpcName' in node) || !node.rpcName) continue
|
|
296
296
|
const rpcName = node.rpcName as string
|
|
297
|
+
const funcId =
|
|
298
|
+
state.rpc?.internalMeta?.[rpcName] ??
|
|
299
|
+
state.rpc?.exposedMeta?.[rpcName] ??
|
|
300
|
+
rpcName
|
|
301
|
+
const funcMeta = (state.functions.meta[funcId] ??
|
|
302
|
+
state.functions.meta[rpcName]) as { workflowQueued?: boolean }
|
|
303
|
+
if (funcMeta?.workflowQueued !== true) continue
|
|
297
304
|
const stepQueueName = `wf-step-${toKebab(rpcName)}`
|
|
298
305
|
if (!state.queueWorkers.meta[stepQueueName]) {
|
|
299
306
|
state.queueWorkers.meta[stepQueueName] = {
|
|
@@ -102,4 +102,34 @@ describe('resolveDeployTarget', () => {
|
|
|
102
102
|
'server'
|
|
103
103
|
)
|
|
104
104
|
})
|
|
105
|
+
|
|
106
|
+
test('defaultTarget: server overrides the serverless default', () => {
|
|
107
|
+
assert.strictEqual(
|
|
108
|
+
resolveDeployTarget({}, new Set(), '<unknown>', 'server'),
|
|
109
|
+
'server'
|
|
110
|
+
)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
test('explicit deploy flag wins over defaultTarget', () => {
|
|
114
|
+
assert.strictEqual(
|
|
115
|
+
resolveDeployTarget({ deploy: 'serverless' }, new Set(), 'fn', 'server'),
|
|
116
|
+
'serverless'
|
|
117
|
+
)
|
|
118
|
+
assert.strictEqual(
|
|
119
|
+
resolveDeployTarget({ deploy: 'server' }, new Set(), 'fn', 'serverless'),
|
|
120
|
+
'server'
|
|
121
|
+
)
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
test('serverlessIncompatible still forces server regardless of defaultTarget', () => {
|
|
125
|
+
assert.strictEqual(
|
|
126
|
+
resolveDeployTarget(
|
|
127
|
+
{ services: { services: ['metaService'] } as any },
|
|
128
|
+
new Set(['metaService']),
|
|
129
|
+
'fn',
|
|
130
|
+
'serverless'
|
|
131
|
+
),
|
|
132
|
+
'server'
|
|
133
|
+
)
|
|
134
|
+
})
|
|
105
135
|
})
|
|
@@ -29,7 +29,8 @@ export class IncompatibleDeployTargetError extends Error {
|
|
|
29
29
|
* - throw if the function explicitly declares `deploy: 'serverless'`
|
|
30
30
|
* - otherwise target is 'server'
|
|
31
31
|
* 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
|
|
32
|
-
* 3.
|
|
32
|
+
* 3. `defaultTarget` (sourced from `pikku.config.json` →
|
|
33
|
+
* `deploy.defaultTarget`, falling back to 'serverless')
|
|
33
34
|
*
|
|
34
35
|
* Used both by the per-unit deploy analyzer (when bucketing functions
|
|
35
36
|
* into deployment units) and by `filterInspectorState` (when
|
|
@@ -39,7 +40,8 @@ export class IncompatibleDeployTargetError extends Error {
|
|
|
39
40
|
export function resolveDeployTarget(
|
|
40
41
|
funcMeta: Pick<FunctionMeta, 'deploy' | 'services'>,
|
|
41
42
|
serverlessIncompatible: Set<string>,
|
|
42
|
-
functionName = '<unknown>'
|
|
43
|
+
functionName = '<unknown>',
|
|
44
|
+
defaultTarget: 'serverless' | 'server' = 'serverless'
|
|
43
45
|
): 'serverless' | 'server' {
|
|
44
46
|
// Service compatibility wins over the explicit flag — a serverless
|
|
45
47
|
// bundle of a function that needs (e.g.) node:fs would crash at runtime.
|
|
@@ -59,5 +61,5 @@ export function resolveDeployTarget(
|
|
|
59
61
|
|
|
60
62
|
if (funcMeta.deploy === 'server') return 'server'
|
|
61
63
|
if (funcMeta.deploy === 'serverless') return 'serverless'
|
|
62
|
-
return
|
|
64
|
+
return defaultTarget
|
|
63
65
|
}
|