@pikku/inspector 0.12.26 → 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 +115 -0
- package/dist/add/add-functions.js +15 -4
- 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/inspector.js +18 -3
- package/dist/types.d.ts +14 -0
- package/dist/utils/extract-function-name.js +20 -3
- 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/schema-generator.d.ts +1 -0
- package/dist/utils/schema-generator.js +76 -0
- package/dist/utils/workflow/derive-workflow-plan.d.ts +20 -0
- package/dist/utils/workflow/derive-workflow-plan.js +78 -0
- package/dist/utils/workflow/graph/finalize-workflows.js +15 -0
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +14 -3
- package/dist/visit.d.ts +1 -0
- package/dist/visit.js +14 -1
- package/package.json +2 -2
- package/src/add/add-functions.ts +15 -4
- package/src/add/add-rpc-invocations.ts +41 -0
- package/src/add/pii-check.test.ts +3 -1
- package/src/add/rpc-type-cast.test.ts +123 -0
- package/src/error-codes.ts +2 -0
- package/src/inspector.ts +24 -2
- package/src/types.ts +17 -0
- package/src/utils/extract-function-name.ts +21 -3
- 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/schema-generator.ts +92 -0
- package/src/utils/workflow/derive-workflow-plan.test.ts +122 -0
- package/src/utils/workflow/derive-workflow-plan.ts +90 -0
- package/src/utils/workflow/graph/finalize-workflows.ts +15 -0
- package/src/utils/workflow/graph/workflow-graph.types.ts +18 -3
- package/src/visit.ts +25 -1
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isVersionedId, formatVersionedId, parseVersionedId } from '@pikku/core';
|
|
2
2
|
import { canonicalJSON, hashString } from '../../hash.js';
|
|
3
3
|
import { convertDslToGraph } from './convert-dsl-to-graph.js';
|
|
4
|
+
import { deriveWorkflowPlan } from '../derive-workflow-plan.js';
|
|
4
5
|
export function finalizeWorkflows(state) {
|
|
5
6
|
const { workflows, functions } = state;
|
|
6
7
|
const functionsMeta = functions.meta;
|
|
@@ -9,6 +10,20 @@ export function finalizeWorkflows(state) {
|
|
|
9
10
|
stampVersionsOnGraph(graph, functionsMeta);
|
|
10
11
|
computeStepHashes(graph, functionsMeta);
|
|
11
12
|
graph.graphHash = computeGraphHash(graph);
|
|
13
|
+
// Predictable (loopless) DSL workflows carry their full step list so a UI
|
|
14
|
+
// can render the skeleton up front without executing the run. Only DSL is
|
|
15
|
+
// gated: a complex workflow's step tree is incomplete (inline JS branches
|
|
16
|
+
// aren't captured) and flattens loops into plain steps, so its plan would
|
|
17
|
+
// lie about determinism.
|
|
18
|
+
if (graph.source === 'dsl') {
|
|
19
|
+
const { deterministic, plannedSteps } = deriveWorkflowPlan(meta.steps);
|
|
20
|
+
graph.deterministic = deterministic;
|
|
21
|
+
// Omit an empty list — a deterministic workflow with no plannedSteps is
|
|
22
|
+
// simply one with no named steps (e.g. a bare return).
|
|
23
|
+
if (plannedSteps?.length) {
|
|
24
|
+
graph.plannedSteps = plannedSteps;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
12
27
|
workflows.graphMeta[name] = graph;
|
|
13
28
|
}
|
|
14
29
|
for (const graph of Object.values(workflows.graphMeta)) {
|
|
@@ -62,14 +62,12 @@ 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)
|
|
70
68
|
*/
|
|
71
69
|
export type FlowType = 'sleep' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
|
|
72
|
-
import type { ContextVariable, WorkflowContext } from '@pikku/core/workflow';
|
|
70
|
+
import type { ContextVariable, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
|
|
73
71
|
export type { ContextVariable, WorkflowContext };
|
|
74
72
|
/**
|
|
75
73
|
* Base node properties shared by all node types
|
|
@@ -151,6 +149,19 @@ export interface SerializedWorkflowGraph {
|
|
|
151
149
|
entryNodeIds: string[];
|
|
152
150
|
/** Hash of graph topology (nodes, edges, input mappings) */
|
|
153
151
|
graphHash?: string;
|
|
152
|
+
/**
|
|
153
|
+
* True when the exact executed step sequence is known up front: a loopless
|
|
154
|
+
* DSL workflow with no branches/switches. Lets a UI render the run as a fixed
|
|
155
|
+
* pipeline. Loops (fanout) → omitted; branchy-but-loopless → false.
|
|
156
|
+
*/
|
|
157
|
+
deterministic?: boolean;
|
|
158
|
+
/**
|
|
159
|
+
* Every named step the workflow can run, in source order — so a frontend can
|
|
160
|
+
* render the step skeleton before the run starts. Populated for any loopless
|
|
161
|
+
* DSL workflow (a branchy one lists all possible steps); omitted when the step
|
|
162
|
+
* count is runtime-dependent (any fanout).
|
|
163
|
+
*/
|
|
164
|
+
plannedSteps?: WorkflowPlannedStep[];
|
|
154
165
|
/** Wire entry points (HTTP, channel, queue, etc.) that trigger this workflow */
|
|
155
166
|
wires?: WorkflowWires;
|
|
156
167
|
}
|
package/dist/visit.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as ts from 'typescript';
|
|
2
2
|
import type { InspectorState, InspectorLogger, InspectorOptions } from './types.js';
|
|
3
3
|
export declare const visitSetup: (logger: InspectorLogger, checker: ts.TypeChecker, node: ts.Node, state: InspectorState, options: InspectorOptions) => void;
|
|
4
|
+
export declare const visitFunctions: (logger: InspectorLogger, checker: ts.TypeChecker, node: ts.Node, state: InspectorState, options: InspectorOptions) => void;
|
|
4
5
|
export declare const visitRoutes: (logger: InspectorLogger, checker: ts.TypeChecker, node: ts.Node, state: InspectorState, options: InspectorOptions) => void;
|
package/dist/visit.js
CHANGED
|
@@ -41,12 +41,25 @@ export const visitSetup = (logger, checker, node, state, options) => {
|
|
|
41
41
|
addWorkflow(logger, node, checker, state, options);
|
|
42
42
|
ts.forEachChild(node, (child) => visitSetup(logger, checker, child, state, options));
|
|
43
43
|
};
|
|
44
|
+
// Register every pikku function before transports/wirings are resolved, so that
|
|
45
|
+
// resolution (e.g. a channel handler referencing a function defined in another
|
|
46
|
+
// file) is independent of source-file traversal order. Runs between visitSetup
|
|
47
|
+
// and visitRoutes.
|
|
48
|
+
export const visitFunctions = (logger, checker, node, state, options) => {
|
|
49
|
+
const nextOptions = ts.isSourceFile(node)
|
|
50
|
+
? { ...options, sourceFile: node }
|
|
51
|
+
: options;
|
|
52
|
+
addFunctions(logger, node, checker, state, nextOptions);
|
|
53
|
+
ts.forEachChild(node, (child) => visitFunctions(logger, checker, child, state, nextOptions));
|
|
54
|
+
};
|
|
44
55
|
export const visitRoutes = (logger, checker, node, state, options) => {
|
|
45
56
|
const nextOptions = ts.isSourceFile(node)
|
|
46
57
|
? { ...options, sourceFile: node }
|
|
47
58
|
: options;
|
|
48
59
|
checkAddonBans(logger, node, checker, state, nextOptions);
|
|
49
|
-
addFunctions
|
|
60
|
+
// NOTE: addFunctions runs in its own earlier pass (visitFunctions) so that
|
|
61
|
+
// every function is registered before any wiring (channels, CLI, etc.)
|
|
62
|
+
// resolves it — wiring resolution must not depend on source-file order.
|
|
50
63
|
addAuth(logger, node, checker, state, nextOptions);
|
|
51
64
|
addSecret(logger, node, checker, state, nextOptions);
|
|
52
65
|
addCredential(logger, node, checker, state, nextOptions);
|
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) {
|
|
@@ -913,7 +917,12 @@ export const addFunctions: AddWiring = (
|
|
|
913
917
|
// secret → never returned by any exposed function (sessioned or not)
|
|
914
918
|
// private → only visible to authenticated (sessioned) users; ok for pikkuFunc
|
|
915
919
|
// public → safe for sessionless functions
|
|
916
|
-
|
|
920
|
+
// Opt-in only: inferring every handler's return type (getReturnTypeOfSignature)
|
|
921
|
+
// is the single most expensive checker operation and dominates `pikku all`
|
|
922
|
+
// wall-clock. The classification leak scan is a security lint, not codegen, so
|
|
923
|
+
// it runs ONLY when explicitly requested (`pikku all --security`) — see the
|
|
924
|
+
// classificationCheck option. Default codegen skips it entirely.
|
|
925
|
+
if (options.classificationCheck) {
|
|
917
926
|
const sig = checker.getSignatureFromDeclaration(handler)
|
|
918
927
|
if (sig) {
|
|
919
928
|
const rawRet = checker.getReturnTypeOfSignature(sig)
|
|
@@ -1022,7 +1031,9 @@ export const addFunctions: AddWiring = (
|
|
|
1022
1031
|
deploy: deploy || undefined,
|
|
1023
1032
|
approvalRequired: approvalRequired || undefined,
|
|
1024
1033
|
approvalDescription: approvalDescription || undefined,
|
|
1025
|
-
|
|
1034
|
+
workflowQueued: workflowQueued === true ? true : undefined,
|
|
1035
|
+
workflowRetries: workflowRetries ?? undefined,
|
|
1036
|
+
workflowTimeout: workflowTimeout ?? undefined,
|
|
1026
1037
|
implementationHash,
|
|
1027
1038
|
version,
|
|
1028
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)) {
|
|
@@ -48,7 +48,9 @@ async function runInspect(sourceCode: string) {
|
|
|
48
48
|
await writeFile(file, sourceCode)
|
|
49
49
|
const { logger, criticals } = makeLogger()
|
|
50
50
|
try {
|
|
51
|
-
|
|
51
|
+
// The data-classification leak scan is opt-in (off by default to keep it
|
|
52
|
+
// off the `pikku all` hot path); these tests exercise it, so enable it.
|
|
53
|
+
await inspect(logger, [file], { rootDir: tmpDir, classificationCheck: true })
|
|
52
54
|
} finally {
|
|
53
55
|
await rm(tmpDir, { recursive: true, force: true })
|
|
54
56
|
}
|
|
@@ -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/inspector.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
2
|
import { performance } from 'perf_hooks'
|
|
3
3
|
import { resolve } from 'path'
|
|
4
|
-
import { visitSetup, visitRoutes } from './visit.js'
|
|
4
|
+
import { visitSetup, visitFunctions, visitRoutes } from './visit.js'
|
|
5
5
|
import { TypesMap } from './types-map.js'
|
|
6
6
|
import type {
|
|
7
7
|
InspectorState,
|
|
@@ -269,6 +269,12 @@ export const inspect = async (
|
|
|
269
269
|
// node_modules under rootDir (e.g. a locally-installed addon) is a
|
|
270
270
|
// dependency, not project source — scanning it double-counts the addon's
|
|
271
271
|
// own application types (CoreConfig/Services/SingletonServices).
|
|
272
|
+
// Sort by file name so the sweeps populate state in a stable order. The
|
|
273
|
+
// program's own file order depends on glob + import-graph resolution, which
|
|
274
|
+
// varies run to run — leaving generated meta keys (and anything serialized
|
|
275
|
+
// in insertion order) non-reproducible across identical `pikku all` runs.
|
|
276
|
+
// Safe because function registration is a dedicated pass (visitFunctions)
|
|
277
|
+
// that completes before any order-sensitive wiring resolution in visitRoutes.
|
|
272
278
|
const sourceFiles = program
|
|
273
279
|
.getSourceFiles()
|
|
274
280
|
.filter(
|
|
@@ -276,6 +282,9 @@ export const inspect = async (
|
|
|
276
282
|
sf.fileName.startsWith(rootDir) &&
|
|
277
283
|
!sf.fileName.includes('/node_modules/')
|
|
278
284
|
)
|
|
285
|
+
.sort((a, b) =>
|
|
286
|
+
a.fileName < b.fileName ? -1 : a.fileName > b.fileName ? 1 : 0
|
|
287
|
+
)
|
|
279
288
|
logger.debug(
|
|
280
289
|
`Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`
|
|
281
290
|
)
|
|
@@ -298,7 +307,20 @@ export const inspect = async (
|
|
|
298
307
|
await loadAddonFunctionsMeta(logger, state)
|
|
299
308
|
|
|
300
309
|
if (!options.setupOnly) {
|
|
301
|
-
//
|
|
310
|
+
// Function sweep: register every function before transports/wirings resolve
|
|
311
|
+
// them, so resolution doesn't depend on source-file order.
|
|
312
|
+
const startFunctions = performance.now()
|
|
313
|
+
for (const sourceFile of sourceFiles) {
|
|
314
|
+
const sourceOptions = { ...options, sourceFile }
|
|
315
|
+
ts.forEachChild(sourceFile, (child) =>
|
|
316
|
+
visitFunctions(logger, checker, child, state, sourceOptions)
|
|
317
|
+
)
|
|
318
|
+
}
|
|
319
|
+
logger.debug(
|
|
320
|
+
`Visit functions phase completed in ${(performance.now() - startFunctions).toFixed(0)}ms`
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
// Transport sweep: add all transports/wirings
|
|
302
324
|
const startRoutes = performance.now()
|
|
303
325
|
for (const sourceFile of sourceFiles) {
|
|
304
326
|
const sourceOptions = { ...options, sourceFile }
|
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 = {
|
|
@@ -287,6 +291,13 @@ export type InspectorOptions = Partial<{
|
|
|
287
291
|
tsconfig: string
|
|
288
292
|
schemasFromTypes?: string[]
|
|
289
293
|
schema?: { additionalProperties?: boolean }
|
|
294
|
+
/**
|
|
295
|
+
* Directory for the on-disk TS-schema cache. When set, generated TS schemas
|
|
296
|
+
* are persisted here keyed by a hash of the custom-types content, so a warm
|
|
297
|
+
* `pikku all` whose function types are unchanged skips ts-json-schema-generator
|
|
298
|
+
* entirely (the single largest cold-run cost). Omit to disable disk caching.
|
|
299
|
+
*/
|
|
300
|
+
cacheDir?: string
|
|
290
301
|
}
|
|
291
302
|
openAPI: {
|
|
292
303
|
additionalInfo: OpenAPISpecInfo
|
|
@@ -294,6 +305,12 @@ export type InspectorOptions = Partial<{
|
|
|
294
305
|
tags: string[]
|
|
295
306
|
manifest: VersionManifest
|
|
296
307
|
oldProgram: ts.Program | undefined
|
|
308
|
+
/**
|
|
309
|
+
* Run the data-classification leak scan (Private/Pii/Secret brands in function
|
|
310
|
+
* return types). Off by default — it forces return-type inference on every
|
|
311
|
+
* function, which is expensive. Enabled via `pikku all --security`.
|
|
312
|
+
*/
|
|
313
|
+
classificationCheck: boolean
|
|
297
314
|
}>
|
|
298
315
|
|
|
299
316
|
export interface InspectorLogger {
|
|
@@ -1,7 +1,25 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
|
-
import {
|
|
2
|
+
import { createHash } from 'crypto'
|
|
3
|
+
import { relative } from 'path'
|
|
3
4
|
import { formatVersionedId } from '@pikku/core'
|
|
4
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Deterministic placeholder id for an anonymous/unnamed pikku function or
|
|
8
|
+
* permission. Derived from the call expression's source location (relative path
|
|
9
|
+
* + start position) so `pikku all` produces byte-identical output across runs —
|
|
10
|
+
* a `randomUUID()` here made generated meta non-reproducible. Still `__temp_`
|
|
11
|
+
* prefixed so downstream resolution (which keys off that prefix) is unchanged.
|
|
12
|
+
*/
|
|
13
|
+
function tempFuncId(callExpr: ts.Node, rootDir: string): string {
|
|
14
|
+
const sourceFile = callExpr.getSourceFile()
|
|
15
|
+
const relPath = relative(rootDir, sourceFile.fileName)
|
|
16
|
+
const hash = createHash('sha1')
|
|
17
|
+
.update(`${relPath}:${callExpr.getStart()}`)
|
|
18
|
+
.digest('hex')
|
|
19
|
+
.slice(0, 16)
|
|
20
|
+
return `__temp_${hash}`
|
|
21
|
+
}
|
|
22
|
+
|
|
5
23
|
export type ExtractedFunctionName = {
|
|
6
24
|
pikkuFuncId: string
|
|
7
25
|
name: string
|
|
@@ -126,7 +144,7 @@ export function extractFunctionName(
|
|
|
126
144
|
}
|
|
127
145
|
|
|
128
146
|
if (!result.pikkuFuncId) {
|
|
129
|
-
result.pikkuFuncId =
|
|
147
|
+
result.pikkuFuncId = tempFuncId(callExpr, rootDir)
|
|
130
148
|
}
|
|
131
149
|
|
|
132
150
|
populateNameByPriority(result)
|
|
@@ -440,7 +458,7 @@ export function extractFunctionName(
|
|
|
440
458
|
} else if (result.exportedName) {
|
|
441
459
|
result.pikkuFuncId = result.exportedName
|
|
442
460
|
} else {
|
|
443
|
-
result.pikkuFuncId =
|
|
461
|
+
result.pikkuFuncId = tempFuncId(callExpr, rootDir)
|
|
444
462
|
}
|
|
445
463
|
|
|
446
464
|
if (result.version !== null) {
|
|
@@ -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
|
}
|