@pikku/inspector 0.12.11 → 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 (61) hide show
  1. package/CHANGELOG.md +13 -0
  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 +28 -1
  5. package/dist/add/add-http-route.js +24 -5
  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 +1 -0
  11. package/dist/error-codes.js +1 -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/package.json +2 -2
  30. package/src/add/add-cli.ts +10 -3
  31. package/src/add/add-credential.ts +3 -0
  32. package/src/add/add-functions.test.ts +149 -0
  33. package/src/add/add-functions.ts +37 -1
  34. package/src/add/add-gateway.ts +5 -1
  35. package/src/add/add-http-route.ts +26 -6
  36. package/src/add/add-keyed-wiring.ts +7 -1
  37. package/src/add/add-mcp-prompt.ts +5 -1
  38. package/src/add/add-mcp-resource.ts +5 -1
  39. package/src/add/add-middleware.ts +42 -4
  40. package/src/add/add-permission.ts +7 -7
  41. package/src/add/add-schedule.ts +5 -1
  42. package/src/add/add-workflow-graph.ts +19 -1
  43. package/src/add/wire-name-literal.test.ts +114 -0
  44. package/src/error-codes.ts +1 -0
  45. package/src/index.ts +1 -0
  46. package/src/inspector.ts +1 -5
  47. package/src/types.ts +19 -15
  48. package/src/utils/extract-function-name.ts +8 -0
  49. package/src/utils/filter-inspector-state.test.ts +168 -64
  50. package/src/utils/filter-inspector-state.ts +290 -64
  51. package/src/utils/filter-utils.test.ts +30 -15
  52. package/src/utils/filter-utils.ts +14 -5
  53. package/src/utils/get-property-value.ts +40 -0
  54. package/src/utils/post-process.ts +3 -38
  55. package/src/utils/resolve-addon-package.ts +65 -14
  56. package/src/utils/resolve-deploy-target.test.ts +105 -0
  57. package/src/utils/resolve-deploy-target.ts +63 -0
  58. package/src/utils/resolve-versions.test.ts +108 -0
  59. package/src/utils/resolve-versions.ts +86 -0
  60. package/src/utils/schema-generator.ts +37 -13
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -1,12 +1,42 @@
1
1
  import * as ts from 'typescript';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { dirname, join, parse } from 'path';
4
+ const packageNameCache = new Map();
5
+ const findPackageNameForFile = (filePath) => {
6
+ if (packageNameCache.has(filePath)) {
7
+ return packageNameCache.get(filePath);
8
+ }
9
+ const root = parse(filePath).root;
10
+ let dir = dirname(filePath);
11
+ while (dir && dir !== root) {
12
+ const pkgPath = join(dir, 'package.json');
13
+ if (existsSync(pkgPath)) {
14
+ try {
15
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
16
+ const name = typeof pkg.name === 'string' ? pkg.name : null;
17
+ packageNameCache.set(filePath, name);
18
+ return name;
19
+ }
20
+ catch {
21
+ packageNameCache.set(filePath, null);
22
+ return null;
23
+ }
24
+ }
25
+ const parent = dirname(dir);
26
+ if (parent === dir)
27
+ break;
28
+ dir = parent;
29
+ }
30
+ packageNameCache.set(filePath, null);
31
+ return null;
32
+ };
2
33
  /**
3
34
  * Resolve the addon package name from an imported identifier.
4
35
  * Checks if the identifier's import module specifier matches any
5
- * configured addon package.
6
- *
7
- * This is a general utility any wire handler that processes a `func`
8
- * property can use it to detect when the function comes from an
9
- * addon package.
36
+ * configured addon package — and if the import is relative (because
37
+ * the identifier resolves to a source file inside the addon package
38
+ * itself), walks up to the nearest package.json to obtain the real
39
+ * package name.
10
40
  */
