@pikku/inspector 0.12.10 → 0.12.11

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,11 @@
1
+ ## 0.12.11
2
+
3
+ ### Patch Changes
4
+
5
+ - 033d172: Log a critical inspector error when multiple functions resolve to the same `pikku` function name, instead of silently allowing routing map collisions. This may cause builds to fail if multiple functions previously resolved to the same `pikku` function name.
6
+ - Updated dependencies [b9ed73e]
7
+ - @pikku/core@0.12.19
8
+
1
9
  ## 0.12.0
2
10
 
3
11
  ## 0.12.10
@@ -32,7 +40,6 @@
32
40
  ### Patch Changes
33
41
 
34
42
  - 624097e: Add deploy pipeline with provider-agnostic architecture
35
-
36
43
  - Add MetaService with explicit typed API, absorb WiringService reads
37
44
  - Add deployment service, traceId propagation, scoped logger
38
45
  - Rewrite analyzer: one function = one worker, gateways dispatch via RPC
@@ -79,7 +86,6 @@
79
86
 
80
87
  - 5866b66: Add critical error (PKU490) when Zod schemas and wiring calls (wireHTTPRoutes, addPermission, addHTTPMiddleware) coexist in the same file. The CLI uses tsImport to extract Zod schemas at runtime, which executes all top-level code — wiring side-effects crash in this context because pikku state metadata doesn't exist. Schemas and wirings must be in separate files.
81
88
  - e412b4d: Optimize CLI codegen performance: 12x faster `pikku all`
82
-
83
89
  - Reuse schemas across re-inspections (skip redundant `ts-json-schema-generator` runs)
84
90
  - Cache TS schemas to disk (`.pikku/schema-cache.json`) for cross-run reuse
85
91
  - Pass `oldProgram` to `ts.createProgram` for incremental TS compilation
@@ -208,14 +214,12 @@
208
214
  - 1967172: Update code generation to support channel middleware enhancements
209
215
 
210
216
  **Code Generation Updates:**
211
-
212
217
  - Update channel type serialization to include middleware support
213
218
  - Improve WebSocket wrapper generation for middleware handling
214
219
  - Update CLI channel client generation with better type support
215
220
  - Enhance services and schema generation for channel configurations
216
221
 
217
222
  **Inspector Updates:**
218
-
219
223
  - Improve channel metadata extraction for middleware
220
224
  - Better type analysis for channel lifecycle functions
221
225
  - Enhanced post-processing for channel configurations
@@ -223,19 +227,16 @@
223
227
  - 753481a: Add bootstrap command, performance optimizations, and CLI improvements
224
228
 
225
229
  **New Features:**
226
-
227
230
  - Add `pikku bootstrap` command for type-only generation (~13.5% faster than `pikku all`)
228
231
  - Add configurable `ignoreFiles` option to pikku.config.json with sensible defaults (_.gen.ts, _.test.ts, \*.spec.ts)
229
232
  - Export pikkuCLIRender helper from serialize-cli-types.ts with JSDoc documentation
230
233
 
231
234
  **Performance Improvements:**
232
-
233
235
  - Add aggressive TypeScript compiler options (skipDefaultLibCheck, types: []) - ~37% faster TypeScript setup
234
236
  - Add detailed performance timing to inspector phases (--logLevel=debug)
235
237
  - Optimize file inspection with ignore patterns - ~10-20% faster overall
236
238
 
237
239
  **Enhancements:**
238
-
239
240
  - Fix --logLevel flag to properly apply log level to logger
240
241
  - Update middleware logging to use structured log format
241
242
  - Improve CLI renderers to consistently use destructured logger service
@@ -336,7 +337,6 @@ For complete details, see https://pikku.dev/changelogs/0_10_0.md
336
337
  ### Patch Changes
337
338
 
338
339
  - 44e3ff4: feat: enhance CLI filtering with type and directory filters
339
-
340
340
  - Add --types filter to filter by PikkuEventTypes (http, channel, queue, scheduler, rpc, mcp)
341
341
  - Add --directories filter to filter by file paths/directories
342
342
  - All filters (tags, types, directories) now work together with AND logic
@@ -347,7 +347,6 @@ For complete details, see https://pikku.dev/changelogs/0_10_0.md
347
347
  - 7c592b8: feat: support for required services and improved service configuration
348
348
 
