@pikku/inspector 0.12.39 → 0.12.40

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 CHANGED
@@ -1,3 +1,16 @@
1
+ ## 0.12.40
2
+
3
+ ### Patch Changes
4
+
5
+ - 1f3f510: Warn when a Pikku function body performs a runtime dynamic `import(...)`.
6
+
7
+ The inspector now flags any `pikkuFunc`/`pikkuSessionlessFunc` (and friends) whose handler body contains a dynamic `import(...)` call — including nested callbacks — with the new `PKU498` diagnostic. Function bodies run on every invocation, so a dynamic import there adds per-call latency and defeats bundling/tree-shaking; the import belongs at the top of the module or in your services/`wireServices` setup instead.
8
+
9
+ Type-only positions like `import('x').Foo` are not flagged. The rule defaults to `warn` — a printed yellow warning that does not fail the build — and is configurable via `lint.functionDynamicImport` in `pikku.config.json` (`'off'` to silence, `'error'` to make it a hard build failure), matching the existing `servicesNotDestructured`/`wiresNotDestructured` lints.
10
+
11
+ - Updated dependencies [1f3f510]
12
+ - @pikku/core@0.12.59
13
+
1
14
  ## 0.12.39
2
15
 
3
16
  ### Patch Changes
@@ -15,6 +15,28 @@ const isValidVariableName = (name) => {
15
15
  const regex = /^[a-zA-Z_$][a-zA-Z0-9_$]*$/;
16
16
  return regex.test(name);
17
17
  };
