@pikku/inspector 0.12.13 → 0.12.16

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 (54) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/add/add-ai-agent.js +1 -1
  3. package/dist/add/add-auth.d.ts +2 -0
  4. package/dist/add/add-auth.js +34 -0
  5. package/dist/add/add-channel.js +25 -7
  6. package/dist/add/add-functions.js +28 -13
  7. package/dist/add/add-gateway.js +1 -1
  8. package/dist/add/add-http-route.js +23 -1
  9. package/dist/add/add-mcp-prompt.js +1 -1
  10. package/dist/add/add-mcp-resource.js +1 -1
  11. package/dist/add/add-queue-worker.js +1 -1
  12. package/dist/add/add-schedule.js +1 -1
  13. package/dist/add/add-trigger.js +1 -1
  14. package/dist/add/add-workflow.js +1 -1
  15. package/dist/inspector.js +4 -0
  16. package/dist/types.d.ts +4 -0
  17. package/dist/utils/check-pii-output.d.ts +13 -8
  18. package/dist/utils/check-pii-output.js +22 -12
  19. package/dist/utils/ensure-function-metadata.js +1 -1
  20. package/dist/utils/extract-node-value.d.ts +1 -1
  21. package/dist/utils/extract-node-value.js +10 -1
  22. package/dist/utils/get-property-value.d.ts +1 -1
  23. package/dist/utils/get-property-value.js +35 -9
  24. package/dist/utils/serialize-inspector-state.d.ts +4 -0
  25. package/dist/utils/serialize-inspector-state.js +8 -0
  26. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +20 -9
  27. package/dist/visit.js +2 -0
  28. package/package.json +2 -2
  29. package/src/add/add-ai-agent.ts +1 -1
  30. package/src/add/add-auth.test.ts +175 -0
  31. package/src/add/add-auth.ts +49 -0
  32. package/src/add/add-channel.ts +37 -7
  33. package/src/add/add-functions.ts +44 -13
  34. package/src/add/add-gateway.ts +1 -1
  35. package/src/add/add-http-route.ts +26 -1
  36. package/src/add/add-mcp-prompt.ts +1 -1
  37. package/src/add/add-mcp-resource.ts +1 -1
  38. package/src/add/add-queue-worker.ts +1 -1
  39. package/src/add/add-schedule.ts +1 -1
  40. package/src/add/add-trigger.ts +1 -1
  41. package/src/add/add-workflow.test.ts +152 -0
  42. package/src/add/add-workflow.ts +2 -1
  43. package/src/add/pii-check.test.ts +79 -32
  44. package/src/inspector.ts +4 -0
  45. package/src/types.ts +4 -0
  46. package/src/utils/check-pii-output.ts +41 -19
  47. package/src/utils/ensure-function-metadata.ts +3 -1
  48. package/src/utils/extract-node-value.test.ts +12 -10
  49. package/src/utils/extract-node-value.ts +15 -1
  50. package/src/utils/get-property-value.ts +33 -13
  51. package/src/utils/serialize-inspector-state.ts +12 -0
  52. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +22 -9
  53. package/src/visit.ts +2 -0
  54. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,152 @@
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
+ function makeLogger(
10
+ criticals: Array<{ code: string; message: string }>
11
+ ): InspectorLogger {
12
+ return {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ critical: (code: any, message: string) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ hasCriticalErrors: () => criticals.length > 0,
21
+ }
22
+ }
23
+
24
+ describe('addWorkflow — workflow.do RPC detection', () => {
25
+ test('detects RPC step when result is assigned to a const', async () => {
26
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-workflow-'))
27
+ const wfFile = join(rootDir, 'my.workflow.ts')
28
+ const stepFile = join(rootDir, 'my.steps.ts')
29
+
30
+ await writeFile(
31
+ stepFile,
32
+ [
33
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
34
+ 'export const doThing = pikkuSessionlessFunc({',
35
+ ' func: async ({ logger }) => ({ ok: true }),',
36
+ '})',
37
+ ].join('\n')
38
+ )
39
+
40
+ await writeFile(
41
+ wfFile,
42
+ [
43
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
44
+ 'export const myWorkflow = pikkuWorkflowFunc(async (_, _input, { workflow }) => {',
45
+ " const result = await workflow.do('Do thing', 'doThing', {})",
46
+ ' return { id: result.ok }',
47
+ '})',
48
+ ].join('\n')
49
+ )
50
+
51
+ const criticals: Array<{ code: string; message: string }> = []
52
+ try {
53
+ const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
54
+ rootDir,
55
+ })
56
+ assert.ok(
57
+ state.rpc.invokedFunctions.has('doThing'),
58
+ 'doThing should be in invokedFunctions'
59
+ )
60
+ assert.ok(
61
+ state.rpc.internalFiles.has('doThing'),
62
+ 'doThing should be in internalFiles'
63
+ )
64
+ } finally {
65
+ await rm(rootDir, { recursive: true, force: true })
66
+ }
67
+ })
68
+
69
+ test('detects RPC step when result is reassigned to a pre-declared null variable', async () => {
70
+ // Regression: `let x = null; x = await workflow.do(...)` was treated as a
71
+ // set-step instead of an RPC step, so the referenced function was never registered.
72
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-workflow-reassign-'))
73
+ const wfFile = join(rootDir, 'project.workflow.ts')
74
+ const stepFile = join(rootDir, 'project.steps.ts')
75
+
76
+ await writeFile(
77
+ stepFile,
78
+ [
79
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
80
+ 'export const launchSandbox = pikkuSessionlessFunc({',
81
+ ' func: async ({ logger }) => ({ sandboxId: "abc" }),',
82
+ '})',
83
+ ].join('\n')
84
+ )
85
+
86
+ await writeFile(
87
+ wfFile,
88
+ [
89
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
90
+ 'export const createProjectWorkflow = pikkuWorkflowFunc(async (_, input, { workflow }) => {',
91
+ ' let launched: { sandboxId: string } | null = null',
92
+ ' if (input.createSandbox) {',
93
+ " launched = await workflow.do('Launch sandbox', 'launchSandbox', { projectId: input.projectId })",
94
+ ' }',
95
+ ' return { sandboxId: launched?.sandboxId ?? null }',
96
+ '})',
97
+ ].join('\n')
98
+ )
99
+
100
+ const criticals: Array<{ code: string; message: string }> = []
101
+ try {
102
+ const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
103
+ rootDir,
104
+ })
105
+ assert.ok(
106
+ state.rpc.invokedFunctions.has('launchSandbox'),
107
+ 'launchSandbox should be in invokedFunctions even when assigned to a pre-declared null variable'
108
+ )
109
+ assert.ok(
110
+ state.rpc.internalFiles.has('launchSandbox'),
111
+ 'launchSandbox should be in internalFiles so it gets registered in pikku-functions.gen.ts'
112
+ )
113
+ } finally {
114
+ await rm(rootDir, { recursive: true, force: true })
115
+ }
116
+ })
117
+
118
+ test('still treats plain reassignment to context var as a set step', async () => {
119
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-workflow-set-'))
120
+ const wfFile = join(rootDir, 'set.workflow.ts')
121
+
122
+ await writeFile(
123
+ wfFile,
124
+ [
125
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
126
+ 'export const setWorkflow = pikkuWorkflowFunc(async (_, input, { workflow }) => {',
127
+ ' let status = "pending"',
128
+ ' status = "done"',
129
+ " await workflow.do('No-op', 'noopStep', {})",
130
+ ' return { status }',
131
+ '})',
132
+ ].join('\n')
133
+ )
134
+
135
+ const criticals: Array<{ code: string; message: string }> = []
136
+ try {
137
+ const state = await inspect(makeLogger(criticals), [wfFile], { rootDir })
138
+ const meta = state.workflows.meta['setWorkflow']
139
+ assert.ok(meta, 'workflow should be registered')
140
+ const steps = meta.steps ?? []
141
+ const setStep = steps.find(
142
+ (s: any) => s.type === 'set' && s.variable === 'status'
143
+ )
144
+ assert.ok(
145
+ setStep,
146
+ 'plain string reassignment should still produce a set step'
147
+ )
148
+ } finally {
149
+ await rm(rootDir, { recursive: true, force: true })
150
+ }
151
+ })
152
+ })
@@ -217,7 +217,8 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
217
217
  firstArg,
