@pikku/inspector 0.12.21 → 0.12.23

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 +30 -0
  2. package/dist/add/add-addon-bans.d.ts +7 -0
  3. package/dist/add/add-addon-bans.js +65 -0
  4. package/dist/add/add-channel.js +47 -6
  5. package/dist/add/add-cli.js +17 -0
  6. package/dist/add/add-functions.js +16 -8
  7. package/dist/add/add-http-route.d.ts +11 -1
  8. package/dist/add/add-http-route.js +37 -0
  9. package/dist/add/add-http-routes.d.ts +0 -3
  10. package/dist/add/add-http-routes.js +179 -36
  11. package/dist/add/add-workflow.js +16 -2
  12. package/dist/error-codes.d.ts +15 -1
  13. package/dist/error-codes.js +3 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/inspector.js +22 -6
  16. package/dist/types.d.ts +53 -2
  17. package/dist/utils/extract-node-value.js +19 -2
  18. package/dist/utils/get-exported-variable-name.d.ts +2 -0
  19. package/dist/utils/get-exported-variable-name.js +34 -0
  20. package/dist/utils/load-addon-functions-meta.js +98 -0
  21. package/dist/utils/resolve-addon-package.js +3 -1
  22. package/dist/utils/resolve-ref-contract.d.ts +21 -0
  23. package/dist/utils/resolve-ref-contract.js +46 -0
  24. package/dist/utils/serialize-inspector-state.d.ts +1 -0
  25. package/dist/utils/serialize-inspector-state.js +9 -0
  26. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +15 -0
  27. package/dist/visit.js +24 -19
  28. package/package.json +2 -2
  29. package/src/add/add-addon-bans.ts +84 -0
  30. package/src/add/add-auth.test.ts +3 -0
  31. package/src/add/add-channel.ts +66 -7
  32. package/src/add/add-cli-renderers.test.ts +1 -0
  33. package/src/add/add-cli.ts +30 -0
  34. package/src/add/add-functions.test.ts +13 -0
  35. package/src/add/add-functions.ts +14 -10
  36. package/src/add/add-http-route.ts +75 -1
  37. package/src/add/add-http-routes.ts +283 -41
  38. package/src/add/add-workflow-fanout.test.ts +106 -0
  39. package/src/add/add-workflow.test.ts +3 -0
  40. package/src/add/add-workflow.ts +16 -2
  41. package/src/add/addon-bans.test.ts +121 -0
  42. package/src/add/addon-contracts.test.ts +221 -0
  43. package/src/add/pii-check.test.ts +4 -0
  44. package/src/add/wire-name-literal.test.ts +3 -0
  45. package/src/error-codes.ts +18 -0
  46. package/src/index.ts +1 -0
  47. package/src/inspector.ts +25 -6
  48. package/src/types.ts +75 -2
  49. package/src/utils/extract-node-value.test.ts +49 -1
  50. package/src/utils/extract-node-value.ts +19 -2
  51. package/src/utils/filter-inspector-state.test.ts +1 -0
  52. package/src/utils/filter-utils.test.ts +1 -0
  53. package/src/utils/get-exported-variable-name.ts +48 -0
  54. package/src/utils/load-addon-functions-meta.ts +164 -0
  55. package/src/utils/resolve-addon-package.ts +6 -1
  56. package/src/utils/resolve-ref-contract.ts +71 -0
  57. package/src/utils/resolve-versions.test.ts +1 -0
  58. package/src/utils/serialize-inspector-state.ts +10 -0
  59. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +16 -0
  60. package/src/visit.ts +26 -19
  61. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,121 @@
