@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.
- package/CHANGELOG.md +33 -0
- package/dist/add/add-ai-agent.js +1 -1
- package/dist/add/add-channel.js +25 -7
- package/dist/add/add-functions.js +31 -13
- package/dist/add/add-gateway.js +1 -1
- package/dist/add/add-http-route.js +23 -1
- package/dist/add/add-mcp-prompt.js +1 -1
- package/dist/add/add-mcp-resource.js +1 -1
- package/dist/add/add-queue-worker.js +1 -1
- package/dist/add/add-schedule.js +1 -1
- package/dist/add/add-trigger.js +1 -1
- package/dist/add/add-workflow.js +1 -1
- package/dist/utils/check-pii-output.d.ts +9 -4
- package/dist/utils/check-pii-output.js +17 -7
- package/dist/utils/custom-types-generator.js +55 -4
- package/dist/utils/ensure-function-metadata.js +1 -1
- package/dist/utils/extract-node-value.d.ts +1 -1
- package/dist/utils/extract-node-value.js +10 -1
- package/dist/utils/get-property-value.d.ts +1 -1
- package/dist/utils/get-property-value.js +35 -9
- package/dist/utils/schema-generator.js +4 -2
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +43 -9
- package/package.json +2 -2
- package/src/add/add-ai-agent.ts +1 -1
- package/src/add/add-channel.ts +37 -7
- package/src/add/add-functions.ts +47 -13
- package/src/add/add-gateway.ts +1 -1
- package/src/add/add-http-route.ts +26 -1
- package/src/add/add-mcp-prompt.ts +1 -1
- package/src/add/add-mcp-resource.ts +1 -1
- package/src/add/add-queue-worker.ts +1 -1
- package/src/add/add-schedule.ts +1 -1
- package/src/add/add-trigger.ts +1 -1
- package/src/add/add-workflow.test.ts +152 -0
- package/src/add/add-workflow.ts +2 -1
- package/src/add/pii-check.test.ts +70 -28
- package/src/utils/check-pii-output.ts +27 -11
- package/src/utils/custom-types-generator.test.ts +99 -0
- package/src/utils/custom-types-generator.ts +64 -4
- package/src/utils/ensure-function-metadata.ts +3 -1
- package/src/utils/extract-node-value.test.ts +12 -10
- package/src/utils/extract-node-value.ts +15 -1
- package/src/utils/get-property-value.ts +33 -13
- package/src/utils/schema-generator.ts +4 -2
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +50 -11
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -46,30 +46,35 @@ async function runInspect(sourceCode: string) {
|
|
|
46
46
|
return criticals
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
// ──
|
|
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
|
-
|
|
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
|
|
61
|
+
export const getToken = pikkuFunc({
|
|
57
62
|
func: async () => {
|
|
58
|
-
const
|
|
59
|
-
return {
|
|
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
|
|
65
|
-
assert.match(hit.message, /
|
|
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 {
|
|
72
|
-
export const getToken =
|
|
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
|
-
|
|
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
|
|
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 {
|
|
171
|
+
import { pikkuSessionlessFunc } from '@pikku/core'
|
|
130
172
|
type UserRow = { id: number; email: Private<string> }
|
|
131
|
-
export const getUser =
|
|
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 {
|
|
146
|
-
export const getEmail =
|
|
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 =
|
|
191
|
+
export const getPhone = pikkuSessionlessFunc({
|
|
150
192
|
func: async () => ({ phone: '555' as Private<string> })
|
|
151
193
|
})
|
|
152
|
-
export const getSafe =
|
|
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 {
|
|
164
|
-
export const getEmails =
|
|
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 {
|
|
177
|
-
export const getMap =
|
|
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 {
|
|
190
|
-
export const getUser =
|
|
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
|
|
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
|
|
13
|
-
* (e.g. `['email', '
|
|
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
|
-
):
|
|
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
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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:
|
|
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 (
|
|
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 ===
|
|
63
|
-
return `export type ${name} = ${
|
|
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('
|
|
49
|
+
test('extracts concatenated string literals in description', () => {
|
|
50
50
|
const { checker, sourceFile } = createChecker(
|
|
51
|
-
`const
|
|
51
|
+
`const data = { description: 'line one ' + 'line two' }`
|
|
52
52
|
)
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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,
|
|
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'
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
) {
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
313
|
+
void Promise.resolve(unregister()).catch(() => {})
|
|
312
314
|
}
|
|
313
315
|
}
|
|
314
316
|
|