@pikku/inspector 0.12.10 → 0.12.12

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 (63) hide show
  1. package/CHANGELOG.md +21 -9
  2. package/dist/add/add-cli.js +10 -3
  3. package/dist/add/add-credential.js +2 -1
  4. package/dist/add/add-functions.js +99 -5
  5. package/dist/add/add-http-route.js +44 -6
  6. package/dist/add/add-keyed-wiring.js +3 -1
  7. package/dist/add/add-middleware.js +33 -4
  8. package/dist/add/add-permission.js +7 -7
  9. package/dist/add/add-workflow-graph.js +20 -1
  10. package/dist/error-codes.d.ts +2 -0
  11. package/dist/error-codes.js +2 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +1 -0
  14. package/dist/inspector.js +2 -5
  15. package/dist/types.d.ts +10 -19
  16. package/dist/utils/extract-function-name.js +6 -0
  17. package/dist/utils/filter-inspector-state.js +187 -59
  18. package/dist/utils/filter-utils.js +13 -5
  19. package/dist/utils/get-property-value.d.ts +10 -0
  20. package/dist/utils/get-property-value.js +30 -0
  21. package/dist/utils/post-process.d.ts +2 -3
  22. package/dist/utils/post-process.js +3 -23
  23. package/dist/utils/resolve-addon-package.d.ts +4 -5
  24. package/dist/utils/resolve-addon-package.js +64 -16
  25. package/dist/utils/resolve-deploy-target.d.ts +28 -0
  26. package/dist/utils/resolve-deploy-target.js +56 -0
  27. package/dist/utils/resolve-versions.js +79 -0
  28. package/dist/utils/schema-generator.js +31 -12
  29. package/dist/utils/validate-auth-sessionless.d.ts +1 -1
  30. package/package.json +2 -2
  31. package/src/add/add-cli.ts +10 -3
  32. package/src/add/add-credential.ts +3 -0
  33. package/src/add/add-functions.test.ts +318 -0
  34. package/src/add/add-functions.ts +164 -6
  35. package/src/add/add-gateway.ts +5 -1
  36. package/src/add/add-http-route.ts +54 -7
  37. package/src/add/add-keyed-wiring.ts +7 -1
  38. package/src/add/add-mcp-prompt.ts +5 -1
  39. package/src/add/add-mcp-resource.ts +5 -1
  40. package/src/add/add-middleware.ts +42 -4
  41. package/src/add/add-permission.ts +7 -7
  42. package/src/add/add-schedule.ts +5 -1
  43. package/src/add/add-workflow-graph.ts +19 -1
  44. package/src/add/wire-name-literal.test.ts +114 -0
  45. package/src/error-codes.ts +2 -0
  46. package/src/index.ts +1 -0
  47. package/src/inspector.ts +1 -5
  48. package/src/types.ts +19 -15
  49. package/src/utils/extract-function-name.ts +8 -0
  50. package/src/utils/filter-inspector-state.test.ts +168 -64
  51. package/src/utils/filter-inspector-state.ts +290 -64
  52. package/src/utils/filter-utils.test.ts +30 -15
  53. package/src/utils/filter-utils.ts +14 -5
  54. package/src/utils/get-property-value.ts +40 -0
  55. package/src/utils/post-process.ts +3 -38
  56. package/src/utils/resolve-addon-package.ts +65 -14
  57. package/src/utils/resolve-deploy-target.test.ts +105 -0
  58. package/src/utils/resolve-deploy-target.ts +63 -0
  59. package/src/utils/resolve-versions.test.ts +108 -0
  60. package/src/utils/resolve-versions.ts +86 -0
  61. package/src/utils/schema-generator.ts +37 -13
  62. package/src/utils/validate-auth-sessionless.ts +1 -1
  63. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,318 @@
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('strips VN suffix so createUserV1 + version:1 groups with createUser as createUser@v1', async () => {
115
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-vn-suffix-'))
116
+ const fileV1 = join(rootDir, 'create-user-v1.ts')
117
+ const fileLatest = join(rootDir, 'create-user.ts')
118
+
119
+ await writeFile(
120
+ fileV1,
121
+ [
122
+ "import { pikkuFunc } from '@pikku/core'",
123
+ 'export const createUserV1 = pikkuFunc({',
124
+ ' version: 1,',
125
+ ' func: async () => ({ ok: true })',
126
+ '})',
127
+ ].join('\n')
128
+ )
129
+
130
+ await writeFile(
131
+ fileLatest,
132
+ [
133
+ "import { pikkuFunc } from '@pikku/core'",
134
+ 'export const createUser = pikkuFunc({',
135
+ ' func: async () => ({ ok: true })',
136
+ '})',
137
+ ].join('\n')
138
+ )
139
+
140
+ const logger: InspectorLogger = {
141
+ debug: () => {},
142
+ info: () => {},
143
+ warn: () => {},
144
+ error: () => {},
145
+ critical: () => {},
146
+ hasCriticalErrors: () => false,
147
+ }
148
+
149
+ try {
150
+ const state = await inspect(logger, [fileV1, fileLatest], { rootDir })
151
+ // V1 suffix stripped: createUserV1 + version:1 → createUser@v1
152
+ assert.ok(
153
+ state.functions.meta['createUser@v1'],
154
+ 'createUser@v1 should exist'
155
+ )
156
+ assert.strictEqual(state.functions.meta['createUser@v1']!.version, 1)
157
+ // Unversioned createUser auto-promoted to createUser@v2
158
+ assert.ok(
159
+ state.functions.meta['createUser@v2'],
160
+ 'createUser@v2 should exist'
161
+ )
162
+ assert.strictEqual(state.functions.meta['createUser@v2']!.version, 2)
163
+ // No stale createUserV1@v1 entry
164
+ assert.strictEqual(state.functions.meta['createUserV1@v1'], undefined)
165
+ // Latest alias points to v2
166
+ assert.strictEqual(state.rpc.internalMeta['createUser'], 'createUser@v2')
167
+ } finally {
168
+ await rm(rootDir, { recursive: true, force: true })
169
+ }
170
+ })
171
+
172
+ test('logs a critical error when exposed function name is duplicated across files', async () => {
173
+ const rootDir = await mkdtemp(
174
+ join(tmpdir(), 'pikku-exposed-duplicate-function-')
175
+ )
176
+ const fileA = join(rootDir, 'a.ts')
177
+ const fileB = join(rootDir, 'b.ts')
178
+
179
+ await writeFile(
180
+ fileA,
181
+ [
182
+ "import { pikkuFunc } from '@pikku/core'",
183
+ 'export const createUser = pikkuFunc({',
184
+ ' expose: true,',
185
+ ' func: async () => ({ ok: true })',
186
+ '})',
187
+ ].join('\n')
188
+ )
189
+
190
+ await writeFile(
191
+ fileB,
192
+ [
193
+ "import { pikkuFunc } from '@pikku/core'",
194
+ 'export const createUser = pikkuFunc({',
195
+ ' expose: true,',
196
+ ' func: async () => ({ ok: true })',
197
+ '})',
198
+ ].join('\n')
199
+ )
200
+
201
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
202
+ const logger: InspectorLogger = {
203
+ debug: () => {},
204
+ info: () => {},
205
+ warn: () => {},
206
+ error: () => {},
207
+ critical: (code: ErrorCode, message: string) => {
208
+ criticals.push({ code, message })
209
+ },
210
+ hasCriticalErrors: () => criticals.length > 0,
211
+ }
212
+
213
+ try {
214
+ await inspect(logger, [fileA, fileB], { rootDir })
215
+ const nameCollision = criticals.find(
216
+ (entry) => entry.code === ErrorCode.DUPLICATE_FUNCTION_NAME
217
+ )
218
+ assert.ok(nameCollision)
219
+ assert.match(
220
+ nameCollision!.message,
221
+ /Function name 'createUser' is not unique/
222
+ )
223
+ } finally {
224
+ await rm(rootDir, { recursive: true, force: true })
225
+ }
226
+ })
227
+ })
228
+
229
+ describe('addFunctions implementationHash', () => {
230
+ test('records a stable implementation hash for an inline function', async () => {
231
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-function-hash-inline-'))
232
+ const file = join(rootDir, 'inline.ts')
233
+
234
+ await writeFile(
235
+ file,
236
+ [
237
+ "import { pikkuFunc } from '@pikku/core'",
238
+ 'export const createUser = pikkuFunc({',
239
+ ' expose: true,',
240
+ ' func: async ({ logger }, input: { name: string }) => {',
241
+ " logger.info('create user')",
242
+ ' return { ok: true, name: input.name }',
243
+ ' }',
244
+ '})',
245
+ ].join('\n')
246
+ )
247
+
248
+ const logger: InspectorLogger = {
249
+ debug: () => {},
250
+ info: () => {},
251
+ warn: () => {},
252
+ error: () => {},
253
+ critical: () => {},
254
+ hasCriticalErrors: () => false,
255
+ }
256
+
257
+ try {
258
+ const first = await inspect(logger, [file], { rootDir })
259
+ const second = await inspect(logger, [file], { rootDir })
260
+ const firstHash = first.functions.meta['createUser']?.implementationHash
261
+ const secondHash = second.functions.meta['createUser']?.implementationHash
262
+
263
+ assert.match(firstHash ?? '', /^[0-9a-f]{16}$/)
264
+ assert.strictEqual(firstHash, secondHash)
265
+ } finally {
266
+ await rm(rootDir, { recursive: true, force: true })
267
+ }
268
+ })
269
+
270
+ test('changes when a referenced handler implementation changes', async () => {
271
+ const rootDir = await mkdtemp(
272
+ join(tmpdir(), 'pikku-function-hash-referenced-')
273
+ )
274
+ const file = join(rootDir, 'referenced.ts')
275
+
276
+ const writeSource = async (bodyLine: string) => {
277
+ await writeFile(
278
+ file,
279
+ [
280
+ "import { pikkuFunc } from '@pikku/core'",
281
+ '',
282
+ 'const handler = async () => {',
283
+ ` ${bodyLine}`,
284
+ ' return { ok: true }',
285
+ '}',
286
+ '',
287
+ 'export const createUser = pikkuFunc({',
288
+ ' func: handler,',
289
+ '})',
290
+ ].join('\n')
291
+ )
292
+ }
293
+
294
+ const logger: InspectorLogger = {
295
+ debug: () => {},
296
+ info: () => {},
297
+ warn: () => {},
298
+ error: () => {},
299
+ critical: () => {},
300
+ hasCriticalErrors: () => false,
301
+ }
302
+
303
+ try {
304
+ await writeSource("console.log('first')")
305
+ const first = await inspect(logger, [file], { rootDir })
306
+
307
+ await writeSource("console.log('second')")
308
+ const second = await inspect(logger, [file], { rootDir })
309
+
310
+ assert.notStrictEqual(
311
+ first.functions.meta['createUser']?.implementationHash,
312
+ second.functions.meta['createUser']?.implementationHash
313
+ )
314
+ } finally {
315
+ await rm(rootDir, { recursive: true, force: true })
316
+ }
317
+ })
318
+ })
@@ -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,11 +9,12 @@ 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,
16
16
  } from '../utils/get-property-value.js'
