@pikku/inspector 0.12.43 → 0.12.45

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 (36) hide show
  1. package/CHANGELOG.md +261 -0
  2. package/dist/add/add-credential.js +2 -0
  3. package/dist/add/add-keyed-wiring.js +2 -0
  4. package/dist/add/add-workflow.js +9 -4
  5. package/dist/utils/schema-generator.js +19 -4
  6. package/dist/utils/workflow/dsl/deserialize-dsl-workflow.js +373 -91
  7. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +218 -99
  8. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +62 -5
  9. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +3 -3
  10. package/package.json +3 -3
  11. package/run-tests.sh +1 -1
  12. package/src/add/add-auth.test.ts +4 -4
  13. package/src/add/add-credential.ts +2 -0
  14. package/src/add/add-keyed-wiring.ts +2 -0
  15. package/src/add/add-workflow-edge-cases.test.ts +201 -0
  16. package/src/add/add-workflow-fanout.test.ts +60 -0
  17. package/src/add/add-workflow-on-error.test.ts +118 -0
  18. package/src/add/add-workflow-return-switch.test.ts +121 -0
  19. package/src/add/add-workflow-silent-drops.test.ts +163 -0
  20. package/src/add/add-workflow-sleep-expression.test.ts +97 -0
  21. package/src/add/add-workflow.ts +9 -4
  22. package/src/add/config-value-metadata.test.ts +169 -0
  23. package/src/utils/schema-generator.test.ts +164 -0
  24. package/src/utils/schema-generator.ts +26 -3
  25. package/src/utils/workflow/dsl/deserialize-dsl-workflow.ts +480 -119
  26. package/src/utils/workflow/dsl/deserialize-fanout-body.test.ts +71 -0
  27. package/src/utils/workflow/dsl/deserialize-fanout-fidelity.test.ts +99 -0
  28. package/src/utils/workflow/dsl/deserialize-graph-next.test.ts +161 -0
  29. package/src/utils/workflow/dsl/deserialize-roundtrip-bindings.test.ts +121 -0
  30. package/src/utils/workflow/dsl/deserialize-roundtrip-values.test.ts +116 -0
  31. package/src/utils/workflow/dsl/deserialize-roundtrip.test.ts +145 -0
  32. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +245 -105
  33. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +67 -10
  34. package/src/utils/workflow/graph/fanout-node-identity.test.ts +105 -0
  35. package/src/utils/workflow/graph/workflow-graph.types.ts +3 -2
  36. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,97 @@
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 type { InspectorLogger } from '../types.js'
8
+
9
+ const STEPS = [
10
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
11
+ "export const notify = pikkuSessionlessFunc({ func: async () => ({ id: 'n' }) })",
12
+ ].join('\n')
13
+
14
+ async function run(body: string) {
15
+ const diags: string[] = []
16
+ const logger = {
17
+ debug: () => {},
18
+ info: () => {},
19
+ warn: () => {},
20
+ error: () => {},
21
+ diagnostic: (...a: any[]) => diags.push(a.map(String).join(' ')),
22
+ critical: (...a: any[]) => diags.push(a.map(String).join(' ')),
23
+ hasCriticalErrors: () => false,
24
+ } as unknown as InspectorLogger
25
+
26
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-sleep-'))
27
+ const stepFile = join(rootDir, 'w.steps.ts')
28
+ const wfFile = join(rootDir, 'w.workflow.ts')
29
+ await writeFile(stepFile, STEPS)
30
+ await writeFile(
31
+ wfFile,
32
+ [
33
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
34
+ 'export const wf = pikkuWorkflowFunc(async (_, data: any, { workflow }) => {',
35
+ body,
36
+ '})',
37
+ ].join('\n')
38
+ )
39
+ try {
40
+ const state: any = await inspect(logger, [stepFile, wfFile], { rootDir })
41
+ const gm = state.workflows.graphMeta
42
+ const graph = (gm instanceof Map ? [...gm.values()] : Object.values(gm))[0]
43
+ return { graph: graph as any, diags }
44
+ } finally {
45
+ await rm(rootDir, { recursive: true, force: true })
46
+ }
47
+ }
48
+
49
+ describe('sleep with a duration that is not a literal', () => {
50
+ test('a variable duration inside a loop is kept, not rejected', async () => {
51
+ const { graph, diags } = await run(
52
+ [
53
+ ' for (const interval of data.intervals) {',
54
+ ' await workflow.sleep(`Wait ${interval}`, interval)',
55
+ " await workflow.do('Notify', 'notify', {})",
56
+ ' }',
57
+ ' return { sent: true }',
58
+ ].join('\n')
59
+ )
60
+
61
+ assert.deepEqual(
62
+ diags.filter((d) => d.includes('sleep')),
63
+ [],
64
+ 'a duration only known at runtime is legal — the closure evaluates it'
65
+ )
66
+
67
+ const sleepNode = Object.values(graph?.nodes ?? {}).find(
68
+ (n: any) => n.flow === 'sleep'
69
+ ) as any
70
+ assert.ok(sleepNode, 'the sleep must still appear in the graph')
71
+ assert.equal(
72
+ sleepNode.expression,
73
+ 'interval',
74
+ 'its source text is recorded so the graph can show what it waits on'
75
+ )
76
+ })
77
+
78
+ test('a literal duration still round-trips as a value', async () => {
79
+ const { graph, diags } = await run(
80
+ [
81
+ " await workflow.sleep('Wait', '5s')",
82
+ ' return { sent: true }',
83
+ ].join('\n')
84
+ )
85
+
86
+ assert.deepEqual(diags, [])
87
+ const sleepNode = Object.values(graph?.nodes ?? {}).find(
88
+ (n: any) => n.flow === 'sleep'
89
+ ) as any
90
+ assert.equal(sleepNode?.duration, '5s')
91
+ assert.equal(
92
+ sleepNode?.expression,
93
+ undefined,
94
+ 'a literal is a value, not an expression'
95
+ )
96
+ })
97
+ })
@@ -73,8 +73,8 @@ function hasInlineSteps(steps: WorkflowStepMeta[]): boolean {
73
73
  if (c.steps && hasInlineSteps(c.steps)) return true
74
74
  }
