@pikku/inspector 0.12.72 → 0.12.74

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,21 @@
1
+ ## 0.12.74
2
+
3
+ ### Patch Changes
4
+
5
+ - 1becc2d: Publish an MCP tool under its declared `name`
6
+
7
+ `pikkuMCPToolFunc`'s config accepts a `name`, and it is the name an AI client
8
+ calls the tool by, but the inspector never read it — the tool was always
9
+ published under whatever the export happened to be called. The declared name
10
+ now keys `toolsMeta`; `pikkuFuncId` is unchanged.
11
+
12
+ ## 0.12.73
13
+
14
+ ### Patch Changes
15
+
16
+ - 6519c66: Report what each inspector pass did, not only how long it took. `inspect()` now records per-pass work counters on the state (`stats`: files in the program, files reused from the previous program, type/instantiation/symbol counts, CPU and wall time, heap in use), and `pikku all` prints one `[INSPECT]` row per pass under `PIKKU_TIMING`. The codegen benchmark gates on those counts and on live heap per pass instead of on step timings, which swing with the runner, and its fixture now includes an agent so the post-agent re-inspection is actually exercised.
17
+ - 5447dba: Keep parsed source files across inspector passes. A re-inspection in `pikku all` used to re-parse and re-bind every file, because the previous `ts.Program` — the thing TypeScript reuses parses from — had to be dropped to free its type checker. The inspector now builds each program through a compiler host with a content-hashed `SourceFile` cache, so a re-inspection parses only what codegen just wrote while holding no checker: on the 500-function benchmark the re-inspection reuses all 908 files, its CPU time falls by a third, and peak heap drops.
18
+
1
19
  ## 0.12.72
2
20
 
3
21
  ### Patch Changes
@@ -1053,13 +1053,25 @@ export const addFunctions = (logger, node, checker, state, options) => {
1053
1053
  };
1054
1054
  }