11
41
  export const resolveAddonName = (identifier, checker, wireAddonDeclarations) => {
12
42
  if (!wireAddonDeclarations || wireAddonDeclarations.size === 0) {
@@ -16,18 +46,36 @@ export const resolveAddonName = (identifier, checker, wireAddonDeclarations) =>
16
46
  if (!sym)
17
47
  return null;
18
48
  const decl = sym.declarations?.[0];
19
- if (!decl || !ts.isImportSpecifier(decl))
20
- return null;
21
- // ImportSpecifier -> NamedImports -> ImportClause -> ImportDeclaration
22
- const importDecl = decl.parent?.parent?.parent;
23
- if (!importDecl || !ts.isImportDeclaration(importDecl))
49
+ if (!decl)
24
50
  return null;
25
- if (!ts.isStringLiteral(importDecl.moduleSpecifier))
26
- return null;
27
- const moduleSpecifier = importDecl.moduleSpecifier.text;
28
- for (const addonDecl of wireAddonDeclarations.values()) {
29
- if (addonDecl.package === moduleSpecifier) {
30
- return addonDecl.package;
51
+ let candidatePackage = null;
52
+ if (ts.isImportSpecifier(decl)) {
53
+ // ImportSpecifier -> NamedImports -> ImportClause -> ImportDeclaration
54
+ const importDecl = decl.parent?.parent?.parent;
55
+ if (importDecl &&
56
+ ts.isImportDeclaration(importDecl) &&
57
+ ts.isStringLiteral(importDecl.moduleSpecifier)) {
58
+ candidatePackage = importDecl.moduleSpecifier.text;
59
+ }
60
+ }
61
+ // Bare package import path
62
+ if (candidatePackage && !candidatePackage.startsWith('.')) {
63
+ for (const addonDecl of wireAddonDeclarations.values()) {
64
+ if (addonDecl.package === candidatePackage)
65
+ return addonDecl.package;
66
+ }
67
+ }
68
+ // Fall back to package.json lookup based on the declaration's source file.
69
+ // This catches the case where the identifier resolves into an addon
70
+ // package's own internal source (relative import inside that package).
71
+ const declFile = decl.getSourceFile()?.fileName;
72
+ if (declFile) {
73
+ const pkgName = findPackageNameForFile(declFile);
74
+ if (pkgName) {
75
+ for (const addonDecl of wireAddonDeclarations.values()) {
76
+ if (addonDecl.package === pkgName)
77
+ return addonDecl.package;
78
+ }
31
79
  }
32
80
  }
33
81
  return null;
@@ -0,0 +1,28 @@
1
+ import type { FunctionMeta } from '@pikku/core';
2
+ /**
3
+ * Thrown when a function's explicit `deploy: 'serverless'` conflicts
4
+ * with one of its services being declared `serverlessIncompatible`.
5
+ * The user has to either remove the explicit flag (let it auto-resolve
6
+ * to 'server'), or set `deploy: 'server'` explicitly.
7
+ */
8
+ export declare class IncompatibleDeployTargetError extends Error {
9
+ readonly functionName: string;
10
+ readonly incompatibleServices: string[];
11
+ constructor(functionName: string, incompatibleServices: string[]);
12
+ }
13
+ /**
14
+ * Determine the effective deploy target for a function.
15
+ *
16
+ * Resolution order:
17
+ * 1. If any of the function's services is in `serverlessIncompatible`:
18
+ * - throw if the function explicitly declares `deploy: 'serverless'`
19
+ * - otherwise target is 'server'
20
+ * 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
21
+ * 3. Default 'serverless'
22
+ *
23
+ * Used both by the per-unit deploy analyzer (when bucketing functions
24
+ * into deployment units) and by `filterInspectorState` (when
25
+ * `pikku all --deploy <target>` is used to emit a target-scoped set
26
+ * of gen files).
27
+ */
28
+ export declare function resolveDeployTarget(funcMeta: Pick<FunctionMeta, 'deploy' | 'services'>, serverlessIncompatible: Set<string>, functionName?: string): 'serverless' | 'server';
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Thrown when a function's explicit `deploy: 'serverless'` conflicts
3
+ * with one of its services being declared `serverlessIncompatible`.
4
+ * The user has to either remove the explicit flag (let it auto-resolve
5
+ * to 'server'), or set `deploy: 'server'` explicitly.
6
+ */
7
+ export class IncompatibleDeployTargetError extends Error {
8
+ functionName;
9
+ incompatibleServices;
10
+ constructor(functionName, incompatibleServices) {
11
+ super(`Function '${functionName}' is declared deploy: 'serverless' but uses ` +
12
+ `serverless-incompatible service(s) [${incompatibleServices.join(', ')}]. ` +
13
+ `Either remove deploy: 'serverless' (will auto-resolve to 'server'), ` +
14
+ `or set deploy: 'server' explicitly.`);
15
+ this.functionName = functionName;
16
+ this.incompatibleServices = incompatibleServices;
17
+ this.name = 'IncompatibleDeployTargetError';
18
+ }
19
+ }
20
+ /**
21
+ * Determine the effective deploy target for a function.
22
+ *
23
+ * Resolution order:
24
+ * 1. If any of the function's services is in `serverlessIncompatible`:
25
+ * - throw if the function explicitly declares `deploy: 'serverless'`
26
+ * - otherwise target is 'server'
27
+ * 2. Explicit `funcMeta.deploy: 'serverless' | 'server'`
28
+ * 3. Default 'serverless'
29
+ *
30
+ * Used both by the per-unit deploy analyzer (when bucketing functions
31
+ * into deployment units) and by `filterInspectorState` (when
32
+ * `pikku all --deploy <target>` is used to emit a target-scoped set
33
+ * of gen files).
34
+ */
35
+ export function resolveDeployTarget(funcMeta, serverlessIncompatible, functionName = '<unknown>') {
36
+ // Service compatibility wins over the explicit flag — a serverless
37
+ // bundle of a function that needs (e.g.) node:fs would crash at runtime.
38
+ const incompatibleHits = [];
39
+ if (funcMeta.services?.services) {
40
+ for (const svc of funcMeta.services.services) {
41
+ if (serverlessIncompatible.has(svc))
42
+ incompatibleHits.push(svc);
43
+ }
44
+ }
45
+ if (incompatibleHits.length > 0) {
46
+ if (funcMeta.deploy === 'serverless') {
47
+ throw new IncompatibleDeployTargetError(functionName, incompatibleHits);
48
+ }
49
+ return 'server';
50
+ }
51
+ if (funcMeta.deploy === 'server')
52
+ return 'server';
53
+ if (funcMeta.deploy === 'serverless')
54
+ return 'serverless';
55
+ return 'serverless';
56
+ }
@@ -96,6 +96,7 @@ export function resolveLatestVersions(state, logger) {
96
96
  }
97
97
  }
98
98
  function updateWiringReferences(state, oldId, newId) {
99
+ // HTTP routes
99
100
  if (state.http) {
100
101
  for (const methods of Object.values(state.http.meta)) {
101
102
  for (const meta of Object.values(methods)) {
@@ -105,4 +106,82 @@ function updateWiringReferences(state, oldId, newId) {
105
106
  }
106
107
  }
107
108
  }
109
+ // Channels: connect/disconnect/message slots + action-routed message wirings
110
+ if (state.channels) {
111
+ for (const channel of Object.values(state.channels.meta)) {
112
+ for (const slot of [
113
+ channel.connect,
114
+ channel.disconnect,
115
+ channel.message,
116
+ ]) {
117
+ if (slot && slot.pikkuFuncId === oldId) {
118
+ slot.pikkuFuncId = newId;
119
+ }
120
+ }
121
+ for (const routes of Object.values(channel.messageWirings)) {
122
+ for (const message of Object.values(routes)) {
123
+ if (message.pikkuFuncId === oldId) {
124
+ message.pikkuFuncId = newId;
125
+ }
126
+ }
127
+ }
128
+ }
129
+ }
130
+ // CLI programs: commands and nested subcommands. This also covers
131
+ // CLI-over-channel generation, which reads command funcIds from this meta.
132
+ if (state.cli) {
133
+ const updateCommands = (commands) => {
134
+ for (const command of Object.values(commands)) {
135
+ if (command.pikkuFuncId === oldId) {
136
+ command.pikkuFuncId = newId;
137
+ }
138
+ if (command.subcommands) {
139
+ updateCommands(command.subcommands);
140
+ }
141
+ }
142
+ };
143
+ for (const program of Object.values(state.cli.meta.programs)) {
144
+ updateCommands(program.commands);
145
+ }
146
+ }
147
+ // Scheduled tasks
148
+ if (state.scheduledTasks) {
149
+ for (const task of Object.values(state.scheduledTasks.meta)) {
150
+ if (task.pikkuFuncId === oldId) {
151
+ task.pikkuFuncId = newId;
152
+ }
153
+ }
154
+ }
155
+ // Queue workers
156
+ if (state.queueWorkers) {
157
+ for (const worker of Object.values(state.queueWorkers.meta)) {
158
+ if (worker.pikkuFuncId === oldId) {
159
+ worker.pikkuFuncId = newId;
160
+ }
161
+ }
162
+ }
163
+ // Trigger sources (TriggerSourceMeta carries the handler's pikkuFuncId).
164
+ // Gateways/workflows/agents reference functions by bare rpc name and are
165
+ // resolved at runtime via state.rpc.internalMeta, so they need no rewrite here.
166
+ if (state.triggers) {
167
+ for (const source of Object.values(state.triggers.sourceMeta)) {
168
+ if (source.pikkuFuncId === oldId) {
169
+ source.pikkuFuncId = newId;
170
+ }
171
+ }
172
+ }
173
+ // MCP resources, tools, and prompts
174
+ if (state.mcpEndpoints) {
175
+ for (const collection of [
176
+ state.mcpEndpoints.resourcesMeta,
177
+ state.mcpEndpoints.toolsMeta,
178
+ state.mcpEndpoints.promptsMeta,
179
+ ]) {
180
+ for (const endpoint of Object.values(collection)) {
181
+ if (endpoint.pikkuFuncId === oldId) {
182
+ endpoint.pikkuFuncId = newId;
183
+ }
184
+ }
185
+ }
186
+ }
108
187
  }
@@ -1,7 +1,7 @@
1
1
  import * as ts from 'typescript';
2
2
  import { dirname, join, resolve } from 'path';
3
3
  import { createGenerator, RootlessError } from 'ts-json-schema-generator';
4
- import { register, tsImport } from 'tsx/esm/api';
4
+ import { register } from 'tsx/esm/api';
5
5
  import * as z from 'zod';
6
6
  import { zodToTs, createAuxiliaryTypeStore } from 'zod-to-ts';
7
7
  import { ErrorCode } from '../error-codes.js';
@@ -212,7 +212,16 @@ async function batchImportWithRegister(logger, sourceFiles) {
212
212
  return null;
213
213
  }
214
214
  finally {
215
- unregister?.();
215
+ await unregister?.();
216
+ }
217
+ }
218
+ async function importWithRegister(sourceFile) {
219
+ const unregister = register();
220
+ try {
221
+ return await import(sourceFile);
222
+ }
223
+ finally {
224
+ await unregister();
216
225
  }
217
226
  }
218
227
  function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger) {
@@ -243,6 +252,7 @@ function processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTyp
243
252
  }
244
253
  async function generateZodSchemas(logger, schemaLookup, typesMap) {
245
254
  const schemas = {};
255
+ const errors = [];
246
256
  const auxiliaryTypeStore = createAuxiliaryTypeStore();
247
257
  const printer = ts.createPrinter();
248
258
  const fakeSourceFile = ts.createSourceFile('zod-types.ts', '', ts.ScriptTarget.ESNext, false, ts.ScriptKind.TS);
@@ -258,10 +268,10 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
258
268
  const uniqueSourceFiles = [
259
269
  ...new Set([...schemaLookup.values()].map((ref) => ref.sourceFile)),
260
270
  ];
261
- logger.info(`[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`);
271
+ logger.debug(`[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`);
262
272
  const importStart = performance.now();
263
273
  const importedModules = await batchImportWithRegister(logger, uniqueSourceFiles);
264
- logger.info(`[TIMING] Batch import: ${(performance.now() - importStart).toFixed(0)}ms`);
274
+ logger.debug(`[TIMING] Batch import: ${(performance.now() - importStart).toFixed(0)}ms`);
265
275
  const processStart = performance.now();
266
276
  // Track schemas that need per-file tsImport fallback
267
277
  const fallbackSchemas = [];
@@ -270,39 +280,48 @@ async function generateZodSchemas(logger, schemaLookup, typesMap) {
270
280
  if (mod) {
271
281
  const zodSchema = mod[ref.variableName];
272
282
  if (!zodSchema) {
273
- logger.warn(`Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`);
283
+ errors.push(`Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`);
274
284
  continue;
275
285
  }
276
286
  try {
277
287
  processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger);
278
288
  }
279
289
  catch (e) {
280
- logger.warn(`Could not convert Zod schema '${schemaName}': ${e instanceof Error ? e.message : e}`);
290
+ errors.push(`Could not convert Zod schema '${schemaName}': ${e instanceof Error ? e.message : e}`);
281
291
  }
282
292
  }
283
293
  else {
284
294
  fallbackSchemas.push([schemaName, ref]);
285
295
  }
286
296
  }
287
- // Fallback: use tsImport for any schemas that batch import couldn't handle
297
+ // Fallback: use a scoped tsx register/import cycle for any schemas that
298
+ // batch import couldn't handle. Avoid tsImport() here because its ESM path
299
+ // can leave loader plumbing alive after failed imports, which prevents the
300
+ // CLI process from exiting on schema errors.
288
301
  if (fallbackSchemas.length > 0) {
289
- logger.debug(`Falling back to tsImport for ${fallbackSchemas.length} schema(s)`);
302
+ logger.debug(`Falling back to register() import for ${fallbackSchemas.length} schema(s)`);
290
303
  for (const [schemaName, ref] of fallbackSchemas) {
291
304
  try {
292
- const module = await tsImport(ref.sourceFile, import.meta.url);
305
+ const module = await importWithRegister(ref.sourceFile);
293
306
  const zodSchema = module[ref.variableName];
294
307
  if (!zodSchema) {
295
- logger.warn(`Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`);
308
+ errors.push(`Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`);
296
309
  continue;
297
310
  }
298
311
  processZodSchema(schemaName, zodSchema, schemas, typesMap, auxiliaryTypeStore, printer, fakeSourceFile, logger);
299
312
  }
300
313
  catch (e) {
301
- logger.warn(`Could not convert Zod schema '${schemaName}': ${e instanceof Error ? e.message : e}`);
314
+ errors.push(`Could not convert Zod schema '${schemaName}': ${e instanceof Error ? e.message : e}`);
302
315
  }
303
316
  }
304
317
  }
305
- logger.info(`[TIMING] Process schemas: ${(performance.now() - processStart).toFixed(0)}ms (${Object.keys(schemas).length} generated)`);
318
+ if (errors.length > 0) {
319
+ for (const message of errors) {
320
+ logger.error(message);
321
+ }
322
+ throw new Error(`Schema generation failed for ${errors.length} schema${errors.length === 1 ? '' : 's'}`);
323
+ }
324
+ logger.debug(`[TIMING] Process schemas: ${(performance.now() - processStart).toFixed(0)}ms (${Object.keys(schemas).length} generated)`);
306
325
  return schemas;
307
326
  }
308
327
  export async function generateAllSchemas(logger, config, state) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.11",
3
+ "version": "0.12.12",
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.19",
38
+ "@pikku/core": "^0.12.21",
39
39
  "path-to-regexp": "^8.3.0",
40
40
  "ts-json-schema-generator": "^2.5.0",
41
41
  "tsx": "^4.21.0",
@@ -479,10 +479,16 @@ function processCommand(
479
479
  }
480
480
  break
481
481
 
482
- case 'subcommands':
483
- if (ts.isObjectLiteralExpression(prop.initializer)) {
482
+ case 'subcommands': {
483
+ let subcommandsNode: ts.Node | undefined = prop.initializer
484
+ if (ts.isIdentifier(prop.initializer)) {
485
+ subcommandsNode = resolveIdentifier(prop.initializer, typeChecker, [
486
+ 'defineCLICommands',
487
+ ])
488
+ }
489
+ if (subcommandsNode && ts.isObjectLiteralExpression(subcommandsNode)) {
484
490
  meta.subcommands = {}
485
- for (const subProp of prop.initializer.properties) {
491
+ for (const subProp of subcommandsNode.properties) {
486
492
  if (!ts.isPropertyAssignment(subProp)) continue
487
493
 
488
494
  const subName = getPropertyName(subProp)
@@ -507,6 +513,7 @@ function processCommand(
507
513
  }
508
514
  }
509
515
  break
516
+ }
510
517
 
511
518
  case 'isDefault':
512
519
  if (
@@ -2,6 +2,7 @@ import * as ts from 'typescript'
2
2
  import {
3
3
  getPropertyValue,
4
4
  getArrayPropertyValue,
5
+ assertStringLiteralProperty,
5
6
  } from '../utils/get-property-value.js'
6
7
  import type { AddWiring } from '../types.js'
7
8
  import { ErrorCode } from '../error-codes.js'
@@ -33,6 +34,8 @@ export const addCredential: AddWiring = (
33
34
  if (ts.isObjectLiteralExpression(firstArg)) {
34
35
  const obj = firstArg
35
36
 
37
+ assertStringLiteralProperty(obj, 'name', 'Credential', logger)
38
+
36
39
  const nameValue = getPropertyValue(obj, 'name') as string | null
37
40
  const displayNameValue = getPropertyValue(obj, 'displayName') as
38
41
  | string
@@ -111,6 +111,64 @@ describe('addFunctions duplicate name handling', () => {
111
111
  }
112
112
  })
113
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
+
114
172
  test('logs a critical error when exposed function name is duplicated across files', async () => {
115
173
  const rootDir = await mkdtemp(
116
174
  join(tmpdir(), 'pikku-exposed-duplicate-function-')
@@ -167,3 +225,94 @@ describe('addFunctions duplicate name handling', () => {
167
225
  }
168
226
  })
169
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
+ })
@@ -14,6 +14,7 @@ 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'
@@ -312,6 +313,29 @@ const areCompatibleFunctionIds = (
312
313
  return existingParsed.baseName === incomingParsed.baseName
313
314
  }
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
+
315
339
  /**
316
340
  * Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
317
341
  * then push into state.functions.meta.
@@ -566,7 +590,12 @@ export const addFunctions: AddWiring = (
566
590
  }
567
591
 
568
592
  if (version !== undefined) {
569
- 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
+ }
570
599
  pikkuFuncId = formatVersionedId(baseName, version)
571
600
  }
572
601
 
@@ -894,6 +923,12 @@ export const addFunctions: AddWiring = (
894
923
  }
895
924
 
896
925
  const sessionless = expression.text !== 'pikkuFunc'
926
+ const implementationHash = computeImplementationHash({
927
+ wrapper: expression.text,
928
+ handler,
929
+ objectNode,
930
+ isDirectFunction,
931
+ })
897
932
 
898
933
  state.functions.meta[pikkuFuncId] = {
899
934
  pikkuFuncId,
@@ -914,6 +949,7 @@ export const addFunctions: AddWiring = (
914
949
  deploy: deploy || undefined,
915
950
  approvalRequired: approvalRequired || undefined,
916
951
  approvalDescription: approvalDescription || undefined,
952
+ implementationHash,
917
953
  version,
918
954
  title,
919
955
  tags: tags || undefined,