@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.
Files changed (40) hide show
  1. package/CHANGELOG.md +115 -0
  2. package/dist/add/add-functions.js +15 -4
  3. package/dist/add/add-rpc-invocations.js +27 -0
  4. package/dist/error-codes.d.ts +2 -1
  5. package/dist/error-codes.js +1 -0
  6. package/dist/inspector.js +18 -3
  7. package/dist/types.d.ts +14 -0
  8. package/dist/utils/extract-function-name.js +20 -3
  9. package/dist/utils/filter-inspector-state.js +7 -6
  10. package/dist/utils/post-process.js +8 -1
  11. package/dist/utils/resolve-deploy-target.d.ts +3 -2
  12. package/dist/utils/resolve-deploy-target.js +4 -3
  13. package/dist/utils/schema-generator.d.ts +1 -0
  14. package/dist/utils/schema-generator.js +76 -0
  15. package/dist/utils/workflow/derive-workflow-plan.d.ts +20 -0
  16. package/dist/utils/workflow/derive-workflow-plan.js +78 -0
  17. package/dist/utils/workflow/graph/finalize-workflows.js +15 -0
  18. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +14 -3
  19. package/dist/visit.d.ts +1 -0
  20. package/dist/visit.js +14 -1
  21. package/package.json +2 -2
  22. package/src/add/add-functions.ts +15 -4
  23. package/src/add/add-rpc-invocations.ts +41 -0
  24. package/src/add/pii-check.test.ts +3 -1
  25. package/src/add/rpc-type-cast.test.ts +123 -0
  26. package/src/error-codes.ts +2 -0
  27. package/src/inspector.ts +24 -2
  28. package/src/types.ts +17 -0
  29. package/src/utils/extract-function-name.ts +21 -3
  30. package/src/utils/filter-inspector-state.ts +13 -7
  31. package/src/utils/post-process.ts +8 -1
  32. package/src/utils/resolve-deploy-target.test.ts +30 -0
  33. package/src/utils/resolve-deploy-target.ts +5 -3
  34. package/src/utils/schema-generator.ts +92 -0
  35. package/src/utils/workflow/derive-workflow-plan.test.ts +122 -0
  36. package/src/utils/workflow/derive-workflow-plan.ts +90 -0
  37. package/src/utils/workflow/graph/finalize-workflows.ts +15 -0
  38. package/src/utils/workflow/graph/workflow-graph.types.ts +18 -3
  39. package/src/visit.ts +25 -1
  40. 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(logger, node, checker, state, nextOptions);
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.26",
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.37",
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",
@@ -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 inline: boolean | undefined
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
- inline = getPropertyValue(firstArg, 'inline') as boolean | undefined
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
- inline: inline === false ? false : undefined,
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
- await inspect(logger, [file], { rootDir: tmpDir })
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
+ })
@@ -88,6 +88,8 @@ export enum ErrorCode {
88
88
  // Addon authoring errors
89
89
  ADDON_WIRING_NOT_ALLOWED = 'PKU920',
90
90
  ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = 'PKU921',
91
+
92
+ RPC_INVOCATION_TYPE_CAST = 'PKU940',
91
93
  }
92
94
 
93
95
  /**
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
- // Second sweep: add all transports
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 { randomUUID } from 'crypto'
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 = `__temp_${randomUUID()}`
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 = `__temp_${randomUUID()}`
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(funcMeta as any, incompatible, funcId)
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 opts out of inline execution (inline: false).
1066
- // Such a unit needs workflowService + queueService injected even though the
1067
- // function itself doesn't reference them. Check the ORIGINAL graph meta
1068
- // (before filtering pruned it).
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 { inline?: boolean }
1085
- if (funcMeta?.inline === false) {
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. Default 'serverless'
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 'serverless'
64
+ return defaultTarget
63
65
  }