18
+ /**
19
+ * True when the handler body contains a runtime dynamic `import(...)` call
20
+ * anywhere (including nested callbacks). Type-only positions like
21
+ * `import('x').Foo` are `ImportTypeNode`s, not `CallExpression`s with an
22
+ * `ImportKeyword` callee, so they are intentionally not matched.
23
+ */
24
+ const handlerHasDynamicImport = (handler) => {
25
+ let found = false;
26
+ const visit = (node) => {
27
+ if (found) {
28
+ return;
29
+ }
30
+ if (ts.isCallExpression(node) &&
31
+ node.expression.kind === ts.SyntaxKind.ImportKeyword) {
32
+ found = true;
33
+ return;
34
+ }
35
+ ts.forEachChild(node, visit);
36
+ };
37
+ visit(handler.body);
38
+ return found;
39
+ };
18
40
  const nullifyTypes = (type) => {
19
41
  if (type === 'void' ||
20
42
  type === 'undefined' ||
@@ -779,6 +801,9 @@ export const addFunctions = (logger, node, checker, state, options) => {
779
801
  exportedName: exportedName || undefined,
780
802
  ...bodySpan,
781
803
  };
804
+ if (handlerHasDynamicImport(handler)) {
805
+ state.functions.dynamicImportIds.add(pikkuFuncId);
806
+ }
782
807
  // Populate node metadata if node config is present
783
808
  if (nodeDisplayName && nodeCategory && nodeType) {
784
809
  state.nodes.files.add(node.getSourceFile().fileName);
@@ -46,6 +46,7 @@ export declare enum ErrorCode {
46
46
  SCHEMA_AND_WIRING_COLOCATED = "PKU490",
47
47
  SERVICES_NOT_DESTRUCTURED = "PKU410",
48
48
  WIRES_NOT_DESTRUCTURED = "PKU411",
49
+ FUNCTION_DYNAMIC_IMPORT = "PKU498",
49
50
  DUPLICATE_CORE_VERSION = "PKU717",
50
51
  PII_IN_OUTPUT = "PKU910",
51
52
  ADDON_WIRING_NOT_ALLOWED = "PKU920",
@@ -58,6 +58,7 @@ export var ErrorCode;
58
58
  // Optimization diagnostics
59
59
  ErrorCode["SERVICES_NOT_DESTRUCTURED"] = "PKU410";
60
60
  ErrorCode["WIRES_NOT_DESTRUCTURED"] = "PKU411";
61
+ ErrorCode["FUNCTION_DYNAMIC_IMPORT"] = "PKU498";
61
62
  // Dependency integrity errors
62
63
  ErrorCode["DUPLICATE_CORE_VERSION"] = "PKU717";
63
64
  // Data classification errors
package/dist/inspector.js CHANGED
@@ -43,6 +43,7 @@ export function getInitialInspectorState(rootDir) {
43
43
  meta: {},
44
44
  files: new Map(),
45
45
  approvalDescriptions: {},
46
+ dynamicImportIds: new Set(),
46
47
  },
47
48
  http: {
48
49
  metaInputTypes: new Map(),
package/dist/types.d.ts CHANGED
@@ -76,6 +76,12 @@ export interface InspectorFunctionState {
76
76
  exportedName: string;
77
77
  }>;
78
78
  approvalDescriptions: Record<string, InspectorApprovalDescriptionDefinition>;
79
+ /**
80
+ * pikkuFuncIds whose handler body performs a runtime dynamic `import(...)`.
81
+ * Transient lint signal consumed by computeDiagnostics (PKU498) in the same
82
+ * inspection pass — deliberately not part of the serialized function meta.
83
+ */
84
+ dynamicImportIds: Set<string>;
79
85
  }
80
86
  export interface InspectorChannelState {
81
87
  meta: ChannelsMeta;
@@ -581,6 +581,14 @@ export function computeDiagnostics(state) {
581
581
  position: 0,
582
582
  });
583
583
  }
584
+ if (state.functions.dynamicImportIds.has(id)) {
585
+ diagnostics.push({
586
+ code: ErrorCode.FUNCTION_DYNAMIC_IMPORT,
587
+ message: `Function '${id}' performs a runtime dynamic 'import(...)' in its body. Move the import to the top of the module (static import) or into your services/wireServices setup — function bodies run on every invocation, so a dynamic import there adds latency and defeats bundling/tree-shaking.`,
588
+ sourceFile: meta.pikkuFuncId,
589
+ position: 0,
590
+ });
591
+ }
584
592
  }
585
593
  for (const [id, def] of Object.entries(state.middleware.definitions)) {
586
594
  if (def.services && !def.services.optimized) {
@@ -194,6 +194,7 @@ export function deserializeInspectorState(data) {
194
194
  meta: data.functions.meta,
195
195
  files: new Map(data.functions.files),
196
196
  approvalDescriptions: data.functions.approvalDescriptions || {},
197
+ dynamicImportIds: new Set(),
197
198
  },
198
199
  http: {
199
200
  metaInputTypes: new Map(data.http.metaInputTypes),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.39",
3
+ "version": "0.12.40",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -44,10 +44,10 @@
44
44
  "zod-to-ts": "^2.1.0"
45
45
  },
46
46
  "peerDependencies": {
47
- "@pikku/core": "^0.12.58"
47
+ "@pikku/core": "^0.12.59"
48
48
  },
49
49
  "devDependencies": {
50
- "@pikku/core": "^0.12.58",
50
+ "@pikku/core": "^0.12.59",
51
51
  "@types/node": "^24.11.0"
52
52
  },
53
53
  "engines": {
@@ -27,6 +27,33 @@ const isValidVariableName = (name: string) => {
27
27
  return regex.test(name)
28
28
  }
29
29
 
30
+ /**
31
+ * True when the handler body contains a runtime dynamic `import(...)` call
32
+ * anywhere (including nested callbacks). Type-only positions like
33
+ * `import('x').Foo` are `ImportTypeNode`s, not `CallExpression`s with an
34
+ * `ImportKeyword` callee, so they are intentionally not matched.
35
+ */
36
+ const handlerHasDynamicImport = (
37
+ handler: ts.ArrowFunction | ts.FunctionExpression
38
+ ): boolean => {
39
+ let found = false
40
+ const visit = (node: ts.Node) => {
41
+ if (found) {
42
+ return
43
+ }
44
+ if (
45
+ ts.isCallExpression(node) &&
46
+ node.expression.kind === ts.SyntaxKind.ImportKeyword
47
+ ) {
48
+ found = true
49
+ return
50
+ }
51
+ ts.forEachChild(node, visit)
52
+ }
53
+ visit(handler.body)
54
+ return found
55
+ }
56
+
30
57
  const nullifyTypes = (type: string | null) => {
31
58
  if (
32
59
  type === 'void' ||
@@ -1083,6 +1110,10 @@ export const addFunctions: AddWiring = (
1083
1110
  ...bodySpan,
1084
1111
  }
1085
1112
 
1113
+ if (handlerHasDynamicImport(handler)) {
1114
+ state.functions.dynamicImportIds.add(pikkuFuncId)
1115
+ }
1116
+
1086
1117
  // Populate node metadata if node config is present
1087
1118
  if (nodeDisplayName && nodeCategory && nodeType) {
1088
1119
  state.nodes.files.add(node.getSourceFile().fileName)
@@ -0,0 +1,119 @@
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
+ const logger: InspectorLogger = {
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ diagnostic: () => {},
16
+ critical: () => {},
17
+ hasCriticalErrors: () => false,
18
+ }
19
+
20
+ async function inspectSource(source: string) {
21
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-dynimport-'))
22
+ const file = join(rootDir, 'my.function.ts')
23
+ await writeFile(file, source)
24
+ try {
25
+ return await inspect(logger, [file], { rootDir })
26
+ } finally {
27
+ await rm(rootDir, { recursive: true, force: true })
28
+ }
29
+ }
30
+
31
+ describe('dynamic import in function bodies', () => {
32
+ test('flags a function whose body does `await import(...)`', async () => {
33
+ const state = await inspectSource(
34
+ [
35
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
36
+ '',
37
+ 'export const greedy = pikkuSessionlessFunc({',
38
+ ' func: async ({ logger }) => {',
39
+ " const { readFile } = await import('node:fs/promises')",
40
+ ' return { readFile: typeof readFile }',
41
+ ' },',
42
+ '})',
43
+ ].join('\n')
44
+ )
45
+ const meta = (state.functions.meta as any).greedy
46
+ assert.ok(meta, 'greedy should be inspected')
47
+ assert.ok(state.functions.dynamicImportIds.has(meta.pikkuFuncId))
48
+ const diagnostic = state.diagnostics.find(
49
+ (d) => d.code === ErrorCode.FUNCTION_DYNAMIC_IMPORT
50
+ )
51
+ assert.ok(
52
+ diagnostic,
53
+ 'expected a FUNCTION_DYNAMIC_IMPORT diagnostic for the function'
54
+ )
55
+ })
56
+
57
+ test('flags a nested `import(...)` call inside a callback', async () => {
58
+ const state = await inspectSource(
59
+ [
60
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
61
+ '',
62
+ 'export const nested = pikkuSessionlessFunc({',
63
+ ' func: async ({ logger }) => {',
64
+ ' await Promise.all([',
65
+ " import('node:os').then((m) => m.hostname()),",
66
+ ' ])',
67
+ ' return { ok: true }',
68
+ ' },',
69
+ '})',
70
+ ].join('\n')
71
+ )
72
+ const meta = (state.functions.meta as any).nested
73
+ assert.ok(state.functions.dynamicImportIds.has(meta.pikkuFuncId))
74
+ assert.ok(
75
+ state.diagnostics.some(
76
+ (d) => d.code === ErrorCode.FUNCTION_DYNAMIC_IMPORT
77
+ )
78
+ )
79
+ })
80
+
81
+ test('does NOT flag a function with only static imports', async () => {
82
+ const state = await inspectSource(
83
+ [
84
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
85
+ '',
86
+ 'export const clean = pikkuSessionlessFunc({',
87
+ ' func: async ({ logger }, data: { n: number }) => {',
88
+ ' return { doubled: data.n * 2 }',
89
+ ' },',
90
+ '})',
91
+ ].join('\n')
92
+ )
93
+ const meta = (state.functions.meta as any).clean
94
+ assert.ok(meta, 'clean should be inspected')
95
+ assert.ok(!state.functions.dynamicImportIds.has(meta.pikkuFuncId))
96
+ assert.ok(
97
+ !state.diagnostics.some(
98
+ (d) => d.code === ErrorCode.FUNCTION_DYNAMIC_IMPORT
99
+ )
100
+ )
101
+ })
102
+
103
+ test('does NOT flag a type-only import position `import("x").T`', async () => {
104
+ const state = await inspectSource(
105
+ [
106
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
107
+ '',
108
+ 'export const typeOnly = pikkuSessionlessFunc({',
109
+ " func: async ({ logger }, data: import('node:buffer').Blob) => {",
110
+ ' return { size: data.size }',
111
+ ' },',
112
+ '})',
113
+ ].join('\n')
114
+ )
115
+ const meta = (state.functions.meta as any).typeOnly
116
+ assert.ok(meta, 'typeOnly should be inspected')
117
+ assert.ok(!state.functions.dynamicImportIds.has(meta.pikkuFuncId))
118
+ })
119
+ })
@@ -68,6 +68,7 @@ export enum ErrorCode {
68
68
  // Optimization diagnostics
69
69
  SERVICES_NOT_DESTRUCTURED = 'PKU410',
70
70
  WIRES_NOT_DESTRUCTURED = 'PKU411',
71
+ FUNCTION_DYNAMIC_IMPORT = 'PKU498',
71
72
 
72
73
  // Dependency integrity errors
73
74
  DUPLICATE_CORE_VERSION = 'PKU717',
package/src/inspector.ts CHANGED
@@ -74,6 +74,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
74
74
  meta: {},
75
75
  files: new Map(),
76
76
  approvalDescriptions: {},
77
+ dynamicImportIds: new Set(),
77
78
  },
78
79
  http: {
79
80
  metaInputTypes: new Map(),
package/src/types.ts CHANGED
@@ -101,6 +101,12 @@ export interface InspectorFunctionState {
101
101
  meta: FunctionsMeta
102
102
  files: Map<string, { path: string; exportedName: string }>
103
103
  approvalDescriptions: Record<string, InspectorApprovalDescriptionDefinition>
104
+ /**
105
+ * pikkuFuncIds whose handler body performs a runtime dynamic `import(...)`.
106
+ * Transient lint signal consumed by computeDiagnostics (PKU498) in the same
107
+ * inspection pass — deliberately not part of the serialized function meta.
108
+ */
109
+ dynamicImportIds: Set<string>
104
110
  }
105
111
 
106
112
  export interface InspectorChannelState {
@@ -5,10 +5,11 @@ import type { InspectorState } from '../types.js'
5
5
  import { ErrorCode } from '../error-codes.js'
6
6
 
7
7
  function stateWithFunctions(
8
- meta: InspectorState['functions']['meta']
8
+ meta: InspectorState['functions']['meta'],
9
+ dynamicImportIds: string[] = []
9
10
  ): InspectorState {
10
11
  return {
11
- functions: { meta },
12
+ functions: { meta, dynamicImportIds: new Set(dynamicImportIds) },
12
13
  middleware: { definitions: {} },
13
14
  permissions: { definitions: {} },
14
15
  } as unknown as InspectorState
@@ -27,10 +28,7 @@ describe('computeDiagnostics', () => {
27
28
  })
28
29
  computeDiagnostics(state)
29
30
  assert.equal(state.diagnostics.length, 1)
30
- assert.equal(
31
- state.diagnostics[0].code,
32
- ErrorCode.SERVICES_NOT_DESTRUCTURED
33
- )
31
+ assert.equal(state.diagnostics[0].code, ErrorCode.SERVICES_NOT_DESTRUCTURED)
34
32
  })
35
33
 
36
34
  test('does NOT flag a generated .gen.ts function (user cannot edit it)', () => {
@@ -66,4 +64,43 @@ describe('computeDiagnostics', () => {
66
64
  computeDiagnostics(state)
67
65
  assert.equal(state.diagnostics.length, 0)
68
66
  })
67
+
68
+ test('flags a user-authored function that does a dynamic import in its body', () => {
69
+ const state = stateWithFunctions(
70
+ {
71
+ greedy: {
72
+ pikkuFuncId: 'greedy',
73
+ inputSchemaName: null,
74
+ outputSchemaName: null,
75
+ sourceFile: '/project/src/greedy.ts',
76
+ services: { optimized: true, services: ['logger'] },
77
+ },
78
+ },
79
+ ['greedy']
80
+ )
81
+ computeDiagnostics(state)
82
+ assert.equal(
83
+ state.diagnostics.filter(
84
+ (d) => d.code === ErrorCode.FUNCTION_DYNAMIC_IMPORT
85
+ ).length,
86
+ 1
87
+ )
88
+ })
89
+
90
+ test('does NOT flag a dynamic import in a generated .gen.ts function', () => {
91
+ const state = stateWithFunctions(
92
+ {
93
+ authHandler: {
94
+ pikkuFuncId: 'authHandler',
95
+ inputSchemaName: null,
96
+ outputSchemaName: null,
97
+ sourceFile: '/project/.pikku/auth.gen.ts',
98
+ services: { optimized: true, services: [] },
99
+ },
100
+ },
101
+ ['authHandler']
102
+ )
103
+ computeDiagnostics(state)
104
+ assert.equal(state.diagnostics.length, 0)
105
+ })
69
106
  })
@@ -719,6 +719,14 @@ export function computeDiagnostics(state: InspectorState): void {
719
719
  position: 0,
720
720
  })
721
721
  }
722
+ if (state.functions.dynamicImportIds.has(id)) {
723
+ diagnostics.push({
724
+ code: ErrorCode.FUNCTION_DYNAMIC_IMPORT,
725
+ message: `Function '${id}' performs a runtime dynamic 'import(...)' in its body. Move the import to the top of the module (static import) or into your services/wireServices setup — function bodies run on every invocation, so a dynamic import there adds latency and defeats bundling/tree-shaking.`,
726
+ sourceFile: meta.pikkuFuncId,
727
+ position: 0,
728
+ })
729
+ }
722
730
  }
723
731
 
724
732
  for (const [id, def] of Object.entries(state.middleware.definitions)) {
@@ -529,6 +529,7 @@ export function deserializeInspectorState(
529
529
  meta: data.functions.meta,
530
530
  files: new Map(data.functions.files),
531
531
  approvalDescriptions: (data.functions as any).approvalDescriptions || {},
532
+ dynamicImportIds: new Set(),
532
533
  },
533
534
  http: {
534
535
  metaInputTypes: new Map(data.http.metaInputTypes),