1
+ import { strict as assert } from 'node: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
+ const recordingLogger = () => {
11
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
12
+ const logger: InspectorLogger = {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ critical: (code, message) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ hasCriticalErrors: () => criticals.length > 0,
21
+ }
22
+ return { logger, criticals }
23
+ }
24
+
25
+ const withTempApp = async (
26
+ source: string,
27
+ run: (file: string, rootDir: string) => Promise<void>
28
+ ) => {
29
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-addon-bans-'))
30
+ const file = join(rootDir, 'app.ts')
31
+ await writeFile(
32
+ join(rootDir, 'package.json'),
33
+ JSON.stringify({ name: 'test-addon', type: 'module' }, null, 2)
34
+ )
35
+ await writeFile(file, source)
36
+ try {
37
+ await run(file, rootDir)
38
+ } finally {
39
+ await rm(rootDir, { recursive: true, force: true })
40
+ }
41
+ }
42
+
43
+ describe('addon authoring bans', () => {
44
+ const wireHTTPSource = [
45
+ "import { wireHTTP } from '@pikku/core/http'",
46
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
47
+ 'const f = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
48
+ "wireHTTP({ method: 'get', route: '/a', func: f })",
49
+ ].join('\n')
50
+
51
+ const defineWithMiddlewareSource = [
52
+ "import { defineHTTPRoutes } from '@pikku/core/http'",
53
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
54
+ 'const f = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
55
+ 'const mw = (async (_s: any, _w: any, next: any) => next()) as any',
56
+ "export const routes = defineHTTPRoutes({ basePath: '/x', routes: { a: { method: 'get', route: '/a', func: f, middleware: [mw] } } })",
57
+ ].join('\n')
58
+
59
+ test('wire* inside an addon is a critical error', async () => {
60
+ await withTempApp(wireHTTPSource, async (file, rootDir) => {
61
+ const { logger, criticals } = recordingLogger()
62
+ await inspect(logger, [file], { rootDir, isAddon: true })
63
+ assert.ok(
64
+ criticals.some((c) => c.code === ErrorCode.ADDON_WIRING_NOT_ALLOWED),
65
+ `expected ADDON_WIRING_NOT_ALLOWED, got ${JSON.stringify(criticals)}`
66
+ )
67
+ })
68
+ })
69
+
70
+ test('wire* outside an addon is allowed', async () => {
71
+ await withTempApp(wireHTTPSource, async (file, rootDir) => {
72
+ const { logger, criticals } = recordingLogger()
73
+ await inspect(logger, [file], { rootDir })
74
+ assert.ok(
75
+ !criticals.some((c) => c.code === ErrorCode.ADDON_WIRING_NOT_ALLOWED),
76
+ `expected no addon-wiring ban, got ${JSON.stringify(criticals)}`
77
+ )
78
+ })
79
+ })
80
+
81
+ test('define* carrying middleware inside an addon is a critical error', async () => {
82
+ await withTempApp(defineWithMiddlewareSource, async (file, rootDir) => {
83
+ const { logger, criticals } = recordingLogger()
84
+ await inspect(logger, [file], { rootDir, isAddon: true })
85
+ assert.ok(
86
+ criticals.some(
87
+ (c) => c.code === ErrorCode.ADDON_CONTRACT_HANDLERS_NOT_ALLOWED
88
+ ),
89
+ `expected ADDON_CONTRACT_HANDLERS_NOT_ALLOWED, got ${JSON.stringify(criticals)}`
90
+ )
91
+ })
92
+ })
93
+
94
+ test('define* carrying middleware outside an addon is allowed', async () => {
95
+ await withTempApp(defineWithMiddlewareSource, async (file, rootDir) => {
96
+ const { logger, criticals } = recordingLogger()
97
+ await inspect(logger, [file], { rootDir })
98
+ assert.ok(
99
+ !criticals.some(
100
+ (c) => c.code === ErrorCode.ADDON_CONTRACT_HANDLERS_NOT_ALLOWED
101
+ ),
102
+ `expected no contract-handlers ban, got ${JSON.stringify(criticals)}`
103
+ )
104
+ })
105
+ })
106
+
107
+ test('wireSecret inside an addon is allowed', async () => {
108
+ const source = [
109
+ "import { wireSecret } from '@pikku/core'",
110
+ "wireSecret({ name: 'example', secretId: 'EXAMPLE' } as any)",
111
+ ].join('\n')
112
+ await withTempApp(source, async (file, rootDir) => {
113
+ const { logger, criticals } = recordingLogger()
114
+ await inspect(logger, [file], { rootDir, isAddon: true })
115
+ assert.ok(
116
+ !criticals.some((c) => c.code === ErrorCode.ADDON_WIRING_NOT_ALLOWED),
117
+ `expected wireSecret to be allowed, got ${JSON.stringify(criticals)}`
118
+ )
119
+ })
120
+ })
121
+ })
@@ -0,0 +1,221 @@
1
+ import { strict as assert } from 'node:assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdir, 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 logger: InspectorLogger = {
10
+ debug: () => {},
11
+ info: () => {},
12
+ warn: () => {},
13
+ error: () => {},
14
+ critical: () => {},
15
+ hasCriticalErrors: () => false,
16
+ }
17
+
18
+ describe('addon contract metadata', () => {
19
+ test('collects exported local define contracts', async () => {
20
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-exported-contracts-'))
21
+ const file = join(rootDir, 'contracts.ts')
22
+
23
+ await writeFile(
24
+ join(rootDir, 'package.json'),
25
+ JSON.stringify({ name: 'test-app', type: 'module' }, null, 2)
26
+ )
27
+
28
+ await writeFile(
29
+ file,
30
+ [
31
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
32
+ "import { defineHTTPRoutes } from '@pikku/core/http'",
33
+ "import { defineChannelRoutes } from '@pikku/core/channel'",
34
+ "import { defineCLICommands } from '@pikku/core/cli'",
35
+ 'export const listTodos = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
36
+ 'export const subscribeTodo = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
37
+ 'export const runSync = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
38
+ "export const httpRoutes = defineHTTPRoutes({ basePath: '/todos', routes: { list: { method: 'get', route: '/', func: listTodos } } })",
39
+ 'export const channelRoutes = defineChannelRoutes({ subscribe: { func: subscribeTodo } })',
40
+ 'export const cliCommands = defineCLICommands({ sync: { func: runSync, options: {} } })',
41
+ ].join('\n')
42
+ )
43
+
44
+ try {
45
+ const state = await inspect(logger, [file], { rootDir })
46
+ assert.equal(
47
+ state.exportedContracts.http.httpRoutes?.routes.list?.func.pikkuFuncId,
48
+ 'listTodos'
49
+ )
50
+ assert.equal(
51
+ state.exportedContracts.channel.channelRoutes?.subscribe?.pikkuFuncId,
52
+ 'subscribeTodo'
53
+ )
54
+ assert.equal(
55
+ state.exportedContracts.cli.cliCommands?.sync?.pikkuFuncId,
56
+ 'runSync'
57
+ )
58
+ } finally {
59
+ await rm(rootDir, { recursive: true, force: true })
60
+ }
61
+ })
62
+
63
+ test('expands addon contracts referenced via refHTTP/refChannel/refCLI', async () => {
64
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-addon-refs-app-'))
65
+ const nodeModulesDir = join(rootDir, 'node_modules', '@test', 'addon')
66
+ const appFile = join(rootDir, 'app.ts')
67
+
68
+ await mkdir(join(nodeModulesDir, '.pikku', 'function'), { recursive: true })
69
+ await mkdir(join(nodeModulesDir, '.pikku', 'http'), { recursive: true })
70
+ await mkdir(join(nodeModulesDir, '.pikku', 'cli'), { recursive: true })
71
+ await mkdir(join(nodeModulesDir, '.pikku', 'channel'), { recursive: true })
72
+
73
+ await writeFile(
74
+ join(rootDir, 'package.json'),
75
+ JSON.stringify({ name: 'test-app', type: 'module' }, null, 2)
76
+ )
77
+ await writeFile(
78
+ join(nodeModulesDir, 'package.json'),
79
+ JSON.stringify({ name: '@test/addon', type: 'module' }, null, 2)
80
+ )
81
+ await writeFile(
82
+ join(
83
+ nodeModulesDir,
84
+ '.pikku',
85
+ 'function',
86
+ 'pikku-functions-meta.gen.json'
87
+ ),
88
+ JSON.stringify(
89
+ {
90
+ listTodos: {
91
+ pikkuFuncId: 'listTodos',
92
+ inputSchemaName: null,
93
+ outputSchemaName: null,
94
+ sessionless: true,
95
+ },
96
+ runSync: {
97
+ pikkuFuncId: 'runSync',
98
+ inputSchemaName: null,
99
+ outputSchemaName: null,
100
+ sessionless: true,
101
+ },
102
+ subscribeTodo: {
103
+ pikkuFuncId: 'subscribeTodo',
104
+ inputSchemaName: null,
105
+ outputSchemaName: null,
106
+ sessionless: true,
107
+ },
108
+ },
109
+ null,
110
+ 2
111
+ )
112
+ )
113
+ await writeFile(
114
+ join(
115
+ nodeModulesDir,
116
+ '.pikku',
117
+ 'http',
118
+ 'pikku-http-contracts-meta.gen.json'
119
+ ),
120
+ JSON.stringify(
121
+ {
122
+ httpRoutes: {
123
+ basePath: '/addon',
124
+ routes: {
125
+ list: {
126
+ method: 'get',
127
+ route: '/todos',
128
+ func: { pikkuFuncId: 'listTodos' },
129
+ },
130
+ },
131
+ },
132
+ },
133
+ null,
134
+ 2
135
+ )
136
+ )
137
+ await writeFile(
138
+ join(
139
+ nodeModulesDir,
140
+ '.pikku',
141
+ 'cli',
142
+ 'pikku-cli-contracts-meta.gen.json'
143
+ ),
144
+ JSON.stringify(
145
+ {
146
+ cliCommands: {
147
+ sync: {
148
+ pikkuFuncId: 'runSync',
149
+ positionals: [],
150
+ options: {},
151
+ },
152
+ },
153
+ },
154
+ null,
155
+ 2
156
+ )
157
+ )
158
+ await writeFile(
159
+ join(
160
+ nodeModulesDir,
161
+ '.pikku',
162
+ 'channel',
163
+ 'pikku-channel-contracts-meta.gen.json'
164
+ ),
165
+ JSON.stringify(
166
+ {
167
+ channelRoutes: {
168
+ subscribe: {
169
+ pikkuFuncId: 'subscribeTodo',
170
+ },
171
+ },
172
+ },
173
+ null,
174
+ 2
175
+ )
176
+ )
177
+
178
+ await writeFile(
179
+ appFile,
180
+ [
181
+ "import { wireAddon } from '@pikku/core/rpc'",
182
+ "import { wireHTTPRoutes } from '@pikku/core/http'",
183
+ "import { wireChannel } from '@pikku/core/channel'",
184
+ "import { wireCLI } from '@pikku/core/cli'",
185
+ 'declare const refHTTP: (name: string) => any',
186
+ 'declare const refChannel: (name: string) => any',
187
+ 'declare const refCLI: (name: string) => any',
188
+ "wireAddon({ name: 'addon', package: '@test/addon' })",
189
+ "wireHTTPRoutes({ basePath: '/api', routes: { keep: refHTTP('addon:httpRoutes'), moved: refHTTP('addon:httpRoutes', { basePath: '/ext' }) } })",
190
+ "wireChannel({ name: 'live', route: '/live', auth: false, onMessageWiring: { action: refChannel('addon:channelRoutes') } })",
191
+ "wireCLI({ program: 'app', commands: { ...refCLI('addon:cliCommands') } })",
192
+ ].join('\n')
193
+ )
194
+
195
+ try {
196
+ const state = await inspect(logger, [appFile], { rootDir })
197
+
198
+ // Default: the addon contract's own basePath ('/addon') is preserved —
199
+ // the cosmetic slot key ('keep') does not affect the path.
200
+ const httpEntry = state.http.meta.get['/api/addon/todos']
201
+ assert.equal(httpEntry?.packageName, '@test/addon')
202
+ assert.equal(httpEntry?.pikkuFuncId, 'addon:listTodos')
203
+
204
+ // Override: the second-arg basePath ('/ext') replaces the addon's own.
205
+ const overridden = state.http.meta.get['/api/ext/todos']
206
+ assert.equal(overridden?.packageName, '@test/addon')
207
+ assert.equal(overridden?.pikkuFuncId, 'addon:listTodos')
208
+
209
+ const channelEntry =
210
+ state.channels.meta.live?.messageWirings.action?.subscribe
211
+ assert.equal(channelEntry?.packageName, '@test/addon')
212
+ assert.equal(channelEntry?.pikkuFuncId, 'addon:subscribeTodo')
213
+
214
+ const cliEntry = state.cli.meta.programs.app?.commands.sync
215
+ assert.equal(cliEntry?.packageName, '@test/addon')
216
+ assert.equal(cliEntry?.pikkuFuncId, 'addon:runSync')
217
+ } finally {
218
+ await rm(rootDir, { recursive: true, force: true })
219
+ }
220
+ })
221
+ })
@@ -10,12 +10,16 @@ import type { InspectorLogger } from '../types.js'
10
10
  // ── helpers ──────────────────────────────────────────────────────────────────
