@pikku/inspector 0.12.14 → 0.12.17

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 (46) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/dist/add/add-ai-agent.js +1 -1
  3. package/dist/add/add-channel.js +25 -7
  4. package/dist/add/add-functions.js +31 -13
  5. package/dist/add/add-gateway.js +1 -1
  6. package/dist/add/add-http-route.js +23 -1
  7. package/dist/add/add-mcp-prompt.js +1 -1
  8. package/dist/add/add-mcp-resource.js +1 -1
  9. package/dist/add/add-queue-worker.js +1 -1
  10. package/dist/add/add-schedule.js +1 -1
  11. package/dist/add/add-trigger.js +1 -1
  12. package/dist/add/add-workflow.js +1 -1
  13. package/dist/utils/check-pii-output.d.ts +9 -4
  14. package/dist/utils/check-pii-output.js +17 -7
  15. package/dist/utils/custom-types-generator.js +55 -4
  16. package/dist/utils/ensure-function-metadata.js +1 -1
  17. package/dist/utils/extract-node-value.d.ts +1 -1
  18. package/dist/utils/extract-node-value.js +10 -1
  19. package/dist/utils/get-property-value.d.ts +1 -1
  20. package/dist/utils/get-property-value.js +35 -9
  21. package/dist/utils/schema-generator.js +4 -2
  22. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +43 -9
  23. package/package.json +2 -2
  24. package/src/add/add-ai-agent.ts +1 -1
  25. package/src/add/add-channel.ts +37 -7
  26. package/src/add/add-functions.ts +47 -13
  27. package/src/add/add-gateway.ts +1 -1
  28. package/src/add/add-http-route.ts +26 -1
  29. package/src/add/add-mcp-prompt.ts +1 -1
  30. package/src/add/add-mcp-resource.ts +1 -1
  31. package/src/add/add-queue-worker.ts +1 -1
  32. package/src/add/add-schedule.ts +1 -1
  33. package/src/add/add-trigger.ts +1 -1
  34. package/src/add/add-workflow.test.ts +152 -0
  35. package/src/add/add-workflow.ts +2 -1
  36. package/src/add/pii-check.test.ts +70 -28
  37. package/src/utils/check-pii-output.ts +27 -11
  38. package/src/utils/custom-types-generator.test.ts +99 -0
  39. package/src/utils/custom-types-generator.ts +64 -4
  40. package/src/utils/ensure-function-metadata.ts +3 -1
  41. package/src/utils/extract-node-value.test.ts +12 -10
  42. package/src/utils/extract-node-value.ts +15 -1
  43. package/src/utils/get-property-value.ts +33 -13
  44. package/src/utils/schema-generator.ts +4 -2
  45. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +50 -11
  46. package/tsconfig.tsbuildinfo +1 -1
@@ -46,30 +46,35 @@ async function runInspect(sourceCode: string) {
46
46
  return criticals
47
47
  }
48
48
 
49
- // ── 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
50
53
 