17
+ import { canonicalJSON, hashString } from '../utils/hash.js'
17
18
  import { resolveMiddleware } from '../utils/middleware.js'
18
19
  import { resolvePermissions } from '../utils/permissions.js'
19
20
  import { extractWireNames } from '../utils/post-process.js'
@@ -287,6 +288,54 @@ function unwrapPromise(checker: ts.TypeChecker, type: ts.Type): ts.Type {
287
288
  return type
288
289
  }
289
290
 
291
+ const resolveExistingFunctionSource = (
292
+ state: InspectorState,
293
+ pikkuFuncId: string
294
+ ): string | null => {
295
+ return (
296
+ state.functions.meta[pikkuFuncId]?.sourceFile ||
297
+ state.rpc.internalFiles.get(pikkuFuncId)?.path ||
298
+ null
299
+ )
300
+ }
301
+
302
+ const areCompatibleFunctionIds = (
303
+ existingId: string,
304
+ incomingId: string
305
+ ): boolean => {
306
+ if (existingId === incomingId) {
307
+ return true
308
+ }
309
+
310
+ const existingParsed = parseVersionedId(existingId)
311
+ const incomingParsed = parseVersionedId(incomingId)
312
+
313
+ return existingParsed.baseName === incomingParsed.baseName
314
+ }
315
+
316
+ function printNode(node: ts.Node): string {
317
+ return ts
318
+ .createPrinter({ removeComments: true })
319
+ .printNode(ts.EmitHint.Unspecified, node, node.getSourceFile())
320
+ }
321
+
322
+ function computeImplementationHash(args: {
323
+ wrapper: string
324
+ handler: ts.ArrowFunction | ts.FunctionExpression
325
+ objectNode?: ts.ObjectLiteralExpression
326
+ isDirectFunction: boolean
327
+ }): string {
328
+ const { wrapper, handler, objectNode, isDirectFunction } = args
329
+ return hashString(
330
+ canonicalJSON({
331
+ wrapper,
332
+ isDirectFunction,
333
+ handler: printNode(handler),
334
+ config: objectNode ? printNode(objectNode) : null,
335
+ })
336
+ )
337
+ }
338
+
290
339
  /**
291
340
  * Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
292
341
  * then push into state.functions.meta.
@@ -541,11 +590,17 @@ export const addFunctions: AddWiring = (
541
590
  }
542
591
 
543
592
  if (version !== undefined) {
544
- const baseName = explicitName || exportedName || pikkuFuncId
593
+ let baseName = explicitName || exportedName || pikkuFuncId
594
+ // Strip trailing VN suffix if it matches the version (e.g. getDataV1 + version:1 → getData@v1)
595
+ const vSuffix = `V${version}`
596
+ if (baseName.endsWith(vSuffix) && baseName.length > vSuffix.length) {
597
+ baseName = baseName.slice(0, -vSuffix.length)
598
+ }
545
599
  pikkuFuncId = formatVersionedId(baseName, version)
546
600
  }
547
601
 
548
602
  const isMCPToolFunc = expression.text === 'pikkuMCPToolFunc'
603
+ const isListFunc = expression.text === 'pikkuListFunc'
549
604
  const mcpEnabled = mcp || isMCPToolFunc
550
605
 
551
606
  // Pick the handler: use resolvedFunc when it exists and is a function, otherwise fall back to handlerNode
@@ -618,7 +673,33 @@ export const addFunctions: AddWiring = (
618
673
  inputNames = [schemaName]
619
674
  state.schemaLookup.set(schemaName, inputSchemaRef)
620
675
  state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
621
- } else if (genericTypes.length >= 1 && genericTypes[0]) {
676
+ } else if (isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
677
+ const inputAliasName = `${capitalizedName}Input`
678
+ const filterType = genericTypes[0]
679
+ const filterTypeText = checker.typeToString(
680
+ filterType,
681
+ undefined,
682
+ ts.TypeFormatFlags.NoTruncation
683
+ )
684
+ const refs = resolveTypeImports(
685
+ filterType,
686
+ state.functions.typesMap,
687
+ true,
688
+ checker
689
+ )
690
+ state.functions.typesMap.addCustomType(
691
+ inputAliasName,
692
+ `{ cursor?: string; limit?: number; sort?: Array<{ column: string; direction: 'asc' | 'desc' }>; filter?: unknown; search?: string; } & ${filterTypeText}`,
693
+ [...new Set(refs)]
694
+ )
695
+ inputNames = [inputAliasName]
696
+ const secondParam = handler.parameters[1]
697
+ if (secondParam) {
698
+ inputTypes = [checker.getTypeAtLocation(secondParam)]
699
+ } else {
700
+ inputTypes = [filterType]
701
+ }
702
+ } else if (!isListFunc && genericTypes.length >= 1 && genericTypes[0]) {
622
703
  // Fall back to extracting from generic type arguments
623
704
  const result = getNamesAndTypes(
624
705
  checker,
@@ -654,7 +735,27 @@ export const addFunctions: AddWiring = (
654
735
  outputNames = [schemaName]
655
736
  state.schemaLookup.set(schemaName, outputSchemaRef)
656
737
  state.functions.typesMap.addCustomType(schemaName, 'unknown', [])
657
- } else if (genericTypes.length >= 2) {
738
+ } else if (isListFunc && genericTypes.length >= 2 && genericTypes[1]) {
739
+ const outputAliasName = `${capitalizedName}Output`
740
+ const rowType = genericTypes[1]
741
+ const rowTypeText = checker.typeToString(
742
+ rowType,
743
+ undefined,
744
+ ts.TypeFormatFlags.NoTruncation
745
+ )
746
+ const refs = resolveTypeImports(
747
+ rowType,
748
+ state.functions.typesMap,
749
+ true,
750
+ checker
751
+ )
752
+ state.functions.typesMap.addCustomType(
753
+ outputAliasName,
754
+ `{ rows: Array<${rowTypeText}>; nextCursor: string | null; totalCount?: number; }`,
755
+ [...new Set(refs)]
756
+ )
757
+ outputNames = [outputAliasName]
758
+ } else if (!isListFunc && genericTypes.length >= 2) {
658
759
  outputNames = getNamesAndTypes(
659
760
  checker,
660
761
  state.functions.typesMap,
@@ -731,6 +832,56 @@ export const addFunctions: AddWiring = (
731
832
  state.typesLookup.set(pikkuFuncId, inputTypes)
732
833
  }
733
834
 
835
+ const sourceFile = node.getSourceFile().fileName
836
+ const existingFunction = state.functions.meta[pikkuFuncId]
837
+ if (
838
+ existingFunction &&
839
+ existingFunction.sourceFile &&
840
+ existingFunction.sourceFile !== sourceFile
841
+ ) {
842
+ logger.critical(
843
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
844
+ `Function name '${name}' is not unique. ` +
845
+ `'${pikkuFuncId}' is already defined in '${existingFunction.sourceFile}' and cannot be redefined in '${sourceFile}'.`
846
+ )
847
+ return
848
+ }
849
+
850
+ if (exportedName || explicitName) {
851
+ const existingInternal = state.rpc.internalMeta[name]
852
+ if (
853
+ existingInternal &&
854
+ !areCompatibleFunctionIds(existingInternal, pikkuFuncId)
855
+ ) {
856
+ const existingSource =
857
+ resolveExistingFunctionSource(state, existingInternal) || 'unknown file'
858
+ logger.critical(
859
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
860
+ `Function name '${name}' is not unique. ` +
861
+ `It already points to '${existingInternal}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
862
+ )
863
+ return
864
+ }
865
+
866
+ if (expose) {
867
+ const existingExposed = state.rpc.exposedMeta[name]
868
+ if (
869
+ existingExposed &&
870
+ !areCompatibleFunctionIds(existingExposed, pikkuFuncId)
871
+ ) {
872
+ const existingSource =
873
+ resolveExistingFunctionSource(state, existingExposed) ||
874
+ 'unknown file'
875
+ logger.critical(
876
+ ErrorCode.DUPLICATE_FUNCTION_NAME,
877
+ `Exposed function name '${name}' is not unique. ` +
878
+ `It already points to '${existingExposed}' in '${existingSource}', but '${pikkuFuncId}' in '${sourceFile}' tried to use the same name.`
879
+ )
880
+ return
881
+ }
882
+ }
883
+ }
884
+
734
885
  // --- resolve middleware ---
735
886
  let middleware = objectNode
736
887
  ? resolveMiddleware(state, objectNode, tags, checker)
@@ -772,6 +923,12 @@ export const addFunctions: AddWiring = (
772
923
  }
773
924
 
774
925
  const sessionless = expression.text !== 'pikkuFunc'
926
+ const implementationHash = computeImplementationHash({
927
+ wrapper: expression.text,
928
+ handler,
929
+ objectNode,
930
+ isDirectFunction,
931
+ })
775
932
 
776
933
  state.functions.meta[pikkuFuncId] = {
777
934
  pikkuFuncId,
@@ -792,6 +949,7 @@ export const addFunctions: AddWiring = (
792
949
  deploy: deploy || undefined,
793
950
  approvalRequired: approvalRequired || undefined,
794
951
  approvalDescription: approvalDescription || undefined,
952
+ implementationHash,
795
953
  version,
796
954
  title,
797
955
  tags: tags || undefined,
@@ -801,7 +959,7 @@ export const addFunctions: AddWiring = (
801
959
  middleware,
802
960
  permissions,
803
961
  isDirectFunction,
804
- sourceFile: node.getSourceFile().fileName,
962
+ sourceFile,
805
963
  exportedName: exportedName || undefined,
806
964
  }
807
965
 
@@ -70,7 +70,11 @@ export const addGateway: AddWiring = (
70
70
  }
71
71
 
72
72
  const packageName = ts.isIdentifier(funcInitializer)
73
- ? resolveAddonName(funcInitializer, checker, state.rpc.wireAddonDeclarations)
73
+ ? resolveAddonName(
74
+ funcInitializer,
75
+ checker,
76
+ state.rpc.wireAddonDeclarations
77
+ )
74
78
  : null
75
79
 
76
80
  if (!nameValue || !typeValue) {
@@ -18,6 +18,7 @@ import { resolveHTTPMiddlewareFromObject } from '../utils/middleware.js'
18
18
  import { resolveHTTPPermissionsFromObject } from '../utils/permissions.js'
19
19
  import { extractWireNames } from '../utils/post-process.js'
20
20
  import { ensureFunctionMetadata } from '../utils/ensure-function-metadata.js'
21
+ import { resolveFunctionMeta } from '../utils/resolve-function-meta.js'
21
22
  import { ErrorCode } from '../error-codes.js'
22
23
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js'
23
24
  import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js'
@@ -204,6 +205,22 @@ export function registerHTTPRoute({
204
205
  funcName = makeContextBasedId('http', method, fullRoute)
205
206
  }
206
207
 
208
+ let refAddonTarget: string | null = null
209
+ if (
210
+ ts.isCallExpression(funcInitializer) &&
211
+ ts.isIdentifier(funcInitializer.expression) &&
212
+ funcInitializer.expression.text === 'ref'
213
+ ) {
214
+ const [firstArg] = funcInitializer.arguments
215
+ if (
216
+ firstArg &&
217
+ ts.isStringLiteral(firstArg) &&
218
+ firstArg.text.includes(':')
219
+ ) {
220
+ refAddonTarget = firstArg.text
221
+ }
222
+ }
223
+
207
224
  const packageName = ts.isIdentifier(funcInitializer)
208
225
  ? resolveAddonName(
209
226
  funcInitializer,
@@ -212,6 +229,16 @@ export function registerHTTPRoute({
212
229
  )
213
230
  : null
214
231
 
232
+ if (refAddonTarget) {
233
+ const targetMeta = resolveFunctionMeta(state, refAddonTarget)
234
+ if (!targetMeta) {
235
+ logger.warn(
236
+ `Skipping route '${fullRoute}': addon function metadata for '${refAddonTarget}' is not available yet.`
237
+ )
238
+ return
239
+ }
240
+ }
241
+
215
242
  ensureFunctionMetadata(
216
243
  state,
217
244
  funcName,
@@ -222,7 +249,7 @@ export function registerHTTPRoute({
222
249
  )
223
250
 
224
251
  // Lookup existing function metadata
225
- const fnMeta = state.functions.meta[funcName]
252
+ const fnMeta = resolveFunctionMeta(state, funcName)
226
253
  if (!fnMeta) {
227
254
  logger.critical(
228
255
  ErrorCode.FUNCTION_METADATA_NOT_FOUND,
@@ -246,12 +273,32 @@ export function registerHTTPRoute({
246
273
 
247
274
  const input = fnMeta.inputs?.[0] || null
248
275
 
249
- // Validate that route params and query params exist in function input type
250
- if (params.length > 0 || query.length > 0) {
251
- const inputTypes = state.typesLookup.get(funcName)
276
+ const getRouteInputKeys = (): string[] | null => {
277
+ const targetFuncName = refAddonTarget ?? funcName
278
+ const inputTypes = state.typesLookup.get(targetFuncName)
252
279
  if (inputTypes && inputTypes.length > 0) {
253
- const inputKeys = extractTypeKeys(inputTypes[0])
280
+ return extractTypeKeys(inputTypes[0])
281
+ }
282
+
283
+ const targetMeta = resolveFunctionMeta(state, targetFuncName)
284
+ if (targetMeta?.inputSchemaName) {
285
+ const schema = state.schemas[targetMeta.inputSchemaName] as any
286
+ const properties = schema?.properties
287
+ if (properties && typeof properties === 'object') {
288
+ return Object.keys(properties)
289
+ }
290
+ }
254
291
 
292
+ return null
293
+ }
294
+
295
+ // Validate that route params and query params exist in function input type
296
+ if (params.length > 0 || query.length > 0) {
297
+ const inputKeys = getRouteInputKeys()
298
+ if (!inputKeys) {
299
+ // Input shape isn't inspectable at this phase (e.g. addon ref or opaque handler).
300
+ // Skip param/query validation rather than emitting a false positive.
301
+ } else {
255
302
  // Check path params
256
303
  if (params.length > 0) {
257
304
  const missingParams = params.filter((p) => !inputKeys.includes(p))
@@ -259,7 +306,7 @@ export function registerHTTPRoute({
259
306
  logger.critical(
260
307
  ErrorCode.ROUTE_PARAM_MISMATCH,
261
308
  `Route '${fullRoute}' has path parameter(s) [${missingParams.join(', ')}] ` +
262
- `not found in function '${funcName}' input type. ` +
309
+ `not found in function '${refAddonTarget ?? funcName}' input type. ` +
263
310
  `Input type has: [${inputKeys.join(', ')}]`
264
311
  )
265
312
  return
@@ -273,7 +320,7 @@ export function registerHTTPRoute({
273
320
  logger.critical(
274
321
  ErrorCode.ROUTE_QUERY_MISMATCH,
275
322
  `Route '${fullRoute}' has query parameter(s) [${missingQuery.join(', ')}] ` +
276
- `not found in function '${funcName}' input type. ` +
323
+ `not found in function '${refAddonTarget ?? funcName}' input type. ` +
277
324
  `Input type has: [${inputKeys.join(', ')}]`
278
325
  )
279
326
  return