1055
1055
  if (mcpEnabled) {
1056
+ // `pikkuMCPToolFunc`'s config accepts `name`, and it is the name an AI client
1057
+ // calls the tool by — `runMCPTool` looks the tool up in `toolsMeta` by exactly
1058
+ // this key and then dispatches through `pikkuFuncId`, so it is a public label
1059
+ // rather than an identity. It was never read here, so a declared `name` was
1060
+ // silently dropped and the tool published under whatever the export happened to
1061
+ // be called. It does NOT become the `pikkuFuncId`: renaming an export's rpc id
1062
+ // is a different decision, and the scenario-step wrappers are the only place
1063
+ // `name` carries that weight.
1064
+ const declaredName = objectNode
1065
+ ? getPropertyValue(objectNode, 'name')
1066
+ : undefined;
1067
+ const toolName = typeof declaredName === 'string' && declaredName ? declaredName : name;
1056
1068
  if (!description) {
1057
- logger.warn(`MCP tool '${name}' is missing a description.`);
1069
+ logger.warn(`MCP tool '${toolName}' is missing a description.`);
1058
1070
  }
1059
1071
  state.mcpEndpoints.files.add(node.getSourceFile().fileName);
1060
- state.mcpEndpoints.toolsMeta[name] = {
1072
+ state.mcpEndpoints.toolsMeta[toolName] = {
1061
1073
  pikkuFuncId,
1062
- name,
1074
+ name: toolName,
1063
1075
  title: title || undefined,
1064
1076
  description: description || undefined,
1065
1077
  summary,
package/dist/inspector.js CHANGED
@@ -5,6 +5,7 @@ import { visitSetup, visitFunctions, visitRoutes } from './visit.js';
5
5
  import { TypesMap } from './types-map.js';
6
6
  import { getFilesAndMethods } from './utils/get-files-and-methods.js';
7
7
  import { findCommonAncestor } from './utils/find-root-dir.js';
8
+ import { createSourceFileCacheHost } from './source-file-cache.js';
8
9
  import { createNestedProjectFilter } from './utils/nested-project-filter.js';
9
10
  import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateWorkflowGraphAddons, } from './utils/post-process.js';
10
11
  import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js';
@@ -273,10 +274,13 @@ export const inspect = async (logger, routeFiles, options = {}) => {
273
274
  allowJs: false,
274
275
  checkJs: false,
275
276
  };
277
+ const cpuStart = process.cpuUsage();
276
278
  const startProgram = performance.now();
277
- const program = ts.createProgram(normalizedRouteFiles, compilerOptions, undefined, // host
278
- options.oldProgram);
279
- logger.debug(`Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files${options.oldProgram ? ', incremental' : ''})`);
279
+ const host = createSourceFileCacheHost(compilerOptions);
280
+ const program = ts.createProgram(normalizedRouteFiles, compilerOptions, host, options.oldProgram);
281
+ host.prune(program);
282
+ const filesReused = host.reused();
283
+ logger.debug(`Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files, ${filesReused} source files reused)`);
280
284
  const startChecker = performance.now();
281
285
  const checker = program.getTypeChecker();
282
286
  logger.debug(`Got type checker in ${(performance.now() - startChecker).toFixed(2)}ms`);
@@ -417,6 +421,18 @@ export const inspect = async (logger, routeFiles, options = {}) => {
417
421
  validatePersonaRoles(logger, state);
418
422
  logger.endValidationPass?.();
419
423
  }
424
+ const cpu = process.cpuUsage(cpuStart);
425
+ state.stats = {
426
+ files: program.getSourceFiles().length,
427
+ projectFiles: sourceFiles.length,
428
+ filesReused,
429
+ types: program.getTypeCount(),
430
+ instantiations: program.getInstantiationCount(),
431
+ symbols: program.getSymbolCount(),
432
+ cpuMs: Math.round((cpu.user + cpu.system) / 1000),
433
+ wallMs: Math.round(performance.now() - startProgram),
434
+ heapUsedMb: Math.round(process.memoryUsage().heapUsed / 1048576),
435
+ };
420
436
  state.program = program;
421
437
  return state;
422
438
  };
@@ -0,0 +1,11 @@
1
+ import * as ts from 'typescript';
2
+ export interface SourceFileCacheHost extends ts.CompilerHost {
3
+ /** Files served from the cache by the program just built. */
4
+ readonly reused: () => number;
5
+ /**
6
+ * Drop entries the program did not use, so a file that left the project
7
+ * (or watch mode's churn) does not pin its AST forever.
8
+ */
9
+ readonly prune: (program: ts.Program) => void;
10
+ }
11
+ export declare const createSourceFileCacheHost: (options: ts.CompilerOptions) => SourceFileCacheHost;
@@ -0,0 +1,54 @@
1
+ import * as ts from 'typescript';
2
+ import { createHash } from 'crypto';
3
+ /**
4
+ * Parsed source files, kept across programs so a re-inspection only re-parses
5
+ * what changed. Keyed by path; an entry is valid while the file's content
6
+ * hash matches.
7
+ *
8
+ * This is the incremental reuse `oldProgram` used to give, without its cost.
9
+ * Passing the previous ts.Program keeps that program reachable, and a program
10
+ * memoises its type checker — so every re-inspection held a second, fully
11
+ * exercised checker for the sake of skipping a re-parse. A bare SourceFile
12
+ * carries its AST and binder symbols and nothing else; sharing it between
13
+ * programs is what the language service's document registry does.
14
+ *
15
+ * Module-level on purpose: `inspect()` is called several times per `pikku all`
16
+ * (and indefinitely in watch mode) from one process, and the cache is only
17
+ * worth having if it outlives the call.
18
+ */
19
+ const cache = new Map();
20
+ export const createSourceFileCacheHost = (options) => {
21
+ const host = ts.createCompilerHost(options, true);
22
+ let reused = 0;
23
+ host.getSourceFile = (fileName, languageVersionOrOptions, onError) => {
24
+ let text;
25
+ try {
26
+ text = host.readFile(fileName);
27
+ }
28
+ catch (e) {
29
+ onError?.(e instanceof Error ? e.message : String(e));
30
+ return undefined;
31
+ }
32
+ if (text === undefined)
33
+ return undefined;
34
+ const hash = createHash('sha1').update(text).digest('hex');
35
+ const hit = cache.get(fileName);
36
+ if (hit && hit.hash === hash) {
37
+ reused++;
38
+ return hit.file;
39
+ }
40
+ const file = ts.createSourceFile(fileName, text, languageVersionOrOptions, true);
41
+ cache.set(fileName, { hash, file });
42
+ return file;
43
+ };
44
+ return Object.assign(host, {
45
+ reused: () => reused,
46
+ prune: (program) => {
47
+ const live = new Set(program.getSourceFiles().map((sf) => sf.fileName));
48
+ for (const fileName of cache.keys()) {
49
+ if (!live.has(fileName))
50
+ cache.delete(fileName);
51
+ }
52
+ },
53
+ });
54
+ };
package/dist/types.d.ts CHANGED
@@ -223,6 +223,26 @@ export type AddonConfig = {
223
223
  secretOverrides?: Record<string, string>;
224
224
  forceInclude?: boolean;
225
225
  };
226
+ export interface InspectorPassStats {
227
+ /** Source files in the ts.Program, node_modules and lib included. */
228
+ files: number;
229
+ /** Files under rootDir the inspector actually swept. */
230
+ projectFiles: number;
231
+ /**
232
+ * Files served from the source-file cache without re-parsing. Zero on a
233
+ * first pass; on a re-inspection it should be nearly all of `files` — that
234
+ * reuse is what keeps a re-inspection cheaper than a cold one.
235
+ */
236
+ filesReused: number;
237
+ /** Checker work, as `tsc --extendedDiagnostics` counts it. */
238
+ types: number;
239
+ instantiations: number;
240
+ symbols: number;
241
+ cpuMs: number;
242
+ wallMs: number;
243
+ /** V8 heap in use when the pass finished, before any GC. */
244
+ heapUsedMb: number;
245
+ }
226
246
  export type InspectorOptions = Partial<{
227
247
  setupOnly: boolean;
228
248
  rootDir: string;
@@ -456,6 +476,12 @@ export interface InspectorState {
456
476
  filesAndMethods: InspectorFilesAndMethods;
457
477
  filesAndMethodsErrors: Map<string, PathToNameAndType>;
458
478
  typesLookup: Map<string, ts.Type[]>;
479
+ /**
480
+ * What this pass cost, in counts rather than time where it can be counted:
481
+ * counts don't move with the machine, so they are what a perf gate can
482
+ * compare across runners (see benchmarks/bench-codegen-perf.ts).
483
+ */
484
+ stats?: InspectorPassStats;
459
485
  schemaLookup: Map<string, SchemaRef>;
460
486
  schemas: Record<string, JSONValue>;
461
487
  http: InspectorHTTPState;
@@ -914,8 +914,21 @@ export function filterInspectorState(state, filters, logger) {
914
914
  for (const tool of agentMeta.tools ?? [])
915
915
  referencedIds.add(tool);
916
916
  }
917
+ // A unit carrying the generic `/rpc/:rpcName` catch-all (the rpcCaller
918
+ // unit, and agent units) dispatches arbitrary RPCs at runtime via
919
+ // `rpc.exposed(name)`, including namespaced addon RPCs (e.g.
920
+ // `admin:createUser`). Those never appear in referencedIds because no
921
+ // app-local function statically references them, so keep every wired
922
+ // addon's declaration for such units — otherwise the per-unit bootstrap
923
+ // omits the addon package bootstrap and `rpc.exposed('admin:createUser')`
924
+ // throws RPCNotFoundError on the deployed stage.
925
+ const hasRpcCatchAll = (filters.names ?? []).includes('/rpc/:rpcName');
917
926
  const keptNamespaces = new Set();
918
927
  for (const namespace of filteredState.rpc.wireAddonDeclarations.keys()) {
928
+ if (hasRpcCatchAll) {
929
+ keptNamespaces.add(namespace);
930
+ continue;
931
+ }
919
932
  const prefix = `${namespace}:`;
920
933
  for (const id of referencedIds) {
921
934
  if (id.startsWith(prefix)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.72",
3
+ "version": "0.12.74",
4
4
  "description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "BUSL-1.1",
@@ -643,3 +643,101 @@ describe('pikkuRemoteChannelFunc', () => {
643
643
  )
644
644
  })
645
645
  })
646
+
647
+ describe('MCP tool naming', () => {
648
+ const quietLogger = (): InspectorLogger => ({
649
+ debug: () => {},
650
+ info: () => {},
651
+ warn: () => {},
652
+ error: () => {},
653
+ diagnostic: () => {},
654
+ critical: () => {},
655
+ hasCriticalErrors: () => false,
656
+ })
657
+
658
+ test('a declared name is what the tool is published as', async () => {
659
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-mcp-tool-name-'))
660
+ const file = join(rootDir, 'tool.ts')
661
+ await writeFile(
662
+ file,
663
+ [
664
+ "import { pikkuMCPToolFunc } from '@pikku/core'",
665
+ 'export const createUnansweredCountTool = pikkuMCPToolFunc({',
666
+ " name: 'getUnansweredConversationCount',",
667
+ " description: 'How many conversations are unanswered.',",
668
+ ' func: async () => ({ ok: true })',
669
+ '})',
670
+ ].join('\n')
671
+ )
672
+
673
+ try {
674
+ const state = await inspect(quietLogger(), [file], { rootDir })
675
+ const tools = state.mcpEndpoints.toolsMeta
676
+ assert.ok(
677
+ tools['getUnansweredConversationCount'],
678
+ `expected the declared name, got: ${Object.keys(tools).join(', ')}`
679
+ )
680
+ assert.strictEqual(tools['createUnansweredCountTool'], undefined)
681
+ assert.strictEqual(
682
+ tools['getUnansweredConversationCount']!.pikkuFuncId,
683
+ 'createUnansweredCountTool'
684
+ )
685
+ } finally {
686
+ await rm(rootDir, { recursive: true, force: true })
687
+ }
688
+ })
689
+
690
+ test('with no declared name the export still names the tool', async () => {
691
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-mcp-tool-export-'))
692
+ const file = join(rootDir, 'tool.ts')
693
+ await writeFile(
694
+ file,
695
+ [
696
+ "import { pikkuMCPToolFunc } from '@pikku/core'",
697
+ 'export const listOpenTickets = pikkuMCPToolFunc({',
698
+ " description: 'Tickets still open.',",
699
+ ' func: async () => ({ ok: true })',
700
+ '})',
701
+ ].join('\n')
702
+ )
703
+
704
+ try {
705
+ const state = await inspect(quietLogger(), [file], { rootDir })
706
+ assert.ok(state.mcpEndpoints.toolsMeta['listOpenTickets'])
707
+ } finally {
708
+ await rm(rootDir, { recursive: true, force: true })
709
+ }
710
+ })
711
+ test('a plain func with mcp: true is published under its declared name', async () => {
712
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-mcp-flag-name-'))
713
+ const file = join(rootDir, 'tool.ts')
714
+ await writeFile(
715
+ file,
716
+ [
717
+ "import { pikkuFunc } from '@pikku/core'",
718
+ 'export const countUnanswered = pikkuFunc({',
719
+ ' mcp: true,',
720
+ " name: 'getUnansweredConversationCount',",
721
+ " description: 'How many conversations are unanswered.',",
722
+ ' func: async () => ({ ok: true })',
723
+ '})',
724
+ ].join('\n')
725
+ )
726
+
727
+ try {
728
+ const state = await inspect(quietLogger(), [file], { rootDir })
729
+ const tools = state.mcpEndpoints.toolsMeta
730
+ assert.ok(
731
+ tools['getUnansweredConversationCount'],
732
+ `expected the declared name, got: ${Object.keys(tools).join(', ')}`
733
+ )
734
+ assert.strictEqual(tools['countUnanswered'], undefined)
735
+ assert.strictEqual(
736
+ tools['getUnansweredConversationCount']!.pikkuFuncId,
737
+ 'countUnanswered'
738
+ )
739
+ } finally {
740
+ await rm(rootDir, { recursive: true, force: true })
741
+ }
742
+ })
743
+ })
@@ -1431,13 +1431,26 @@ export const addFunctions: AddWiring = (
1431
1431
  }
1432
1432
 
1433
1433
  if (mcpEnabled) {
1434
+ // `pikkuMCPToolFunc`'s config accepts `name`, and it is the name an AI client
1435
+ // calls the tool by — `runMCPTool` looks the tool up in `toolsMeta` by exactly
1436
+ // this key and then dispatches through `pikkuFuncId`, so it is a public label
1437
+ // rather than an identity. It was never read here, so a declared `name` was
1438
+ // silently dropped and the tool published under whatever the export happened to
1439
+ // be called. It does NOT become the `pikkuFuncId`: renaming an export's rpc id
1440
+ // is a different decision, and the scenario-step wrappers are the only place
1441
+ // `name` carries that weight.
1442
+ const declaredName = objectNode
1443
+ ? getPropertyValue(objectNode, 'name')
1444
+ : undefined
1445
+ const toolName =
1446
+ typeof declaredName === 'string' && declaredName ? declaredName : name
1434
1447
  if (!description) {
1435
- logger.warn(`MCP tool '${name}' is missing a description.`)
1448
+ logger.warn(`MCP tool '${toolName}' is missing a description.`)
1436
1449
  }
1437
1450
  state.mcpEndpoints.files.add(node.getSourceFile().fileName)
1438
- state.mcpEndpoints.toolsMeta[name] = {
1451
+ state.mcpEndpoints.toolsMeta[toolName] = {
1439
1452
  pikkuFuncId,
1440
- name,
1453
+ name: toolName,
1441
1454
  title: title || undefined,
1442
1455
  description: description || undefined,
1443
1456
  summary,
package/src/inspector.ts CHANGED
@@ -10,6 +10,7 @@ import type {
10
10
  } from './types.js'
11
11
  import { getFilesAndMethods } from './utils/get-files-and-methods.js'
12
12
  import { findCommonAncestor } from './utils/find-root-dir.js'
13
+ import { createSourceFileCacheHost } from './source-file-cache.js'
13
14
  import { createNestedProjectFilter } from './utils/nested-project-filter.js'
14
15
  import {
15
16
  aggregateRequiredServices,
@@ -335,15 +336,19 @@ export const inspect = async (
335
336
  allowJs: false,
336
337
  checkJs: false,
337
338
  }
339
+ const cpuStart = process.cpuUsage()
338
340
  const startProgram = performance.now()
341
+ const host = createSourceFileCacheHost(compilerOptions)
339
342
  const program = ts.createProgram(
340
343
  normalizedRouteFiles,
341
344
  compilerOptions,
342
- undefined, // host
345
+ host,
343
346
  options.oldProgram
344
347
  )
348
+ host.prune(program)
349
+ const filesReused = host.reused()
345
350
  logger.debug(
346
- `Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files${options.oldProgram ? ', incremental' : ''})`
351
+ `Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files, ${filesReused} source files reused)`
347
352
  )
348
353
 
349
354
  const startChecker = performance.now()
@@ -552,6 +557,18 @@ export const inspect = async (
552
557
  logger.endValidationPass?.()
553
558
  }
554
559
 
560
+ const cpu = process.cpuUsage(cpuStart)
561
+ state.stats = {
562
+ files: program.getSourceFiles().length,
563
+ projectFiles: sourceFiles.length,
564
+ filesReused,
565
+ types: program.getTypeCount(),
566
+ instantiations: program.getInstantiationCount(),
567
+ symbols: program.getSymbolCount(),
568
+ cpuMs: Math.round((cpu.user + cpu.system) / 1000),
569
+ wallMs: Math.round(performance.now() - startProgram),
570
+ heapUsedMb: Math.round(process.memoryUsage().heapUsed / 1048576),
571
+ }
555
572
  state.program = program
556
573
 
557
574
  return state
@@ -0,0 +1,73 @@
1
+ import * as ts from 'typescript'
2
+ import { createHash } from 'crypto'
3
+
4
+ /**
5
+ * Parsed source files, kept across programs so a re-inspection only re-parses
6
+ * what changed. Keyed by path; an entry is valid while the file's content
7
+ * hash matches.
8
+ *
9
+ * This is the incremental reuse `oldProgram` used to give, without its cost.
10
+ * Passing the previous ts.Program keeps that program reachable, and a program
11
+ * memoises its type checker — so every re-inspection held a second, fully
12
+ * exercised checker for the sake of skipping a re-parse. A bare SourceFile
13
+ * carries its AST and binder symbols and nothing else; sharing it between
14
+ * programs is what the language service's document registry does.
15
+ *
16
+ * Module-level on purpose: `inspect()` is called several times per `pikku all`
17
+ * (and indefinitely in watch mode) from one process, and the cache is only
18
+ * worth having if it outlives the call.
19
+ */
20
+ const cache = new Map<string, { hash: string; file: ts.SourceFile }>()
21
+
22
+ export interface SourceFileCacheHost extends ts.CompilerHost {
23
+ /** Files served from the cache by the program just built. */
24
+ readonly reused: () => number
25
+ /**
26
+ * Drop entries the program did not use, so a file that left the project
27
+ * (or watch mode's churn) does not pin its AST forever.
28
+ */
29
+ readonly prune: (program: ts.Program) => void
30
+ }
31
+
32
+ export const createSourceFileCacheHost = (
33
+ options: ts.CompilerOptions
34
+ ): SourceFileCacheHost => {
35
+ const host = ts.createCompilerHost(options, true)
36
+ let reused = 0
37
+
38
+ host.getSourceFile = (fileName, languageVersionOrOptions, onError) => {
39
+ let text: string | undefined
40
+ try {
41
+ text = host.readFile(fileName)
42
+ } catch (e) {
43
+ onError?.(e instanceof Error ? e.message : String(e))
44
+ return undefined
45
+ }
46
+ if (text === undefined) return undefined
47
+
48
+ const hash = createHash('sha1').update(text).digest('hex')
49
+ const hit = cache.get(fileName)
50
+ if (hit && hit.hash === hash) {
51
+ reused++
52
+ return hit.file
53
+ }
54
+ const file = ts.createSourceFile(
55
+ fileName,
56
+ text,
57
+ languageVersionOrOptions,
58
+ true
59
+ )
60
+ cache.set(fileName, { hash, file })
61
+ return file
62
+ }
63
+
64
+ return Object.assign(host, {
65
+ reused: () => reused,
66
+ prune: (program: ts.Program) => {
67
+ const live = new Set(program.getSourceFiles().map((sf) => sf.fileName))
68
+ for (const fileName of cache.keys()) {
69
+ if (!live.has(fileName)) cache.delete(fileName)
70
+ }
71
+ },
72
+ })
73
+ }
package/src/types.ts CHANGED
@@ -290,6 +290,27 @@ export type AddonConfig = {
290
290
  forceInclude?: boolean
291
291
  }
292
292
 
293
+ export interface InspectorPassStats {
294
+ /** Source files in the ts.Program, node_modules and lib included. */
295
+ files: number
296
+ /** Files under rootDir the inspector actually swept. */
297
+ projectFiles: number
298
+ /**
299
+ * Files served from the source-file cache without re-parsing. Zero on a
300
+ * first pass; on a re-inspection it should be nearly all of `files` — that
301
+ * reuse is what keeps a re-inspection cheaper than a cold one.
302
+ */
303
+ filesReused: number
304
+ /** Checker work, as `tsc --extendedDiagnostics` counts it. */
305
+ types: number
306
+ instantiations: number
307
+ symbols: number
308
+ cpuMs: number
309
+ wallMs: number
310
+ /** V8 heap in use when the pass finished, before any GC. */
311
+ heapUsedMb: number
312
+ }
313
+
293
314
  export type InspectorOptions = Partial<{
294
315
  setupOnly: boolean
295
316
  rootDir: string
@@ -534,6 +555,12 @@ export interface InspectorState {
534
555
  filesAndMethods: InspectorFilesAndMethods
535
556
  filesAndMethodsErrors: Map<string, PathToNameAndType>
536
557
  typesLookup: Map<string, ts.Type[]> // Lookup for types by name (e.g., function input types, Config type)
558
+ /**
559
+ * What this pass cost, in counts rather than time where it can be counted:
560
+ * counts don't move with the machine, so they are what a perf gate can
561
+ * compare across runners (see benchmarks/bench-codegen-perf.ts).
562
+ */
563
+ stats?: InspectorPassStats
537
564
  schemaLookup: Map<string, SchemaRef> // Lookup for schemas by name for deferred JSON Schema conversion (supports Standard Schema vendors)
538
565
  schemas: Record<string, JSONValue>
539
566
  http: InspectorHTTPState
@@ -2141,4 +2141,67 @@ describe('invokedAgentsByFile', () => {
2141
2141
  ['/test/project/src/api/users.ts']
2142
2142
  )
2143
2143
  })
2144
+
2145
+ describe('Wired addon retention', () => {
2146
+ // A unit that owns the generic `/rpc/:rpcName` catch-all (the rpcCaller
2147
+ // unit, and agent units) dispatches arbitrary RPCs at runtime via
2148
+ // `rpc.exposed(name)`, including namespaced addon RPCs like
2149
+ // `admin:createUser`. No app-local function statically references those
2150
+ // ids, so the catch-all must be enough to keep every wired addon's
2151
+ // declaration — otherwise the per-unit bootstrap omits the addon package
2152
+ // bootstrap and `rpc.exposed('admin:createUser')` throws RPCNotFoundError
2153
+ // on a deployed stage.
2154
+ const withAddons = () => {
2155
+ const state = createMockInspectorState()
2156
+ state.rpc.wireAddonDeclarations = new Map([
2157
+ ['admin', { package: '@pikku/addon-admin' } as any],
2158
+ ['console', { package: '@pikku/addon-console' } as any],
2159
+ ])
2160
+ state.rpc.usedAddons = new Set(['admin', 'console'])
2161
+ return state
2162
+ }
2163
+
2164
+ test('keeps all wired addons for a unit with the /rpc/:rpcName catch-all', () => {
2165
+ const result = filterInspectorState(
2166
+ withAddons(),
2167
+ { names: ['/rpc/:rpcName', 'http:post:/rpc/:rpcName', 'rpcCaller'] },
2168
+ mockLogger
2169
+ )
2170
+ assert.deepStrictEqual(
2171
+ [...result.rpc.wireAddonDeclarations.keys()].sort(),
2172
+ ['admin', 'console']
2173
+ )
2174
+ assert.deepStrictEqual([...result.rpc.usedAddons].sort(), [
2175
+ 'admin',
2176
+ 'console',
2177
+ ])
2178
+ })
2179
+
2180
+ test('drops wired addons for a unit without the catch-all that references no namespace', () => {
2181
+ const result = filterInspectorState(
2182
+ withAddons(),
2183
+ { names: ['getUsers'] },
2184
+ mockLogger
2185
+ )
2186
+ assert.deepStrictEqual([...result.rpc.wireAddonDeclarations.keys()], [])
2187
+ assert.deepStrictEqual([...result.rpc.usedAddons], [])
2188
+ })
2189
+
2190
+ test('still keeps an addon a surviving function statically references, without the catch-all', () => {
2191
+ const state = withAddons()
2192
+ state.serviceAggregation.usedFunctions.add('getUsers')
2193
+ state.rpc.invokedFunctionsByFile = new Map([
2194
+ ['/test/project/src/api/users.ts', new Set(['admin:createUser'])],
2195
+ ])
2196
+ const result = filterInspectorState(
2197
+ state,
2198
+ { names: ['getUsers'] },
2199
+ mockLogger
2200
+ )
2201
+ assert.deepStrictEqual(
2202
+ [...result.rpc.wireAddonDeclarations.keys()],
2203
+ ['admin']
2204
+ )
2205
+ })
2206
+ })
2144
2207
  })
@@ -1258,8 +1258,21 @@ export function filterInspectorState(
1258
1258
  ) as Array<{ tools?: string[] }>) {
1259
1259
  for (const tool of agentMeta.tools ?? []) referencedIds.add(tool)
1260
1260
  }
1261
+ // A unit carrying the generic `/rpc/:rpcName` catch-all (the rpcCaller
1262
+ // unit, and agent units) dispatches arbitrary RPCs at runtime via
1263
+ // `rpc.exposed(name)`, including namespaced addon RPCs (e.g.
1264
+ // `admin:createUser`). Those never appear in referencedIds because no
1265
+ // app-local function statically references them, so keep every wired
1266
+ // addon's declaration for such units — otherwise the per-unit bootstrap
1267
+ // omits the addon package bootstrap and `rpc.exposed('admin:createUser')`
1268
+ // throws RPCNotFoundError on the deployed stage.
1269
+ const hasRpcCatchAll = (filters.names ?? []).includes('/rpc/:rpcName')
1261
1270
  const keptNamespaces = new Set<string>()
1262
1271
  for (const namespace of filteredState.rpc.wireAddonDeclarations.keys()) {
1272
+ if (hasRpcCatchAll) {
1273
+ keptNamespaces.add(namespace)
1274
+ continue
1275
+ }
1263
1276
  const prefix = `${namespace}:`
1264
1277
  for (const id of referencedIds) {
1265
1278
  if (id.startsWith(prefix)) {