51
54
  describe('PII output check — PKU910', () => {
52
- 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 () => {
53
58
  const criticals = await runInspect(`
54
59
  ${BRAND_TYPES}
55
60
  import { pikkuFunc } from '@pikku/core'
56
- export const getUser = pikkuFunc({
61
+ export const getToken = pikkuFunc({
57
62
  func: async () => {
58
- const email = 'test@example.com' as Private<string>
59
- return { id: 1, email }
63
+ const token = 'abc' as Secret<string>
64
+ return { token }
60
65
  }
61
66
  })
62
67
  `)
63
68
  const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
64
- assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
65
- assert.match(hit.message, /email/)
69
+ assert.ok(hit)
70
+ assert.match(hit.message, /token/)
66
71
  })
67
72
 
68
- test('flags a top-level Secret<string> field', async () => {
73
+ test('flags a top-level Secret<string> field in a sessionless function', async () => {
69
74
  const criticals = await runInspect(`
70
75
  ${BRAND_TYPES}
71
- import { pikkuFunc } from '@pikku/core'
72
- export const getToken = pikkuFunc({
76
+ import { pikkuSessionlessFunc } from '@pikku/core'
77
+ export const getToken = pikkuSessionlessFunc({
73
78
  func: async () => {
74
79
  const token = 'abc' as Secret<string>
75
80
  return { token }
@@ -81,11 +86,48 @@ export const getToken = pikkuFunc({
81
86
  assert.match(hit.message, /token/)
82
87
  })
83
88
 
84
- 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 () => {
85
108
  const criticals = await runInspect(`
86
109
  ${BRAND_TYPES}
87
110
  import { pikkuFunc } from '@pikku/core'
88
- 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({
89
131
  func: async () => {
90
132
  const email = 'x@y.com' as Private<string>
91
133
  return { user: { id: 1, email } }
@@ -123,12 +165,12 @@ export const doWork = pikkuFunc({
123
165
  assert.equal(hit, undefined)
124
166
  })
125
167
 
126
- 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 () => {
127
169
  const criticals = await runInspect(`
128
170
  ${BRAND_TYPES}
129
- import { pikkuFunc } from '@pikku/core'
171
+ import { pikkuSessionlessFunc } from '@pikku/core'
130
172
  type UserRow = { id: number; email: Private<string> }
131
- export const getUser = pikkuFunc({
173
+ export const getUser = pikkuSessionlessFunc({
132
174
  func: async (): Promise<UserRow> => {
133
175
  return { id: 1, email: 'x' as Private<string> }
134
176
  }
@@ -139,17 +181,17 @@ export const getUser = pikkuFunc({
139
181
  assert.match(hit.message, /email/)
140
182
  })
141
183
 
142
- test('flags across multiple functions in the same file', async () => {
184
+ test('flags across multiple sessionless functions in the same file', async () => {
143
185
  const criticals = await runInspect(`
144
186
  ${BRAND_TYPES}
145
- import { pikkuFunc } from '@pikku/core'
146
- export const getEmail = pikkuFunc({
187
+ import { pikkuSessionlessFunc } from '@pikku/core'
188
+ export const getEmail = pikkuSessionlessFunc({
147
189
  func: async () => ({ email: 'x' as Private<string> })
148
190
  })
149
- export const getPhone = pikkuFunc({
191
+ export const getPhone = pikkuSessionlessFunc({
150
192
  func: async () => ({ phone: '555' as Private<string> })
151
193
  })
152
- export const getSafe = pikkuFunc({
194
+ export const getSafe = pikkuSessionlessFunc({
153
195
  func: async () => ({ name: 'Alice' })
154
196
  })
155
197
  `)
@@ -157,11 +199,11 @@ export const getSafe = pikkuFunc({
157
199
  assert.equal(hits.length, 2, `Expected 2 PKU910 but got ${hits.length}`)
158
200
  })
159
201
 
160
- test('flags branded values inside arrays', async () => {
202
+ test('flags branded values inside arrays (sessionless)', async () => {
161
203
  const criticals = await runInspect(`
162
204
  ${BRAND_TYPES}
163
- import { pikkuFunc } from '@pikku/core'
164
- export const getEmails = pikkuFunc({
205
+ import { pikkuSessionlessFunc } from '@pikku/core'
206
+ export const getEmails = pikkuSessionlessFunc({
165
207
  func: async () => ({ emails: ['x@y.com' as Private<string>] })
166
208
  })
167
209
  `)
@@ -170,11 +212,11 @@ export const getEmails = pikkuFunc({
170
212
  assert.match(hit.message, /emails/)
171
213
  })
172
214
 
173
- test('flags branded values inside string-indexed records', async () => {
215
+ test('flags branded values inside string-indexed records (sessionless)', async () => {
174
216
  const criticals = await runInspect(`
175
217
  ${BRAND_TYPES}
176
- import { pikkuFunc } from '@pikku/core'
177
- export const getMap = pikkuFunc({
218
+ import { pikkuSessionlessFunc } from '@pikku/core'
219
+ export const getMap = pikkuSessionlessFunc({
178
220
  func: async () => ({ byId: { a: 'x@y.com' as Private<string> } as Record<string, Private<string>> })
179
221
  })
180
222
  `)
@@ -186,8 +228,8 @@ export const getMap = pikkuFunc({
186
228
  test('does not flag when branded field is stripped before return', async () => {
187
229
  const criticals = await runInspect(`
188
230
  ${BRAND_TYPES}
189
- import { pikkuFunc } from '@pikku/core'
190
- export const getUser = pikkuFunc({
231
+ import { pikkuSessionlessFunc } from '@pikku/core'
232
+ export const getUser = pikkuSessionlessFunc({
191
233
  func: async () => {
192
234
  const raw: { email: Private<string> } = { email: 'x' as Private<string> }
193
235
  const safe: { email: string } = { email: raw.email as 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
9
  * Recursively walks a resolved TypeScript type looking for `__classification__` brands —
5
- * the structural marker emitted by `Private<T>` and `Secret<T>`.
10
+ * the structural marker emitted by `Private<T>`, `Pii<T>`, and `Secret<T>`.
6
11
  *
7
12
  * `Private<T> = T & { readonly __classification__: 'private' }` shows up in the TS type
8
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
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
32
  // Private<T> = T & { readonly __classification__: 'private' } → isIntersection()
27
- // where one constituent has a `__classification__` property.
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 === '__classification__')
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()) {
@@ -0,0 +1,99 @@
1
+ import { test, describe } from 'node:test'
2
+ import { strict as assert } from 'node:assert'
3
+ import { generateCustomTypes } from './custom-types-generator.js'
4
+ import { TypesMap } from '../types-map.js'
5
+
6
+ function makeTypesMap(
7
+ entries: Record<string, { type: string; references: string[] }>
8
+ ): TypesMap {
9
+ const tm = new TypesMap()
10
+ for (const [name, { type, references }] of Object.entries(entries)) {
11
+ tm.addCustomType(name, type, references)
12
+ }
13
+ return tm
14
+ }
15
+
16
+ describe('generateCustomTypes — classification wrapper stripping', () => {
17
+ test('strips Private<T> wrapper from type alias', () => {
18
+ const tm = makeTypesMap({
19
+ UserEmail: { type: 'Private<string>', references: ['Private'] },
20
+ })
21
+ const result = generateCustomTypes(tm, new Set())
22
+ assert.match(result, /UserEmail/, 'should emit UserEmail alias')
23
+ assert.match(
24
+ result,
25
+ /= string/,
26
+ 'Private<string> should be stripped to string'
27
+ )
28
+ assert.doesNotMatch(result, /Private/, 'Private wrapper must be removed')
29
+ })
30
+
31
+ test('strips Secret<T> wrapper from type alias', () => {
32
+ const tm = makeTypesMap({
33
+ HashedPw: { type: 'Secret<string>', references: ['Secret'] },
34
+ })
35
+ const result = generateCustomTypes(tm, new Set())
36
+ assert.match(result, /HashedPw/)
37
+ assert.match(
38
+ result,
39
+ /= string/,
40
+ 'Secret<string> should be stripped to string'
41
+ )
42
+ assert.doesNotMatch(result, /Secret/, 'Secret wrapper must be removed')
43
+ })
44
+
45
+ test('strips Pii<T> wrapper from type alias', () => {
46
+ const tm = makeTypesMap({
47
+ UserPhone: { type: 'Pii<string>', references: ['Pii'] },
48
+ })
49
+ const result = generateCustomTypes(tm, new Set())
50
+ assert.match(result, /UserPhone/)
51
+ assert.match(result, /= string/, 'Pii<string> should be stripped to string')
52
+ assert.doesNotMatch(result, /Pii/, 'Pii wrapper must be removed')
53
+ })
54
+
55
+ test('strips nested classification wrappers', () => {
56
+ const tm = makeTypesMap({
57
+ Combo: {
58
+ type: 'Private<Secret<string>>',
59
+ references: ['Private', 'Secret'],
60
+ },
61
+ })
62
+ const result = generateCustomTypes(tm, new Set())
63
+ assert.match(result, /Combo/)
64
+ assert.match(
65
+ result,
66
+ /= string/,
67
+ 'nested wrappers should resolve to inner type'
68
+ )
69
+ assert.doesNotMatch(
70
+ result,
71
+ /Private|Secret/,
72
+ 'all wrappers must be removed'
73
+ )
74
+ })
75
+
76
+ test('does not strip non-classification type names', () => {
77
+ const tm = makeTypesMap({
78
+ MyType: { type: 'SomeOtherType', references: ['SomeOtherType'] },
79
+ })
80
+ const required = new Set<string>()
81
+ generateCustomTypes(tm, required)
82
+ assert.ok(
83
+ required.has('SomeOtherType'),
84
+ 'non-classification references must remain in requiredTypes'
85
+ )
86
+ })
87
+
88
+ test('classification wrapper references are not added to requiredTypes', () => {
89
+ const tm = makeTypesMap({
90
+ SensitiveField: { type: 'Private<string>', references: ['Private'] },
91
+ })
92
+ const required = new Set<string>()
93
+ generateCustomTypes(tm, required)
94
+ assert.ok(
95
+ !required.has('Private'),
96
+ 'Private must not be added to requiredTypes'
97
+ )
98
+ })
99
+ })
@@ -11,6 +11,61 @@ export function sanitizeTypeName(name: string): string {
11
11
  return name.replace(/[^a-zA-Z0-9_$]/g, '_')
12
12
  }
13
13
 
14
+ const CLASSIFICATION_WRAPPERS = new Set(['Private', 'Pii', 'Secret'])
15
+
16
+ function findMatchingAngleBracket(type: string, startIndex: number): number {
17
+ let depth = 0
18
+ for (let i = startIndex; i < type.length; i += 1) {
19
+ const char = type[i]
20
+ if (char === '<') {
21
+ depth += 1
22
+ continue
23
+ }
24
+ if (char === '>' && (i === 0 || type[i - 1] !== '=')) {
25
+ depth -= 1
26
+ if (depth === 0) {
27
+ return i
28
+ }
29
+ }
30
+ }
31
+ return -1
32
+ }
33
+
34
+ function stripClassificationWrappers(type: string): string {
35
+ let output = ''
36
+ let index = 0
37
+
38
+ while (index < type.length) {
39
+ const char = type[index]
40
+ if (!/[A-Za-z_$]/.test(char)) {
41
+ output += char
42
+ index += 1
43
+ continue
44
+ }
45
+
46
+ let end = index + 1
47
+ while (end < type.length && /[A-Za-z0-9_$]/.test(type[end])) {
48
+ end += 1
49
+ }
50
+
51
+ const identifier = type.slice(index, end)
52
+ if (CLASSIFICATION_WRAPPERS.has(identifier) && type[end] === '<') {
53
+ const closingIndex = findMatchingAngleBracket(type, end)
54
+ if (closingIndex !== -1) {
55
+ const inner = type.slice(end + 1, closingIndex)
56
+ output += stripClassificationWrappers(inner)
57
+ index = closingIndex + 1
58
+ continue
59
+ }
60
+ }
61
+
62
+ output += identifier
63
+ index = end
64
+ }
65
+
66
+ return output
67
+ }
68
+
14
69
  export function generateCustomTypes(
15
70
  typesMap: TypesMap,
16
71
  requiredTypes: Set<string>
@@ -25,12 +80,16 @@ export function generateCustomTypes(
25
80
  .map(([originalName, { type, references }]) => {
26
81
  const name = sanitizeTypeName(originalName)
27
82
  references.forEach((refName) => {
28
- if (refName !== '__object' && !refName.startsWith('__object_')) {
83
+ if (
84
+ refName !== '__object' &&
85
+ !refName.startsWith('__object_') &&
86
+ !CLASSIFICATION_WRAPPERS.has(refName)
87
+ ) {
29
88
  requiredTypes.add(refName)
30
89
  }
31
90
  })
32
91
 
33
- const typeString = type
92
+ const typeString = stripClassificationWrappers(type)
34
93
  const typeNameRegex = /\b[A-Z][a-zA-Z0-9]*\b/g
35
94
  const potentialTypes = typeString.match(typeNameRegex) || []
36
95
 
@@ -59,12 +118,13 @@ export function generateCustomTypes(
59
118
  }
60
119
  })
61
120
 
62
- if (name === type) return null
63
- return `export type ${name} = ${type}`
121
+ if (name === typeString) return null
122
+ return `export type ${name} = ${typeString}`
64
123
  })
65
124
 
66
125
  const importsByPath = new Map<string, Set<string>>()
67
126
  for (const typeName of requiredTypes) {
127
+ if (CLASSIFICATION_WRAPPERS.has(typeName)) continue
68
128
  try {
69
129
  const typeMeta = typesMap.getTypeMeta(typeName)
70
130
  if (typeMeta.path) {
@@ -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
@@ -297,7 +297,9 @@ async function batchImportWithRegister(
297
297
  logger.debug(`tsx register() batch import failed: ${(e as Error).message}`)
298
298
  return null
299
299
  } finally {
300
- await unregister?.()
300
+ void Promise.resolve(unregister?.()).catch((e) => {
301
+ logger.debug(`tsx unregister() failed: ${(e as Error).message}`)
302
+ })
301
303
  }
302
304
  }
303
305
 
@@ -308,7 +310,7 @@ async function importWithRegister(
308
310
  try {
309
311
  return await import(sourceFile)
310
312
  } finally {
311
- await unregister()
313
+ void Promise.resolve(unregister()).catch(() => {})
312
314
  }
313
315
  }
314
316