349
349
  This release includes several enhancements to service management and configuration:
350
-
351
350
  - Added support for required services configuration
352
351
  - Improved service discovery and registration
353
352
  - Added typed RPC clients for service communication
@@ -3,7 +3,7 @@ import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js';
3
3
  import { extractFunctionName, funcIdToTypeName, } from '../utils/extract-function-name.js';
4
4
  import { extractFunctionNode } from '../utils/extract-function-node.js';
5
5
  import { extractUsedWires } from '../utils/extract-services.js';
6
- import { formatVersionedId } from '@pikku/core';
6
+ import { formatVersionedId, parseVersionedId } from '@pikku/core';
7
7
  import { getPropertyValue, getCommonWireMetaData, } from '../utils/get-property-value.js';
8
8
  import { resolveMiddleware } from '../utils/middleware.js';
9
9
  import { resolvePermissions } from '../utils/permissions.js';
@@ -209,6 +209,19 @@ function unwrapPromise(checker, type) {
209
209
  }
210
210
  return type;
211
211
  }
212
+ const resolveExistingFunctionSource = (state, pikkuFuncId) => {
213
+ return (state.functions.meta[pikkuFuncId]?.sourceFile ||
214
+ state.rpc.internalFiles.get(pikkuFuncId)?.path ||
215
+ null);
216
+ };
217
+ const areCompatibleFunctionIds = (existingId, incomingId) => {
218
+ if (existingId === incomingId) {
219
+ return true;
220
+ }
221
+ const existingParsed = parseVersionedId(existingId);
222
+ const incomingParsed = parseVersionedId(incomingId);
223
+ return existingParsed.baseName === incomingParsed.baseName;
224
+ };
212
225
  /**
213
226
  * Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
214
227
  * then push into state.functions.meta.
@@ -400,6 +413,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
400
413
  pikkuFuncId = formatVersionedId(baseName, version);
401
414
  }
402
415
  const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc';
416
+ const isListFunc = expression.text === 'pikkuListFunc';
403
417
  const mcpEnabled = mcp || isMCPToolFunc;
404
418
  // Pick the handler: use resolvedFunc when it exists and is a function, otherwise fall back to handlerNode
405
419
  const handler = resolvedFunc &&
@@ -461,7 +475,22 @@ export const addFunctions = (logger, node, checker, state, options) => {
461
475
  state.schemaLookup.set(schemaName, inputSchemaRef);
462
476
  state.functions.typesMap.addCustomType(schemaName, 'unknown', []);
463
477
  }
464
- else if (genericTypes.length >= 1 && genericTypes[0]) {
478
+ else if (isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
479
+ const inputAliasName = `${capitalizedName}Input`;
480
+ const filterType = genericTypes[0];
481
+ const filterTypeText = checker.typeToString(filterType, undefined, ts.TypeFormatFlags.NoTruncation);
482
+ const refs = resolveTypeImports(filterType, state.functions.typesMap, true, checker);
483
+ state.functions.typesMap.addCustomType(inputAliasName, `{ cursor?: string; limit?: number; sort?: Array<{ column: string; direction: 'asc' | 'desc' }>; filter?: unknown; search?: string; } & ${filterTypeText}`, [...new Set(refs)]);
484
+ inputNames = [inputAliasName];
485
+ const secondParam = handler.parameters[1];
486
+ if (secondParam) {
487
+ inputTypes = [checker.getTypeAtLocation(secondParam)];
488
+ }
489
+ else {
490
+ inputTypes = [filterType];
491
+ }
492
+ }
493
+ else if (!isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
465
494
  // Fall back to extracting from generic type arguments
466
495
  const result = getNamesAndTypes(checker, state.functions.typesMap, 'Input', name, genericTypes[0]);
467
496
  inputNames = result.names;
@@ -485,7 +514,15 @@ export const addFunctions = (logger, node, checker, state, options) => {
485
514
  state.schemaLookup.set(schemaName, outputSchemaRef);
486
515
  state.functions.typesMap.addCustomType(schemaName, 'unknown', []);
487
516
  }
488
- else if (genericTypes.length >= 2) {
517
+ else if (isListFunc && genericTypes.length >= 2 && genericTypes[1]) {
518
+ const outputAliasName = `${capitalizedName}Output`;
519
+ const rowType = genericTypes[1];
520
+ const rowTypeText = checker.typeToString(rowType, undefined, ts.TypeFormatFlags.NoTruncation);
521
+ const refs = resolveTypeImports(rowType, state.functions.typesMap, true, checker);
522
+ state.functions.typesMap.addCustomType(outputAliasName, `{ rows: Array<${rowTypeText}>; nextCursor: string | null; totalCount?: number; }`, [...new Set(refs)]);
523
+ outputNames = [outputAliasName];
524
+ }
525
+ else if (!isListFunc && genericTypes.length >= 2) {
489
526
  outputNames = getNamesAndTypes(checker, state.functions.typesMap, 'Output', name, genericTypes[1]).names;
490
527
  }
491
528
  else {
@@ -539,6 +576,36 @@ export const addFunctions = (logger, node, checker, state, options) => {
539
576
  if (inputTypes.length > 0) {
540
577
  state.typesLookup.set(pikkuFuncId, inputTypes);
541
578
  }
579
+ const sourceFile = node.getSourceFile().fileName;
580
+ const existingFunction = state.functions.meta[pikkuFuncId];
581
+ if (existingFunction &&
582
+ existingFunction.sourceFile &&
583
+ existingFunction.sourceFile !== sourceFile) {
584
+ logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
585
+ `'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`);
586
+ return;
587
+ }
588
+ if (exportedName || explicitName) {
589
+ const existingInternal = state.rpc.internalMeta[name];
590
+ if (existingInternal &&
591
+ !areCompatibleFunctionIds(existingInternal, pikkuFuncId)) {
592
+ const existingSource = resolveExistingFunctionSource(state, existingInternal) || 'unknown file';
593
+ logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Function name '${name}' is not unique. ` +
594
+ `It already points to '${existingInternal}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`);
595
+ return;
596
+ }
597
+ if (expose) {
598
+ const existingExposed = state.rpc.exposedMeta[name];
599
+ if (existingExposed &&
600
+ !areCompatibleFunctionIds(existingExposed, pikkuFuncId)) {
601
+ const existingSource = resolveExistingFunctionSource(state, existingExposed) ||
602
+ 'unknown file';
603
+ logger.critical(ErrorCode.DUPLICATE_FUNCTION_NAME, `Exposed function name '${name}' is not unique. ` +
604
+ `It already points to '${existingExposed}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`);
605
+ return;
606
+ }
607
+ }
608
+ }
542
609
  // --- resolve middleware ---
543
610
  let middleware = objectNode
544
611
  ? resolveMiddleware(state, objectNode, tags, checker)
@@ -597,7 +664,7 @@ export const addFunctions = (logger, node, checker, state, options) => {
597
664
  middleware,
598
665
  permissions,
599
666
  isDirectFunction,
600
- sourceFile: node.getSourceFile().fileName,
667
+ sourceFile,
601
668
  exportedName: exportedName || undefined,
602
669
  };
603
670
  // Populate node metadata if node config is present
@@ -7,6 +7,7 @@ import { resolveHTTPMiddlewareFromObject } from '../utils/middleware.js';
7
7
  import { resolveHTTPPermissionsFromObject } from '../utils/permissions.js';
8
8
  import { extractWireNames } from '../utils/post-process.js';
9
9
  import { ensureFunctionMetadata } from '../utils/ensure-function-metadata.js';
10
+ import { resolveFunctionMeta } from '../utils/resolve-function-meta.js';
10
11
  import { ErrorCode } from '../error-codes.js';
11
12
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js';
12
13
  import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js';
@@ -113,12 +114,30 @@ export function registerHTTPRoute({ obj, state, checker, logger, sourceFile, bas
113
114
  if (funcName.startsWith('__temp_')) {
114
115
  funcName = makeContextBasedId('http', method, fullRoute);
115
116
  }
117
+ let refAddonTarget = null;
118
+ if (ts.isCallExpression(funcInitializer) &&
119
+ ts.isIdentifier(funcInitializer.expression) &&
120
+ funcInitializer.expression.text === 'ref') {
121
+ const [firstArg] = funcInitializer.arguments;
122
+ if (firstArg &&
123
+ ts.isStringLiteral(firstArg) &&
124
+ firstArg.text.includes(':')) {
125
+ refAddonTarget = firstArg.text;
126
+ }
127
+ }
116
128
  const packageName = ts.isIdentifier(funcInitializer)
117
129
  ? resolveAddonName(funcInitializer, checker, state.rpc.wireAddonDeclarations)
118
130
  : null;
131
+ if (refAddonTarget) {
132
+ const targetMeta = resolveFunctionMeta(state, refAddonTarget);
133
+ if (!targetMeta) {
134
+ logger.warn(`Skipping route '${fullRoute}': addon function metadata for '${refAddonTarget}' is not available yet.`);
135
+ return;
136
+ }
137
+ }
119
138
  ensureFunctionMetadata(state, funcName, fullRoute, funcInitializer, checker, extracted.isHelper);
120
139
  // Lookup existing function metadata
121
- const fnMeta = state.functions.meta[funcName];
140
+ const fnMeta = resolveFunctionMeta(state, funcName);
122
141
  if (!fnMeta) {
123
142
  logger.critical(ErrorCode.FUNCTION_METADATA_NOT_FOUND, `No function metadata found for '${funcName}'.`);
124
143
  return;
@@ -41,6 +41,7 @@ export declare enum ErrorCode {
41
41
  PERMISSION_EMPTY_ARRAY = "PKU937",
42
42
  PERMISSION_PATTERN_INVALID = "PKU975",
43
43
  DUPLICATE_FUNCTION_VERSION = "PKU850",
44
+ DUPLICATE_FUNCTION_NAME = "PKU851",
44
45
  MANIFEST_MISSING = "PKU860",
45
46
  FUNCTION_VERSION_MODIFIED = "PKU861",
46
47
  CONTRACT_CHANGED_REQUIRES_BUMP = "PKU862",
@@ -48,6 +48,7 @@ export var ErrorCode;
48
48
  ErrorCode["PERMISSION_PATTERN_INVALID"] = "PKU975";
49
49
  // Versioning errors
50
50
  ErrorCode["DUPLICATE_FUNCTION_VERSION"] = "PKU850";
51
+ ErrorCode["DUPLICATE_FUNCTION_NAME"] = "PKU851";
51
52
  // Contract versioning errors
52
53
  ErrorCode["MANIFEST_MISSING"] = "PKU860";
53
54
  ErrorCode["FUNCTION_VERSION_MODIFIED"] = "PKU861";
@@ -258,10 +258,10 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
258
258
  const uniqueSourceFiles = [
259
259
  ...new Set([...schemaLookup.values()].map((ref) => ref.sourceFile)),
260
260
  ];
261
- console.log(`[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`);
261
+ logger.info(`[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`);
262
262
  const importStart = performance.now();
263
263
  const importedModules = await batchImportWithRegister(logger, uniqueSourceFiles);
264
- console.log(`[TIMING] Batch import: ${(performance.now() - importStart).toFixed(0)}ms`);
264
+ logger.info(`[TIMING] Batch import: ${(performance.now() - importStart).toFixed(0)}ms`);
265
265
  const processStart = performance.now();
266
266
  // Track schemas that need per-file tsImport fallback
267
267
  const fallbackSchemas = [];
@@ -302,7 +302,7 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
302
302
  }
303
303
  }
304
304
  }
305
- console.log(`[TIMING] Process schemas: ${(performance.now() - processStart).toFixed(0)}ms (${Object.keys(schemas).length} generated)`);
305
+ logger.info(`[TIMING] Process schemas: ${(performance.now() - processStart).toFixed(0)}ms (${Object.keys(schemas).length} generated)`);
306
306
  return schemas;
307
307
  }
308
308
  export async function generateAllSchemas(logger, config, state) {
@@ -1,3 +1,3 @@
1
- import * as ts from 'typescript';
1
+ import type * as ts from 'typescript';
2
2
  import type { InspectorLogger, InspectorState } from '../types.js';
3
3
  export declare function validateAuthSessionless(logger: InspectorLogger, obj: ts.ObjectLiteralExpression, state: InspectorState, funcName: string, wireDescription: string, inheritedAuth?: boolean): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.10",
3
+ "version": "0.12.11",
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.18",
38
+ "@pikku/core": "^0.12.19",
39
39
  "path-to-regexp": "^8.3.0",
40
40
  "ts-json-schema-generator": "^2.5.0",
41
41
  "tsx": "^4.21.0",
@@ -0,0 +1,169 @@
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
+ describe('addFunctions duplicate name handling', () => {
11
+ test('logs a critical error when function name is duplicated across files', async () => {
12
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-duplicate-function-'))
13
+ const fileA = join(rootDir, 'a.ts')
14
+ const fileB = join(rootDir, 'b.ts')
15
+
16
+ await writeFile(
17
+ fileA,
18
+ [
19
+ "import { pikkuFunc } from '@pikku/core'",
20
+ 'export const createUser = pikkuFunc({',
21
+ ' func: async () => ({ ok: true })',
22
+ '})',
23
+ ].join('\n')
24
+ )
25
+
26
+ await writeFile(
27
+ fileB,
28
+ [
29
+ "import { pikkuFunc } from '@pikku/core'",
30
+ 'export const createUser = pikkuFunc({',
31
+ ' func: async () => ({ ok: true })',
32
+ '})',
33
+ ].join('\n')
34
+ )
35
+
36
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
37
+ const logger: InspectorLogger = {
38
+ debug: () => {},
39
+ info: () => {},
40
+ warn: () => {},
41
+ error: () => {},
42
+ critical: (code: ErrorCode, message: string) => {
43
+ criticals.push({ code, message })
44
+ },
45
+ hasCriticalErrors: () => criticals.length > 0,
46
+ }
47
+
48
+ try {
49
+ const state = await inspect(logger, [fileA, fileB], { rootDir })
50
+ const nameCollision = criticals.find(
51
+ (entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
52
+ )
53
+ assert.ok(nameCollision)
54
+ assert.match(nameCollision!.message, /createUser/)
55
+ assert.strictEqual(state.rpc.internalMeta['createUser'], 'createUser')
56
+ } finally {
57
+ await rm(rootDir, { recursive: true, force: true })
58
+ }
59
+ })
60
+
61
+ test('allows same base function name across files when versions differ', async () => {
62
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-versioned-function-'))
63
+ const fileA = join(rootDir, 'a.ts')
64
+ const fileB = join(rootDir, 'b.ts')
65
+
66
+ await writeFile(
67
+ fileA,
68
+ [
69
+ "import { pikkuFunc } from '@pikku/core'",
70
+ 'export const createUser = pikkuFunc({',
71
+ ' version: 1,',
72
+ ' func: async () => ({ ok: true })',
73
+ '})',
74
+ ].join('\n')
75
+ )
76
+
77
+ await writeFile(
78
+ fileB,
79
+ [
80
+ "import { pikkuFunc } from '@pikku/core'",
81
+ 'export const createUser = pikkuFunc({',
82
+ ' version: 2,',
83
+ ' func: async () => ({ ok: true })',
84
+ '})',
85
+ ].join('\n')
86
+ )
87
+
88
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
89
+ const logger: InspectorLogger = {
90
+ debug: () => {},
91
+ info: () => {},
92
+ warn: () => {},
93
+ error: () => {},
94
+ critical: (code: ErrorCode, message: string) => {
95
+ criticals.push({ code, message })
96
+ },
97
+ hasCriticalErrors: () => criticals.length > 0,
98
+ }
99
+
100
+ try {
101
+ const state = await inspect(logger, [fileA, fileB], { rootDir })
102
+ const nameCollision = criticals.find(
103
+ (entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
104
+ )
105
+ assert.equal(nameCollision, undefined)
106
+ assert.strictEqual(state.rpc.internalMeta['createUser'], 'createUser@v2')
107
+ assert.ok(state.functions.meta['createUser@v1'])
108
+ assert.ok(state.functions.meta['createUser@v2'])
109
+ } finally {
110
+ await rm(rootDir, { recursive: true, force: true })
111
+ }
112
+ })
113
+
114
+ test('logs a critical error when exposed function name is duplicated across files', async () => {
115
+ const rootDir = await mkdtemp(
116
+ join(tmpdir(), 'pikku-exposed-duplicate-function-')
117
+ )
118
+ const fileA = join(rootDir, 'a.ts')
119
+ const fileB = join(rootDir, 'b.ts')
120
+
121
+ await writeFile(
122
+ fileA,
123
+ [
124
+ "import { pikkuFunc } from '@pikku/core'",
125
+ 'export const createUser = pikkuFunc({',
126
+ ' expose: true,',
127
+ ' func: async () => ({ ok: true })',
128
+ '})',
129
+ ].join('\n')
130
+ )
131
+
132
+ await writeFile(
133
+ fileB,
134
+ [
135
+ "import { pikkuFunc } from '@pikku/core'",
136
+ 'export const createUser = pikkuFunc({',
137
+ ' expose: true,',
138
+ ' func: async () => ({ ok: true })',
139
+ '})',
140
+ ].join('\n')
141
+ )
142
+
143
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
144
+ const logger: InspectorLogger = {
145
+ debug: () => {},
146
+ info: () => {},
147
+ warn: () => {},
148
+ error: () => {},
149
+ critical: (code: ErrorCode, message: string) => {
150
+ criticals.push({ code, message })
151
+ },
152
+ hasCriticalErrors: () => criticals.length > 0,
153
+ }
154
+
155
+ try {
156
+ await inspect(logger, [fileA, fileB], { rootDir })
157
+ const nameCollision = criticals.find(
158
+ (entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
159
+ )
160
+ assert.ok(nameCollision)
161
+ assert.match(
162
+ nameCollision!.message,
163
+ /Function name 'createUser' is not unique/
164
+ )
165
+ } finally {
166
+ await rm(rootDir, { recursive: true, force: true })
167
+ }
168
+ })
169
+ })
@@ -1,5 +1,5 @@
1
1
  import * as ts from 'typescript'
2
- import type { AddWiring, SchemaRef } from '../types.js'
2
+ import type { AddWiring, InspectorState, SchemaRef } from '../types.js'
3
3
  import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js'
4
4
  import type { TypesMap } from '../types-map.js'
5
5
  import {
@@ -9,7 +9,7 @@ import {
9
9
  import { extractFunctionNode } from '../utils/extract-function-node.js'
10
10
  import { extractUsedWires } from '../utils/extract-services.js'
11
11
  import type { FunctionServicesMeta } from '@pikku/core'
12
- import { formatVersionedId } from '@pikku/core'
12
+ import { formatVersionedId, parseVersionedId } from '@pikku/core'
13
13
  import {
14
14
  getPropertyValue,
15
15
  getCommonWireMetaData,
@@ -287,6 +287,31 @@ function unwrapPromise(checker: ts.TypeChecker, type: ts.Type): ts.Type {
287
287
  return type
288
288
  }
289
289
 
290
+ const resolveExistingFunctionSource = (
291
+ state: InspectorState,
292
+ pikkuFuncId: string
293
+ ): string | null => {
294
+ return (
295
+ state.functions.meta[pikkuFuncId]?.sourceFile ||
296
+ state.rpc.internalFiles.get(pikkuFuncId)?.path ||
297
+ null
298
+ )
299
+ }
300
+
301
+ const areCompatibleFunctionIds = (
302
+ existingId: string,
303
+ incomingId: string
304
+ ): boolean => {
305
+ if (existingId === incomingId) {
306
+ return true
307
+ }
308
+
309
+ const existingParsed = parseVersionedId(existingId)
310
+ const incomingParsed = parseVersionedId(incomingId)
311
+
312
+ return existingParsed.baseName === incomingParsed.baseName
313
+ }
314
+
290
315
  /**
291
316
  * Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
292
317
  * then push into state.functions.meta.
@@ -546,6 +571,7 @@ export const addFunctions: AddWiring = (
546
571
  }
547
572
 
548
573
  const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc'
574
+ const isListFunc = expression.text === 'pikkuListFunc'
549
575
  const mcpEnabled = mcp || isMCPToolFunc
550
576
 
551
577
  // Pick the handler: use resolvedFunc when it exists and is a function, otherwise fall back to handlerNode
@@ -618,7 +644,33 @@ export const addFunctions: AddWiring = (
618
644
  inputNames = [schemaName]
619
645
  state.schemaLookup.set(schemaName, inputSchemaRef)
620
646
  state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
621
- } else if (genericTypes.length >= 1 && genericTypes[0]) {
647
+ } else if (isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
648
+ const inputAliasName = `${capitalizedName}Input`
649
+ const filterType = genericTypes[0]
650
+ const filterTypeText = checker.typeToString(
651
+ filterType,
652
+ undefined,
653
+ ts.TypeFormatFlags.NoTruncation
654
+ )
655
+ const refs = resolveTypeImports(
656
+ filterType,
657
+ state.functions.typesMap,
658
+ true,
659
+ checker
660
+ )
661
+ state.functions.typesMap.addCustomType(
662
+ inputAliasName,
663
+ `{ cursor?: string; limit?: number; sort?: Array<{ column: string; direction: 'asc' | 'desc' }>; filter?: unknown; search?: string; } & ${filterTypeText}`,
664
+ [...new Set(refs)]
665
+ )
666
+ inputNames = [inputAliasName]
667
+ const secondParam = handler.parameters[1]
668
+ if (secondParam) {
669
+ inputTypes = [checker.getTypeAtLocation(secondParam)]
670
+ } else {
671
+ inputTypes = [filterType]
672
+ }
673
+ } else if (!isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
622
674
  // Fall back to extracting from generic type arguments
623
675
  const result = getNamesAndTypes(
624
676
  checker,
@@ -654,7 +706,27 @@ export const addFunctions: AddWiring = (
654
706
  outputNames = [schemaName]
655
707
  state.schemaLookup.set(schemaName, outputSchemaRef)
656
708
  state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
657
- } else if (genericTypes.length >= 2) {
709
+ } else if (isListFunc && genericTypes.length >= 2 && genericTypes[1]) {
710
+ const outputAliasName = `${capitalizedName}Output`
711
+ const rowType = genericTypes[1]
712
+ const rowTypeText = checker.typeToString(
713
+ rowType,
714
+ undefined,
715
+ ts.TypeFormatFlags.NoTruncation
716
+ )
717
+ const refs = resolveTypeImports(
718
+ rowType,
719
+ state.functions.typesMap,
720
+ true,
721
+ checker
722
+ )
723
+ state.functions.typesMap.addCustomType(
724
+ outputAliasName,
725
+ `{ rows: Array<${rowTypeText}>; nextCursor: string | null; totalCount?: number; }`,
726
+ [...new Set(refs)]
727
+ )
728
+ outputNames = [outputAliasName]
729
+ } else if (!isListFunc && genericTypes.length >= 2) {
658
730
  outputNames = getNamesAndTypes(
659
731
  checker,
660
732
  state.functions.typesMap,
@@ -731,6 +803,56 @@ export const addFunctions: AddWiring = (
731
803
  state.typesLookup.set(pikkuFuncId, inputTypes)
732
804
  }
733
805
 
806
+ const sourceFile = node.getSourceFile().fileName
807
+ const existingFunction = state.functions.meta[pikkuFuncId]
808
+ if (
809
+ existingFunction &&
810
+ existingFunction.sourceFile &&
811
+ existingFunction.sourceFile !== sourceFile
812
+ ) {
813
+ logger.critical(
814
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
815
+ `Function name '${name}' is not unique. ` +
816
+ `'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`
817
+ )
818
+ return
819
+ }
820
+
821
+ if (exportedName || explicitName) {
822
+ const existingInternal = state.rpc.internalMeta[name]
823
+ if (
824
+ existingInternal &&
825
+ !areCompatibleFunctionIds(existingInternal, pikkuFuncId)
826
+ ) {
827
+ const existingSource =
828
+ resolveExistingFunctionSource(state, existingInternal) || 'unknown file'
829
+ logger.critical(
830
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
831
+ `Function name '${name}' is not unique. ` +
832
+ `It already points to '${existingInternal}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
833
+ )
834
+ return
835
+ }
836
+
837
+ if (expose) {
838
+ const existingExposed = state.rpc.exposedMeta[name]
839
+ if (
840
+ existingExposed &&
841
+ !areCompatibleFunctionIds(existingExposed, pikkuFuncId)
842
+ ) {
843
+ const existingSource =
844
+ resolveExistingFunctionSource(state, existingExposed) ||
845
+ 'unknown file'
846
+ logger.critical(
847
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
848
+ `Exposed function name '${name}' is not unique. ` +
849
+ `It already points to '${existingExposed}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
850
+ )
851
+ return
852
+ }
853
+ }
854
+ }
855
+
734
856
  // --- resolve middleware ---
735
857
  let middleware = objectNode
736
858
  ? resolveMiddleware(state, objectNode, tags, checker)
@@ -801,7 +923,7 @@ export const addFunctions: AddWiring = (
801
923
  middleware,
802
924
  permissions,
803
925
  isDirectFunction,
804
- sourceFile: node.getSourceFile().fileName,
926
+ sourceFile,
805
927
  exportedName: exportedName || undefined,
806
928
  }
807
929