218
218
  'Workflow',
219
219
  workflowName,
220
- logger
220
+ logger,
221
+ checker
221
222
  )
222
223
  if (metadata.disabled) return
223
224
  tags = metadata.tags
@@ -23,13 +23,14 @@ function makeLogger() {
23
23
  }
24
24
 
25
25
  /**
26
- * Inline Private<T>/Secret<T> definitions that the test source files use.
26
+ * Inline Private<T>/Pii<T>/Secret<T> definitions that the test source files use.
27
27
  * Mirrors what schema.d.ts emits so the TypeScript program sees the correct
28
28
  * structural brand type even without @pikku/core being importable from /tmp.
29
29
  */
30
30
  const BRAND_TYPES = `
31
- type Private<T> = T & { readonly __pii__: 'private' }
32
- type Secret<T> = T & { readonly __pii__: 'secret' }
31
+ type Private<T> = T & { readonly __classification__: 'private' }
32
+ type Pii<T> = T & { readonly __classification__: 'pii' }
33
+ type Secret<T> = T & { readonly __classification__: 'secret' }
33
34
  `
34
35
 
35
36
  async function runInspect(sourceCode: string) {
@@ -45,30 +46,35 @@ async function runInspect(sourceCode: string) {
45
46
  return criticals
46
47
  }
47
48
 
48
- // ── findPiiPaths unit tests via full inspect() round-trip ────────────────────
49
+ // ── classification semantics:
50
+ // secret → never returned by any function (sessioned or not)
51
+ // private → only blocked in sessionless functions (pikkuSessionlessFunc)
52
+ // public → safe for sessionless functions
49
53
 
50
54
  describe('PII output check — PKU910', () => {
51
- test('flags a top-level Private<string> field', async () => {
55
+ // ── Secret<T>: always blocked ──────────────────────────────────────────────
56
+
57
+ test('flags a top-level Secret<string> field in a sessioned function', async () => {
52
58
  const criticals = await runInspect(`
53
59
  ${BRAND_TYPES}
54
60
  import { pikkuFunc } from '@pikku/core'
55
- export const getUser = pikkuFunc({
61
+ export const getToken = pikkuFunc({
56
62
  func: async () => {
57
- const email = 'test@example.com' as Private<string>
58
- return { id: 1, email }
63
+ const token = 'abc' as Secret<string>
64
+ return { token }
59
65
  }
60
66
  })
61
67
  `)
62
68
  const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
63
- assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
64
- assert.match(hit.message, /email/)
69
+ assert.ok(hit)
70
+ assert.match(hit.message, /token/)
65
71
  })
66
72
 
67
- test('flags a top-level Secret<string> field', async () => {
73
+ test('flags a top-level Secret<string> field in a sessionless function', async () => {
68
74
  const criticals = await runInspect(`
69
75
  ${BRAND_TYPES}
70
- import { pikkuFunc } from '@pikku/core'
71
- export const getToken = pikkuFunc({
76
+ import { pikkuSessionlessFunc } from '@pikku/core'
77
+ export const getToken = pikkuSessionlessFunc({
72
78
  func: async () => {
73
79
  const token = 'abc' as Secret<string>
74
80
  return { token }
@@ -80,11 +86,48 @@ export const getToken = pikkuFunc({
80
86
  assert.match(hit.message, /token/)
81
87
  })
82
88
 
83
- test('flags a nested Private field', async () => {
89
+ // ── Private<T>: only blocked in sessionless functions ─────────────────────
90
+
91
+ test('flags a top-level Private<string> field in a sessionless function', async () => {
92
+ const criticals = await runInspect(`
93
+ ${BRAND_TYPES}
94
+ import { pikkuSessionlessFunc } from '@pikku/core'
95
+ export const getUser = pikkuSessionlessFunc({
96
+ func: async () => {
97
+ const email = 'test@example.com' as Private<string>
98
+ return { id: 1, email }
99
+ }
100
+ })
101
+ `)
102
+ const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
103
+ assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
104
+ assert.match(hit.message, /email/)
105
+ })
106
+
107
+ test('does not flag a Private<string> field in a sessioned function', async () => {
84
108
  const criticals = await runInspect(`
85
109
  ${BRAND_TYPES}
86
110
  import { pikkuFunc } from '@pikku/core'
87
- export const getProfile = pikkuFunc({
111
+ export const getUser = pikkuFunc({
112
+ func: async () => {
113
+ const email = 'test@example.com' as Private<string>
114
+ return { id: 1, email }
115
+ }
116
+ })
117
+ `)
118
+ const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
119
+ assert.equal(
120
+ hit,
121
+ undefined,
122
+ `Expected no PKU910 (sessioned function may return Private fields) but got: ${JSON.stringify(hit)}`
123
+ )
124
+ })
125
+
126
+ test('flags a nested Private field in a sessionless function', async () => {
127
+ const criticals = await runInspect(`
128
+ ${BRAND_TYPES}
129
+ import { pikkuSessionlessFunc } from '@pikku/core'
130
+ export const getProfile = pikkuSessionlessFunc({
88
131
  func: async () => {
89
132
  const email = 'x@y.com' as Private<string>
90
133
  return { user: { id: 1, email } }
@@ -104,7 +147,11 @@ export const getPublicData = pikkuFunc({
104
147
  })
105
148
  `)
106
149
  const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
107
- assert.equal(hit, undefined, `Expected no PKU910 but got: ${JSON.stringify(hit)}`)
150
+ assert.equal(
151
+ hit,
152
+ undefined,
153
+ `Expected no PKU910 but got: ${JSON.stringify(hit)}`
154
+ )
108
155
  })
109
156
 
110
157
  test('does not flag a void-returning function', async () => {
@@ -118,12 +165,12 @@ export const doWork = pikkuFunc({
118
165
  assert.equal(hit, undefined)
119
166
  })
120
167
 
121
- test('flags a function that returns a typed alias with Private field', async () => {
168
+ test('flags a sessionless function that returns a typed alias with Private field', async () => {
122
169
  const criticals = await runInspect(`
123
170
  ${BRAND_TYPES}
124
- import { pikkuFunc } from '@pikku/core'
171
+ import { pikkuSessionlessFunc } from '@pikku/core'
125
172
  type UserRow = { id: number; email: Private<string> }
126
- export const getUser = pikkuFunc({
173
+ export const getUser = pikkuSessionlessFunc({
127
174
  func: async (): Promise<UserRow> => {
128
175
  return { id: 1, email: 'x' as Private<string> }
129
176
  }
@@ -134,17 +181,17 @@ export const getUser = pikkuFunc({
134
181
  assert.match(hit.message, /email/)
135
182
  })
136
183
 
137
- test('flags across multiple functions in the same file', async () => {
184
+ test('flags across multiple sessionless functions in the same file', async () => {
138
185
  const criticals = await runInspect(`
139
186
  ${BRAND_TYPES}
140
- import { pikkuFunc } from '@pikku/core'
141
- export const getEmail = pikkuFunc({
187
+ import { pikkuSessionlessFunc } from '@pikku/core'
188
+ export const getEmail = pikkuSessionlessFunc({
142
189
  func: async () => ({ email: 'x' as Private<string> })
143
190
  })
144
- export const getPhone = pikkuFunc({
191
+ export const getPhone = pikkuSessionlessFunc({
145
192
  func: async () => ({ phone: '555' as Private<string> })
146
193
  })
147
- export const getSafe = pikkuFunc({
194
+ export const getSafe = pikkuSessionlessFunc({
148
195
  func: async () => ({ name: 'Alice' })
149
196
  })
150
197
  `)
@@ -152,11 +199,11 @@ export const getSafe = pikkuFunc({
152
199
  assert.equal(hits.length, 2, `Expected 2 PKU910 but got ${hits.length}`)
153
200
  })
154
201
 
155
- test('flags branded values inside arrays', async () => {
202
+ test('flags branded values inside arrays (sessionless)', async () => {
156
203
  const criticals = await runInspect(`
157
204
  ${BRAND_TYPES}
158
- import { pikkuFunc } from '@pikku/core'
159
- export const getEmails = pikkuFunc({
205
+ import { pikkuSessionlessFunc } from '@pikku/core'
206
+ export const getEmails = pikkuSessionlessFunc({
160
207
  func: async () => ({ emails: ['x@y.com' as Private<string>] })
161
208
  })
162
209
  `)
@@ -165,11 +212,11 @@ export const getEmails = pikkuFunc({
165
212
  assert.match(hit.message, /emails/)
166
213
  })
167
214
 
168
- test('flags branded values inside string-indexed records', async () => {
215
+ test('flags branded values inside string-indexed records (sessionless)', async () => {
169
216
  const criticals = await runInspect(`
170
217
  ${BRAND_TYPES}
171
- import { pikkuFunc } from '@pikku/core'
172
- export const getMap = pikkuFunc({
218
+ import { pikkuSessionlessFunc } from '@pikku/core'
219
+ export const getMap = pikkuSessionlessFunc({
173
220
  func: async () => ({ byId: { a: 'x@y.com' as Private<string> } as Record<string, Private<string>> })
174
221
  })
175
222
  `)
@@ -181,8 +228,8 @@ export const getMap = pikkuFunc({
181
228
  test('does not flag when branded field is stripped before return', async () => {
182
229
  const criticals = await runInspect(`
183
230
  ${BRAND_TYPES}
184
- import { pikkuFunc } from '@pikku/core'
185
- export const getUser = pikkuFunc({
231
+ import { pikkuSessionlessFunc } from '@pikku/core'
232
+ export const getUser = pikkuSessionlessFunc({
186
233
  func: async () => {
187
234
  const raw: { email: Private<string> } = { email: 'x' as Private<string> }
188
235
  const safe: { email: string } = { email: raw.email as string }
package/src/inspector.ts CHANGED
@@ -145,6 +145,10 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
145
145
  meta: {},
146
146
  files: new Set(),
147
147
  },
148
+ auth: {
149
+ providers: [],
150
+ files: new Set(),
151
+ },
148
152
  secrets: {
149
153
  definitions: [],
150
154
  files: new Set(),
package/src/types.ts CHANGED
@@ -382,6 +382,10 @@ export interface InspectorState {
382
382
  meta: NodesMeta
383
383
  files: Set<string>
384
384
  }
385
+ auth: {
386
+ providers: string[]
387
+ files: Set<string>
388
+ }
385
389
  secrets: {
386
390
  definitions: SecretDefinitions
387
391
  files: Set<string>
@@ -1,16 +1,22 @@
1
1
  import * as ts from 'typescript'
2
2
 
3
+ export type ClassifiedField = {
4
+ path: string
5
+ classification: 'private' | 'pii' | 'secret' | string
6
+ }
7
+
3
8
  /**
4
- * Recursively walks a resolved TypeScript type looking for `__pii__` brands —
5
- * the structural marker emitted by `Private<T>` and `Secret<T>`.
9
+ * Recursively walks a resolved TypeScript type looking for `__classification__` brands —
10
+ * the structural marker emitted by `Private<T>`, `Pii<T>`, and `Secret<T>`.
6
11
  *
7
- * `Private<T> = T & { readonly __pii__: 'private' }` shows up in the TS type
8
- * system as an intersection whose constituents include a type with a `__pii__`
12
+ * `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
13
+ * system as an intersection whose constituents include a type with a `__classification__`
9
14
  * property. We detect that by checking whether any constituent of an
10
- * intersection exposes a property named `__pii__`.
15
+ * intersection exposes a property named `__classification__`.
11
16
  *
12
- * Returns the list of dotted field paths where a brand was found
13
- * (e.g. `['email', 'address.phone']`). An empty array means clean.
17
+ * Returns the list of classified fields found, each with its dotted path and
18
+ * classification level (e.g. `[{ path: 'email', classification: 'private' }]`).
19
+ * An empty array means clean.
14
20
  */
15
21
  export function findPiiPaths(
16
22
  checker: ts.TypeChecker,
@@ -18,23 +24,33 @@ export function findPiiPaths(
18
24
  path = '',
19
25
  depth = 0,
20
26
  seen = new Set<ts.Type>()
21
- ): string[] {
27
+ ): ClassifiedField[] {
22
28
  if (depth > 8 || seen.has(type)) return []
23
29
  seen.add(type)
24
30
 
25
31
  // ── Is this type itself branded? ─────────────────────────────────────────
26
- // Private<T> = T & { readonly __pii__: 'private' } → isIntersection()
27
- // where one constituent has a `__pii__` property.
32
+ // Private<T> = T & { readonly __classification__: 'private' } → isIntersection()
33
+ // where one constituent has a `__classification__` property whose type is a string literal.
28
34
  if (type.isIntersection()) {
29
- const branded = type.types.some((t) =>
30
- t.getProperties().some((p) => p.name === '__pii__')
31
- )
32
- if (branded) {
33
- return [path || '<return value>']
35
+ for (const t of type.types) {
36
+ const classificationProp = t
37
+ .getProperties()
38
+ .find((p) => p.name === '__classification__')
39
+ if (classificationProp) {
40
+ const decl =
41
+ classificationProp.valueDeclaration ??
42
+ classificationProp.declarations?.[0]
43
+ const classification = decl
44
+ ? ((
45
+ checker.getTypeOfSymbolAtLocation(classificationProp, decl) as any
46
+ )?.value ?? 'private')
47
+ : 'private'
48
+ return [{ path: path || '<return value>', classification }]
49
+ }
34
50
  }
35
51
  }
36
52
 
37
- const violations: string[] = []
53
+ const violations: ClassifiedField[] = []
38
54
 
39
55
  // ── Union: check every branch ─────────────────────────────────────────────
40
56
  if (type.isUnion()) {
@@ -54,12 +70,16 @@ export function findPiiPaths(
54
70
  const numberIndex = checker.getIndexTypeOfType(type, ts.IndexKind.Number)
55
71
  if (numberIndex) {
56
72
  const idxPath = path ? `${path}[]` : '[]'
57
- violations.push(...findPiiPaths(checker, numberIndex, idxPath, depth + 1, seen))
73
+ violations.push(
74
+ ...findPiiPaths(checker, numberIndex, idxPath, depth + 1, seen)
75
+ )
58
76
  }
59
77
  const stringIndex = checker.getIndexTypeOfType(type, ts.IndexKind.String)
60
78
  if (stringIndex) {
61
79
  const idxPath = path ? `${path}[*]` : '[*]'
62
- violations.push(...findPiiPaths(checker, stringIndex, idxPath, depth + 1, seen))
80
+ violations.push(
81
+ ...findPiiPaths(checker, stringIndex, idxPath, depth + 1, seen)
82
+ )
63
83
  }
64
84
 
65
85
  for (const prop of type.getProperties()) {
@@ -68,7 +88,9 @@ export function findPiiPaths(
68
88
  if (!decl) continue
69
89
  const propType = checker.getTypeOfSymbolAtLocation(prop, decl)
70
90
  const subPath = path ? `${path}.${prop.name}` : prop.name
71
- violations.push(...findPiiPaths(checker, propType, subPath, depth + 1, seen))
91
+ violations.push(
92
+ ...findPiiPaths(checker, propType, subPath, depth + 1, seen)
93
+ )
72
94
  }
73
95
  }
74
96
 
@@ -280,7 +280,9 @@ export function ensureFunctionMetadata(
280
280
  const { tags } = getCommonWireMetaData(
281
281
  firstArg,
282
282
  'Function',
283
- fallbackName || pikkuFuncId
283
+ fallbackName || pikkuFuncId,
284
+ undefined,
285
+ checker
284
286
  )
285
287
  if (tags) {
286
288
  meta.tags = tags
@@ -46,18 +46,20 @@ describe('extractDescription', () => {
46
46
  assert.equal(extractDescription(obj, checker), 'my step')
47
47
  })
48
48
 
49
- test('returns null for non-literal description value without crashing', () => {
49
+ test('extracts concatenated string literals in description', () => {
50
50
  const { checker, sourceFile } = createChecker(
51
- `const name = 'test'; const data = { description: name + ' addon' }`
51
+ `const data = { description: 'line one ' + 'line two' }`
52
52
  )
53
- const objs: ts.ObjectLiteralExpression[] = []
54
- const visit = (node: ts.Node) => {
55
- if (ts.isObjectLiteralExpression(node)) objs.push(node)
56
- ts.forEachChild(node, visit)
57
- }
58
- ts.forEachChild(sourceFile, visit)
59
- const dataObj = objs[objs.length - 1]!
60
- assert.equal(extractDescription(dataObj, checker), null)
53
+ const obj = findObjectLiteral(sourceFile)!
54
+ assert.equal(extractDescription(obj, checker), 'line one line two')
55
+ })
56
+
57
+ test('extracts deeply nested concatenation in description', () => {
58
+ const { checker, sourceFile } = createChecker(
59
+ `const data = { description: 'a' + 'b' + 'c' }`
60
+ )
61
+ const obj = findObjectLiteral(sourceFile)!
62
+ assert.equal(extractDescription(obj, checker), 'abc')
61
63
  })
62
64
 
63
65
  test('returns null for non-object node', () => {
@@ -32,6 +32,16 @@ export function extractStringLiteral(
32
32
  return extractStringLiteral(node.expression, checker)
33
33
  }
34
34
 
35
+ if (
36
+ ts.isBinaryExpression(node) &&
37
+ node.operatorToken.kind === ts.SyntaxKind.PlusToken
38
+ ) {
39
+ return (
40
+ extractStringLiteral(node.left, checker) +
41
+ extractStringLiteral(node.right, checker)
42
+ )
43
+ }
44
+
35
45
  // Try to evaluate constant identifiers
36
46
  if (ts.isIdentifier(node)) {
37
47
  const symbol = checker.getSymbolAtLocation(node)
@@ -52,7 +62,7 @@ export function extractStringLiteral(
52
62
  /**
53
63
  * Check if node is string-like (string literal or template expression)
54
64
  */
55
- export function isStringLike(node: ts.Node, _checker: ts.TypeChecker): boolean {
65
+ export function isStringLike(node: ts.Node, checker: ts.TypeChecker): boolean {
56
66
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
57
67
  return true
58
68
  }
@@ -60,6 +70,10 @@ export function isStringLike(node: ts.Node, _checker: ts.TypeChecker): boolean {
60
70
  if (ts.isTemplateExpression(node)) {
61
71
  return true
62
72
  }
73
+ // Unwrap type assertions: `expr as Type` or `<Type>expr`
74
+ if (ts.isAsExpression(node) || ts.isTypeAssertionExpression(node)) {
75
+ return isStringLike(node.expression, checker)
76
+ }
63
77
  return false
64
78
  }
65
79
 
@@ -1,5 +1,6 @@
1
1
  import * as ts from 'typescript'
2
2
  import { ErrorCode } from '../error-codes.js'
3
+ import { extractStringLiteral } from './extract-node-value.js'
3
4
 
4
5
  /**
5
6
  * Extracts an array of strings from an object property.
@@ -137,7 +138,8 @@ export const getCommonWireMetaData = (
137
138
  obj: ts.ObjectLiteralExpression,
138
139
  wiringType: string,
139
140
  wiringName: string | null,
140
- logger?: { critical: (code: ErrorCode, message: string) => void }
141
+ logger?: { critical: (code: ErrorCode, message: string) => void },
142
+ checker?: ts.TypeChecker
141
143
  ): {
142
144
  disabled?: true
143
145
  title?: string
@@ -166,18 +168,36 @@ export const getCommonWireMetaData = (
166
168
  prop.initializer.kind === ts.SyntaxKind.TrueKeyword
167
169
  ) {
168
170
  metadata.disabled = true
169
- } else if (propName === 'title' && ts.isStringLiteral(prop.initializer)) {
170
- metadata.title = prop.initializer.text
171
- } else if (
172
- propName === 'summary' &&
173
- ts.isStringLiteral(prop.initializer)
174
- ) {
175
- metadata.summary = prop.initializer.text
176
- } else if (
177
- propName === 'description' &&
178
- ts.isStringLiteral(prop.initializer)
179
- ) {
180
- metadata.description = prop.initializer.text
171
+ } else if (propName === 'title') {
172
+ try {
173
+ metadata.title = checker
174
+ ? extractStringLiteral(prop.initializer, checker)
175
+ : ts.isStringLiteral(prop.initializer)
176
+ ? prop.initializer.text
177
+ : undefined
178
+ } catch {
179
+ // non-static title — skip
180
+ }
181
+ } else if (propName === 'summary') {
182
+ try {
183
+ metadata.summary = checker
184
+ ? extractStringLiteral(prop.initializer, checker)
185
+ : ts.isStringLiteral(prop.initializer)
186
+ ? prop.initializer.text
187
+ : undefined
188
+ } catch {
189
+ // non-static summary — skip
190
+ }
191
+ } else if (propName === 'description') {
192
+ try {
193
+ metadata.description = checker
194
+ ? extractStringLiteral(prop.initializer, checker)
195
+ : ts.isStringLiteral(prop.initializer)
196
+ ? prop.initializer.text
197
+ : undefined
198
+ } catch {
199
+ // non-static description — skip
200
+ }
181
201
  } else if (propName === 'tags') {
182
202
  if (ts.isArrayLiteralExpression(prop.initializer)) {
183
203
  metadata.tags = prop.initializer.elements
@@ -181,6 +181,10 @@ export interface SerializableInspectorState {
181
181
  meta: InspectorState['nodes']['meta']
182
182
  files: string[]
183
183
  }
184
+ auth: {
185
+ providers: string[]
186
+ files: string[]
187
+ }
184
188
  secrets: {
185
189
  definitions: InspectorState['secrets']['definitions']
186
190
  files: string[]
@@ -383,6 +387,10 @@ export function serializeInspectorState(
383
387
  meta: state.nodes.meta,
384
388
  files: Array.from(state.nodes.files),
385
389
  },
390
+ auth: {
391
+ providers: state.auth.providers,
392
+ files: Array.from(state.auth.files),
393
+ },
386
394
  secrets: {
387
395
  definitions: state.secrets.definitions,
388
396
  files: Array.from(state.secrets.files),
@@ -556,6 +564,10 @@ export function deserializeInspectorState(
556
564
  meta: data.nodes?.meta || {},
557
565
  files: new Set(data.nodes?.files || []),
558
566
  },
567
+ auth: {
568
+ providers: data.auth?.providers || [],
569
+ files: new Set(data.auth?.files || []),
570
+ },
559
571
  secrets: {
560
572
  definitions: data.secrets?.definitions || [],
561
573
  files: new Set(data.secrets?.files || []),