11
11
 
12
12
  function makeLogger() {
13
+ // Collects every coded diagnostic regardless of severity. PKU910 is now
14
+ // emitted at 'error' severity (surface, don't block the dev server) so it
15
+ // arrives via `diagnostic`, not `critical`.
13
16
  const criticals: Array<{ code: ErrorCode; message: string }> = []
14
17
  const logger: InspectorLogger = {
15
18
  debug: () => {},
16
19
  info: () => {},
17
20
  warn: () => {},
18
21
  error: () => {},
22
+ diagnostic: ({ code, message }) => criticals.push({ code, message }),
19
23
  critical: (code, message) => criticals.push({ code, message }),
20
24
  hasCriticalErrors: () => criticals.length > 0,
21
25
  }
@@ -13,6 +13,9 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
13
13
  info: () => {},
14
14
  warn: () => {},
15
15
  error: () => {},
16
+ diagnostic: ({ code, message }) => {
17
+ criticals.push({ code, message })
18
+ },
16
19
  critical: (code: ErrorCode, message: string) => {
17
20
  criticals.push({ code, message })
18
21
  },
@@ -84,4 +84,22 @@ export enum ErrorCode {
84
84
 
85
85
  // Data classification errors
86
86
  PII_IN_OUTPUT = 'PKU910',
87
+
88
+ // Addon authoring errors
89
+ ADDON_WIRING_NOT_ALLOWED = 'PKU920',
90
+ ADDON_CONTRACT_HANDLERS_NOT_ALLOWED = 'PKU921',
91
+ }
92
+
93
+ /**
94
+ * Severity of a tracked, coded diagnostic. `critical` always blocks the build;
95
+ * `error`/`warn` only block when the CLI is told to via `--fail-on-error` /
96
+ * `--fail-on-warn` (default: critical only). All severities are still printed.
97
+ */
98
+ export type DiagnosticSeverity = 'warn' | 'error' | 'critical'
99
+
100
+ /** A coded diagnostic emitted via `logger.diagnostic(...)`. */
101
+ export interface CodedDiagnostic {
102
+ severity: DiagnosticSeverity
103
+ code: ErrorCode
104
+ message: string
87
105
  }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export type { TypesMap } from './types-map.js'
3
3
  export type * from './types.js'
4
4
  export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js'
5
5
  export { ErrorCode } from './error-codes.js'
6
+ export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js'
6
7
  export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js'
7
8
  export {
8
9
  serializeInspectorState,
package/src/inspector.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import * as ts from 'typescript'
2
2
  import { performance } from 'perf_hooks'
3
+ import { resolve } from 'path'
3
4
  import { visitSetup, visitRoutes } from './visit.js'
4
5
  import { TypesMap } from './types-map.js'
5
6
  import type {
@@ -216,6 +217,14 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
216
217
  openAPISpec: null,
217
218
  diagnostics: [],
218
219
  addonFunctions: {},
220
+ exportedContracts: {
221
+ http: {},
222
+ cli: {},
223
+ channel: {},
224
+ addonHttp: {},
225
+ addonCli: {},
226
+ addonChannel: {},
227
+ },
219
228
  program: null,
220
229
  }
221
230
  }
@@ -225,6 +234,7 @@ export const inspect = async (
225
234
  routeFiles: string[],
226
235
  options: InspectorOptions = {}
227
236
  ): Promise<InspectorState> => {
237
+ const normalizedRouteFiles = routeFiles.map((file) => resolve(file))
228
238
  const compilerOptions: ts.CompilerOptions = {
229
239
  target: ts.ScriptTarget.ESNext,
230
240
  module: ts.ModuleKind.Node16,
@@ -237,13 +247,13 @@ export const inspect = async (
237
247
  }
238
248
  const startProgram = performance.now()
239
249
  const program = ts.createProgram(
240
- routeFiles,
250
+ normalizedRouteFiles,
241
251
  compilerOptions,
242
252
  undefined, // host
243
253
  options.oldProgram
244
254
  )
245
255
  logger.debug(
246
- `Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${routeFiles.length} files${options.oldProgram ? ', incremental' : ''})`
256
+ `Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files${options.oldProgram ? ', incremental' : ''})`
247
257
  )
248
258
 
249
259
  const startChecker = performance.now()
@@ -253,12 +263,19 @@ export const inspect = async (
253
263
  )
254
264
 
255
265
  // Use provided rootDir or infer from source files
256
- const rootDir = options.rootDir || findCommonAncestor(routeFiles)
266
+ const rootDir = options.rootDir || findCommonAncestor(normalizedRouteFiles)
257
267
 
258
268
  const startSourceFiles = performance.now()
269
+ // node_modules under rootDir (e.g. a locally-installed addon) is a
270
+ // dependency, not project source — scanning it double-counts the addon's
271
+ // own application types (CoreConfig/Services/SingletonServices).
259
272
  const sourceFiles = program
260
273
  .getSourceFiles()
261
- .filter((sf) => sf.fileName.startsWith(rootDir))
274
+ .filter(
275
+ (sf) =>
276
+ sf.fileName.startsWith(rootDir) &&
277
+ !sf.fileName.includes('/node_modules/')
278
+ )
262
279
  logger.debug(
263
280
  `Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`
264
281
  )
@@ -268,8 +285,9 @@ export const inspect = async (
268
285
  // First sweep: add all functions
269
286
  const startSetup = performance.now()
270
287
  for (const sourceFile of sourceFiles) {
288
+ const sourceOptions = { ...options, sourceFile }
271
289
  ts.forEachChild(sourceFile, (child) =>
272
- visitSetup(logger, checker, child, state, options)
290
+ visitSetup(logger, checker, child, state, sourceOptions)
273
291
  )
274
292
  }
275
293
  logger.debug(
@@ -283,8 +301,9 @@ export const inspect = async (
283
301
  // Second sweep: add all transports
284
302
  const startRoutes = performance.now()
285
303
  for (const sourceFile of sourceFiles) {
304
+ const sourceOptions = { ...options, sourceFile }
286
305
  ts.forEachChild(sourceFile, (child) =>
287
- visitRoutes(logger, checker, child, state, options)
306
+ visitRoutes(logger, checker, child, state, sourceOptions)
288
307
  )
289
308
  }
290
309
  logger.debug(
package/src/types.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type * as ts from 'typescript'
2
- import type { ChannelsMeta } from '@pikku/core/channel'
2
+ import type { ChannelMessageMeta, ChannelsMeta } from '@pikku/core/channel'
3
3
  import type { GatewaysMeta } from '@pikku/core/gateway'
4
4
  import type { HTTPWiringsMeta } from '@pikku/core/http'
5
5
  import type { ScheduledTasksMeta } from '@pikku/core/scheduler'
@@ -13,6 +13,7 @@ import type {
13
13
  } from '@pikku/core/mcp'
14
14
  import type { AIAgentMeta } from '@pikku/core/ai-agent'
15
15
  import type { CLIMeta } from '@pikku/core/cli'
16
+ import type { CLICommandMeta } from '@pikku/core/cli'
16
17
  import type { NodesMeta } from '@pikku/core/node'
17
18
  import type { SecretDefinitions } from '@pikku/core/secret'
18
19
  import type { CredentialDefinitions } from '@pikku/core/credential'
@@ -25,7 +26,7 @@ import type {
25
26
  JSONValue,
26
27
  } from '@pikku/core'
27
28
  import type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
28
- import type { ErrorCode } from './error-codes.js'
29
+ import type { ErrorCode, CodedDiagnostic } from './error-codes.js'
29
30
  import type {
30
31
  VersionManifest,
31
32
  VersionValidateError,
@@ -107,6 +108,67 @@ export interface InspectorChannelState {
107
108
  files: Set<string>
108
109
  }
109
110
 
111
+ export interface ExportedHTTPRouteFunctionMeta {
112
+ pikkuFuncId: string
113
+ packageName?: string
114
+ }
115
+
116
+ export interface ExportedHTTPRouteConfigMeta {
117
+ method: string
118
+ route: string
119
+ func: ExportedHTTPRouteFunctionMeta
120
+ auth?: boolean
121
+ tags?: string[]
122
+ sse?: boolean
123
+ contentType?: string
124
+ timeout?: number
125
+ headers?: Record<string, string>
126
+ }
127
+
128
+ export interface ExportedHTTPRoutesGroupMeta {
129
+ basePath?: string
130
+ tags?: string[]
131
+ auth?: boolean
132
+ routes: ExportedHTTPRouteMapMeta
133
+ }
134
+
135
+ export type ExportedHTTPRouteEntryMeta =
136
+ | ExportedHTTPRouteConfigMeta
137
+ | ExportedHTTPRoutesGroupMeta
138
+ | ExportedHTTPRouteMapMeta
139
+
140
+ export interface ExportedHTTPRouteMapMeta {
141
+ [key: string]: ExportedHTTPRouteEntryMeta
142
+ }
143
+
144
+ export type ExportedHTTPContractsMeta = Record<
145
+ string,
146
+ ExportedHTTPRoutesGroupMeta
147
+ >
148
+
149
+ export interface ExportedChannelRouteMeta extends ChannelMessageMeta {
150
+ auth?: boolean
151
+ }
152
+
153
+ export type ExportedChannelContractsMeta = Record<
154
+ string,
155
+ Record<string, ExportedChannelRouteMeta>
156
+ >
157
+
158
+ export type ExportedCLIContractsMeta = Record<
159
+ string,
160
+ Record<string, CLICommandMeta>
161
+ >
162
+
163
+ export interface InspectorExportedContractsState {
164
+ http: ExportedHTTPContractsMeta
165
+ cli: ExportedCLIContractsMeta
166
+ channel: ExportedChannelContractsMeta
167
+ addonHttp: Record<string, ExportedHTTPContractsMeta>
168
+ addonCli: Record<string, ExportedCLIContractsMeta>
169
+ addonChannel: Record<string, ExportedChannelContractsMeta>
170
+ }
171
+
110
172
  export interface InspectorMiddlewareDefinition {
111
173
  services: FunctionServicesMeta
112
174
  wires?: FunctionWiresMeta
@@ -214,6 +276,7 @@ export type InspectorOptions = Partial<{
214
276
  setupOnly: boolean
215
277
  rootDir: string
216
278
  isAddon: boolean
279
+ sourceFile: ts.SourceFile
217
280
  types: Partial<{
218
281
  configFileType: string
219
282
  userSessionType: string
@@ -238,6 +301,15 @@ export interface InspectorLogger {
238
301
  error: (message: string) => void
239
302
  warn: (message: string) => void
240
303
  debug: (message: string) => void
304
+ /**
305
+ * Emit a tracked, coded diagnostic. It is recorded and printed; `error`/`warn`
306
+ * only block the build when the CLI is run with `--fail-on-error` /
307
+ * `--fail-on-warn` (default: critical only). Use this for issues worth
308
+ * surfacing (e.g. data-classification leaks) that should not stop the dev
309
+ * server from starting.
310
+ */
311
+ diagnostic: (diagnostic: CodedDiagnostic) => void
312
+ /** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
241
313
  critical: (code: ErrorCode, message: string) => void
242
314
  hasCriticalErrors: () => boolean
243
315
  }
@@ -476,5 +548,6 @@ export interface InspectorState {
476
548
  openAPISpec: Record<string, any> | null
477
549
  diagnostics: InspectorDiagnostic[]
478
550
  addonFunctions: Record<string, FunctionsMeta> // namespace -> addon's function metadata
551
+ exportedContracts: InspectorExportedContractsState
479
552
  program: ts.Program | null // Retained for incremental re-inspection
480
553
  }
@@ -1,7 +1,7 @@
1
1
  import { test, describe } from 'node:test'
2
2
  import { strict as assert } from 'node:assert'
3
3
  import * as ts from 'typescript'
4
- import { extractDescription } from './extract-node-value'
4
+ import { extractDescription, extractStringLiteral } from './extract-node-value'
5
5
 
6
6
  const createChecker = (source: string) => {
7
7
  const sourceFile = ts.createSourceFile(
@@ -67,3 +67,51 @@ describe('extractDescription', () => {
67
67
  assert.equal(extractDescription(sourceFile, checker), null)
68
68
  })
69
69
  })
70
+
71
+ describe('extractStringLiteral — concatenation/template symmetry', () => {
72
+ const findInitializer = (node: ts.Node): ts.Expression | undefined => {
73
+ if (ts.isVariableDeclaration(node) && node.initializer) {
74
+ return node.initializer
75
+ }
76
+ let result: ts.Expression | undefined
77
+ ts.forEachChild(node, (child) => {
78
+ if (!result) result = findInitializer(child)
79
+ })
80
+ return result
81
+ }
82
+
83
+ test('a `+` operand that cannot be statically resolved becomes a ${...} placeholder', () => {
84
+ const { checker, sourceFile } = createChecker(
85
+ `const x = 'Enrich event ' + (event.id ?? event.name)`
86
+ )
87
+ const init = findInitializer(sourceFile)!
88
+ assert.equal(
89
+ extractStringLiteral(init, checker),
90
+ 'Enrich event ${event.id ?? event.name}'
91
+ )
92
+ })
93
+
94
+ test('`+` concatenation and template literal produce the same display string', () => {
95
+ const concat = createChecker(
96
+ `const x = 'Enrich event ' + (event.id ?? event.name)`
97
+ )
98
+ const template = createChecker(
99
+ 'const x = `Enrich event ${event.id ?? event.name}`'
100
+ )
101
+ const concatValue = extractStringLiteral(
102
+ findInitializer(concat.sourceFile)!,
103
+ concat.checker
104
+ )
105
+ const templateValue = extractStringLiteral(
106
+ findInitializer(template.sourceFile)!,
107
+ template.checker
108
+ )
109
+ assert.equal(concatValue, templateValue)
110
+ })
111
+
112
+ test('still resolves fully-static concatenation exactly', () => {
113
+ const { checker, sourceFile } = createChecker(`const x = 'a' + 'b' + 'c'`)
114
+ const init = findInitializer(sourceFile)!
115
+ assert.equal(extractStringLiteral(init, checker), 'abc')
116
+ })
117
+ })