75
75
  if (step.defaultSteps && hasInlineSteps(step.defaultSteps)) return true
76
- } else if (step.type === 'fanout' && step.child) {
77
- if (hasInlineSteps([step.child])) return true
76
+ } else if (step.type === 'fanout' && step.body) {
77
+ if (hasInlineSteps(step.body)) return true
78
78
  } else if (step.type === 'parallel' && step.children) {
79
79
  if (hasInlineSteps(step.children)) return true
80
80
  }
@@ -92,6 +92,11 @@ export function collectInvokedRPCs(
92
92
  for (const step of steps) {
93
93
  if (step.type === 'rpc' && step.rpcName) {
94
94
  rpcs.add(step.rpcName)
95
+ // The compensation handler is invoked at runtime too, so it has to be
96
+ // wired like any other step.
97
+ if (step.options?.onError) {
98
+ rpcs.add(step.options.onError)
99
+ }
95
100
  } else if (step.type === 'branch') {
96
101
  for (const branch of step.branches) {
97
102
  collectInvokedRPCs(branch.steps, rpcs)
@@ -102,8 +107,8 @@ export function collectInvokedRPCs(
102
107
  if (c.steps) collectInvokedRPCs(c.steps, rpcs)
103
108
  }
104
109
  if (step.defaultSteps) collectInvokedRPCs(step.defaultSteps, rpcs)
105
- } else if (step.type === 'fanout' && step.child) {
106
- collectInvokedRPCs([step.child], rpcs)
110
+ } else if (step.type === 'fanout' && step.body) {
111
+ collectInvokedRPCs(step.body, rpcs)
107
112
  } else if (step.type === 'parallel' && step.children) {
108
113
  collectInvokedRPCs(step.children, rpcs)
109
114
  }
@@ -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 { join } from 'node:path'
5
+ import { fileURLToPath } from 'node:url'
6
+ import { inspect } from '../inspector.js'
7
+ import type { ErrorCode } from '../error-codes.js'
8
+ import type { InspectorLogger } from '../types.js'
9
+
10
+ // Fixtures must sit inside the package so `zod` resolves — the schema-vendor
11
+ // check rejects a schema it cannot trace to a supported library, and the
12
+ // definition is then never recorded at all.
13
+ const fixtureRoot = join(
14
+ fileURLToPath(new URL('.', import.meta.url)),
15
+ '__fixtures'
16
+ )
17
+
18
+ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
19
+ ({
20
+ debug: () => {},
21
+ info: () => {},
22
+ warn: () => {},
23
+ error: () => {},
24
+ diagnostic: ({ code, message }) => {
25
+ criticals.push({ code, message })
26
+ },
27
+ critical: (code: ErrorCode, message: string) => {
28
+ criticals.push({ code, message })
29
+ },
30
+ hasCriticalErrors: () => criticals.length > 0,
31
+ }) satisfies InspectorLogger
32
+
33
+ /**
34
+ * `docsUrl` is only useful if it survives inspection — a field declared on the
35
+ * wiring but dropped by the inspector is invisible to every consumer
36
+ * downstream, which is exactly how `displayName` went unused.
37
+ */
38
+ describe('config value metadata (docsUrl)', () => {
39
+ test('carries docsUrl from wireSecret into the definition', async () => {
40
+ const rootDir = await mkdtemp(`${fixtureRoot}-secret-`)
41
+ const file = join(rootDir, 'secret.ts')
42
+
43
+ await writeFile(
44
+ file,
45
+ [
46
+ "import { wireSecret } from '@pikku/core'",
47
+ "import { z } from 'zod'",
48
+ 'export const StripeKeySchema = z.string()',
49
+ 'wireSecret({',
50
+ " name: 'stripeKey',",
51
+ " displayName: 'Stripe Secret Key',",
52
+ " secretId: 'STRIPE_SECRET_KEY',",
53
+ " docsUrl: 'https://dashboard.stripe.com/apikeys',",
54
+ ' schema: StripeKeySchema,',
55
+ '})',
56
+ ].join('\n')
57
+ )
58
+
59
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
60
+ try {
61
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
62
+ const def = state.secrets.definitions.find(
63
+ (d) => d.secretId === 'STRIPE_SECRET_KEY'
64
+ )
65
+ assert.ok(def, 'expected the secret definition to be inspected')
66
+ assert.equal(def!.docsUrl, 'https://dashboard.stripe.com/apikeys')
67
+ } finally {
68
+ await rm(rootDir, { recursive: true, force: true })
69
+ }
70
+ })
71
+
72
+ test('leaves docsUrl undefined when not declared', async () => {
73
+ const rootDir = await mkdtemp(`${fixtureRoot}-bare-`)
74
+ const file = join(rootDir, 'secret.ts')
75
+
76
+ await writeFile(
77
+ file,
78
+ [
79
+ "import { wireSecret } from '@pikku/core'",
80
+ "import { z } from 'zod'",
81
+ 'export const ApiKeySchema = z.string()',
82
+ 'wireSecret({',
83
+ " name: 'apiKey',",
84
+ " displayName: 'API Key',",
85
+ " secretId: 'API_KEY',",
86
+ ' schema: ApiKeySchema,',
87
+ '})',
88
+ ].join('\n')
89
+ )
90
+
91
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
92
+ try {
93
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
94
+ const def = state.secrets.definitions.find(
95
+ (d) => d.secretId === 'API_KEY'
96
+ )
97
+ assert.ok(def, 'expected the secret definition to be inspected')
98
+ assert.equal(def!.docsUrl, undefined)
99
+ } finally {
100
+ await rm(rootDir, { recursive: true, force: true })
101
+ }
102
+ })
103
+
104
+ test('carries docsUrl from wireVariable into the definition', async () => {
105
+ const rootDir = await mkdtemp(`${fixtureRoot}-variable-`)
106
+ const file = join(rootDir, 'variable.ts')
107
+
108
+ await writeFile(
109
+ file,
110
+ [
111
+ "import { wireVariable } from '@pikku/core'",
112
+ "import { z } from 'zod'",
113
+ 'export const ConsoleUrlSchema = z.string()',
114
+ 'wireVariable({',
115
+ " name: 'consoleUrl',",
116
+ " displayName: 'Console URL',",
117
+ " variableId: 'CONSOLE_URL',",
118
+ " docsUrl: 'https://example.com/docs/console-url',",
119
+ ' schema: ConsoleUrlSchema,',
120
+ '})',
121
+ ].join('\n')
122
+ )
123
+
124
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
125
+ try {
126
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
127
+ const def = state.variables.definitions.find(
128
+ (d) => d.variableId === 'CONSOLE_URL'
129
+ )
130
+ assert.ok(def, 'expected the variable definition to be inspected')
131
+ assert.equal(def!.docsUrl, 'https://example.com/docs/console-url')
132
+ } finally {
133
+ await rm(rootDir, { recursive: true, force: true })
134
+ }
135
+ })
136
+
137
+ test('carries docsUrl from wireCredential into the definition', async () => {
138
+ const rootDir = await mkdtemp(`${fixtureRoot}-credential-`)
139
+ const file = join(rootDir, 'credential.ts')
140
+
141
+ await writeFile(
142
+ file,
143
+ [
144
+ "import { wireCredential } from '@pikku/core'",
145
+ "import { z } from 'zod'",
146
+ 'export const TokenSchema = z.object({ token: z.string() })',
147
+ 'wireCredential({',
148
+ " name: 'githubToken',",
149
+ " displayName: 'GitHub Token',",
150
+ " type: 'wire',",
151
+ " docsUrl: 'https://github.com/settings/tokens',",
152
+ ' schema: TokenSchema,',
153
+ '})',
154
+ ].join('\n')
155
+ )
156
+
157
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
158
+ try {
159
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
160
+ const def = state.credentials.definitions.find(
161
+ (d) => d.name === 'githubToken'
162
+ )
163
+ assert.ok(def, 'expected the credential definition to be inspected')
164
+ assert.equal(def!.docsUrl, 'https://github.com/settings/tokens')
165
+ } finally {
166
+ await rm(rootDir, { recursive: true, force: true })
167
+ }
168
+ })
169
+ })
@@ -0,0 +1,164 @@
1
+ import { test, describe, before, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs'
4
+ import { tmpdir } from 'os'
5
+ import { join } from 'path'
6
+
7
+ import { getInitialInspectorState } from '../inspector.js'
8
+ import { generateAllSchemas } from './schema-generator.js'
9
+
10
+ const debugLines: string[] = []
11
+ const logger = {
12
+ debug(message: string) {
13
+ debugLines.push(message)
14
+ },
15
+ info() {},
16
+ warn() {},
17
+ error() {},
18
+ } as any
19
+
20
+ const UNRELATED_FILE_COUNT = 300
21
+
22
+ let projectDir: string
23
+
24
+ before(() => {
25
+ projectDir = mkdtempSync(join(tmpdir(), 'pikku-schema-gen-'))
26
+ mkdirSync(join(projectDir, 'src'))
27
+ writeFileSync(
28
+ join(projectDir, 'tsconfig.json'),
29
+ JSON.stringify({
30
+ compilerOptions: {
31
+ target: 'ESNext',
32
+ module: 'NodeNext',
33
+ moduleResolution: 'NodeNext',
34
+ strict: true,
35
+ skipLibCheck: true,
36
+ },
37
+ include: ['src/**/*.ts'],
38
+ })
39
+ )
40
+ writeFileSync(
41
+ join(projectDir, 'src', 'types.ts'),
42
+ 'export type Thing = { id: string; count: number }\n'
43
+ )
44
+ // Files the tsconfig picks up but no generated custom type references. They
45
+ // stand in for the bulk of a real project, which the schema program has no
46
+ // reason to load.
47
+ for (let i = 0; i < UNRELATED_FILE_COUNT; i += 1) {
48
+ writeFileSync(
49
+ join(projectDir, 'src', `unrelated-${i}.ts`),
50
+ `export type Unrelated${i} = { a: string; b: number; c: boolean }\n`
51
+ )
52
+ }
53
+ })
54
+
55
+ after(() => {
56
+ rmSync(projectDir, { recursive: true, force: true })
57
+ })
58
+
59
+ describe('generateAllSchemas', () => {
60
+ // NOTE: this must be the first test in the file. The module-level caches are
61
+ // per-process, so only the first call actually builds a program — a later call
62
+ // reuses it and would measure a few MB no matter what.
63
+ //
64
+ // The schema generator builds a second ts.Program over the whole tsconfig —
65
+ // on a real tree that is ~2.5k files and ~600MB, and it used to be pinned at
66
+ // module scope for the life of the process. Combined with the main inspector
67
+ // program it pushed peak heap past 2GB and OOM'd `pikku all` (#982).
68
+ //
69
+ // Only the program is released; cachedTSSchemas (the small result cache that
70
+ // powers the same-process fast path) is deliberately kept.
71
+ test('does not retain the schema ts.Program after returning', async (t) => {
72
+ if (typeof global.gc !== 'function') {
73
+ t.skip('requires --expose-gc')
74
+ return
75
+ }
76
+
77
+ const state = getInitialInspectorState(projectDir) as any
78
+ // A distinct type keeps this off the cachedTSSchemas fast path, so the
79
+ // program is genuinely rebuilt (and therefore genuinely retainable) here.
80
+ state.functions.typesMap.addCustomType(
81
+ 'RetentionProbeInput',
82
+ '{ probe: string; n: number }',
83
+ []
84
+ )
85
+
86
+ global.gc!()
87
+ const before = process.memoryUsage().heapUsed
88
+
89
+ await generateAllSchemas(
90
+ logger,
91
+ { tsconfig: join(projectDir, 'tsconfig.json') },
92
+ state
93
+ )
94
+
95
+ global.gc!()
96
+ const retainedMB = (process.memoryUsage().heapUsed - before) / 1024 / 1024
97
+
98
+ // A retained program (with its SourceFiles and TypeChecker) measures tens of
99
+ // MB even for this one-file fixture; the schemas themselves are a few KB.
100
+ assert.ok(
101
+ retainedMB < 10,
102
+ `expected the schema program to be released, but ${retainedMB.toFixed(1)}MB is still retained after GC`
103
+ )
104
+ })
105
+
106
+ // The virtual file explicitly imports every type it needs, so it is a complete
107
+ // root on its own. Rooting the program at the whole tsconfig instead made it
108
+ // load the entire project (2572 files on a real tree vs 870), for no gain in
109
+ // what could be resolved (#982).
110
+ test('scopes the schema program to the virtual file import closure', async () => {
111
+ debugLines.length = 0
112
+
113
+ const state = getInitialInspectorState(projectDir) as any
114
+ state.functions.typesMap.addCustomType(
115
+ 'ScopingProbeInput',
116
+ '{ scope: string }',
117
+ []
118
+ )
119
+
120
+ await generateAllSchemas(
121
+ logger,
122
+ { tsconfig: join(projectDir, 'tsconfig.json') },
123
+ state
124
+ )
125
+
126
+ const line = debugLines.find((l) => l.includes('Created schema program'))
127
+ assert.ok(
128
+ line,
129
+ `expected a "Created schema program" debug line, got: ${debugLines.join(' | ')}`
130
+ )
131
+
132
+ const fileCount = Number(line!.match(/(\d+) files/)?.[1])
133
+ assert.ok(
134
+ Number.isFinite(fileCount),
135
+ `could not parse a file count out of: ${line}`
136
+ )
137
+ // The virtual file plus types.ts plus the default lib. Rooting at the whole
138
+ // tsconfig would pull in all UNRELATED_FILE_COUNT files on top of that.
139
+ assert.ok(
140
+ fileCount < UNRELATED_FILE_COUNT,
141
+ `expected the schema program to skip the ${UNRELATED_FILE_COUNT} unreferenced files, but it loaded ${fileCount}`
142
+ )
143
+ })
144
+
145
+ test('produces a schema for a custom type', async () => {
146
+ const state = getInitialInspectorState(projectDir) as any
147
+ state.functions.typesMap.addCustomType(
148
+ 'ThingInput',
149
+ '{ id: string; count: number }',
150
+ []
151
+ )
152
+
153
+ const schemas = await generateAllSchemas(
154
+ logger,
155
+ { tsconfig: join(projectDir, 'tsconfig.json') },
156
+ state
157
+ )
158
+
159
+ assert.ok(
160
+ Object.keys(schemas).length > 0,
161
+ 'expected at least one generated schema'
162
+ )
163
+ })
164
+ })
@@ -87,7 +87,9 @@ const inspectorVersion: string = (() => {
87
87
  try {
88
88
  const pkgUrl = new URL('../../package.json', import.meta.url)
89
89
  const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8'))
90
- return typeof pkg.version === 'string' ? pkg.version : `v${SCHEMA_CACHE_VERSION}`
90
+ return typeof pkg.version === 'string'
91
+ ? pkg.version
92
+ : `v${SCHEMA_CACHE_VERSION}`
91
93
  } catch {
92
94
  return `v${SCHEMA_CACHE_VERSION}`
93
95
  }
@@ -98,7 +100,10 @@ const inspectorVersion: string = (() => {
98
100
  // inspector version (schema-format changes ship with a version bump).
99
101
  function tsSchemaCacheKey(
100
102
  customTypesContent: string,
101
- config: { schemasFromTypes?: string[]; schema?: { additionalProperties?: boolean } }
103
+ config: {
104
+ schemasFromTypes?: string[]
105
+ schema?: { additionalProperties?: boolean }
106
+ }
102
107
  ): string {
103
108
  return createHash('sha1')
104
109
  .update(`v${SCHEMA_CACHE_VERSION}\0`)
@@ -145,6 +150,7 @@ function writeDiskTSSchemas(
145
150
  }
146
151
 
147
152
  function createProgramWithVirtualFile(
153
+ logger: InspectorLogger,
148
154
  tsconfig: string,
149
155
  virtualFilePath: string,
150
156
  virtualFileContent: string
@@ -165,7 +171,11 @@ function createProgramWithVirtualFile(
165
171
  }
166
172
 
167
173
  const resolvedVirtualPath = resolve(virtualFilePath)
168
- const fileNames = [...cachedParsedConfig.fileNames, resolvedVirtualPath]
174
+ // The virtual file imports every type it references, so it is a complete root
175
+ // on its own and TypeScript pulls in exactly the transitive closure it needs.
176
+ // Rooting at cachedParsedConfig.fileNames instead loaded the whole project
177
+ // (2572 files vs 870 on a real tree) without widening what could be resolved.
178
+ const fileNames = [resolvedVirtualPath]
169
179
 
170
180
  const defaultHost = ts.createCompilerHost(cachedParsedConfig.options)
171
181
  const customHost: ts.CompilerHost = {
@@ -200,6 +210,7 @@ function createProgramWithVirtualFile(
200
210
  },
201
211
  }
202
212
 
213
+ const startProgram = performance.now()
203
214
  const program = ts.createProgram(
204
215
  fileNames,
205
216
  cachedParsedConfig.options,
@@ -207,6 +218,9 @@ function createProgramWithVirtualFile(
207
218
  cachedSchemaProgram // reuse previous program for incremental compilation
208
219
  )
209
220
  cachedSchemaProgram = program
221
+ logger.debug(
222
+ `Created schema program in ${(performance.now() - startProgram).toFixed(0)}ms (${program.getSourceFiles().length} files)`
223
+ )
210
224
  return program
211
225
  }
212
226
 
@@ -274,6 +288,7 @@ function generateTSSchemas(
274
288
  '__pikku_virtual_types__.ts'
275
289
  )
276
290
  const program = createProgramWithVirtualFile(
291
+ logger,
277
292
  tsconfig,
278
293
  virtualFilePath,
279
294
  customTypesContent
@@ -614,6 +629,14 @@ export async function generateAllSchemas(
614
629
  zodSchemas
615
630
  )
616
631
 
632
+ // Release the program once the schemas are out of it. It spans the whole
633
+ // tsconfig (~2.5k files, ~600MB on a large tree) and was previously pinned at
634
+ // module scope for the life of the process, so it stacked on top of the main
635
+ // inspector program and pushed `pikku all` past a 2GB heap (#982). The result
636
+ // cache below is what the same-process fast path actually needs; the program
637
+ // only bought incremental reuse on a rebuild, at a cost that dwarfs it.
638
+ cachedSchemaProgram = undefined
639
+
617
640
  cachedCustomTypesContent = customTypesContent
618
641
  cachedTSSchemas = tsSchemas
619
642