@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.
- package/CHANGELOG.md +48 -0
- package/dist/add/add-ai-agent.js +1 -1
- package/dist/add/add-auth.d.ts +2 -0
- package/dist/add/add-auth.js +34 -0
- package/dist/add/add-channel.js +25 -7
- package/dist/add/add-functions.js +28 -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/inspector.js +4 -0
- package/dist/types.d.ts +4 -0
- package/dist/utils/check-pii-output.d.ts +13 -8
- package/dist/utils/check-pii-output.js +22 -12
- 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/serialize-inspector-state.d.ts +4 -0
- package/dist/utils/serialize-inspector-state.js +8 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +20 -9
- package/dist/visit.js +2 -0
- package/package.json +2 -2
- package/src/add/add-ai-agent.ts +1 -1
- package/src/add/add-auth.test.ts +175 -0
- package/src/add/add-auth.ts +49 -0
- package/src/add/add-channel.ts +37 -7
- package/src/add/add-functions.ts +44 -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 +79 -32
- package/src/inspector.ts +4 -0
- package/src/types.ts +4 -0
- package/src/utils/check-pii-output.ts +41 -19
- 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/serialize-inspector-state.ts +12 -0
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +22 -9
- package/src/visit.ts +2 -0
- 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
|
+
})
|
package/src/add/add-workflow.ts
CHANGED
|
@@ -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
|
|
32
|
-
type
|
|
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
|
-
// ──
|
|
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
|
-
|
|
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
|
|
61
|
+
export const getToken = pikkuFunc({
|
|
56
62
|
func: async () => {
|
|
57
|
-
const
|
|
58
|
-
return {
|
|
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
|
|
64
|
-
assert.match(hit.message, /
|
|
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 {
|
|
71
|
-
export const getToken =
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 {
|
|
171
|
+
import { pikkuSessionlessFunc } from '@pikku/core'
|
|
125
172
|
type UserRow = { id: number; email: Private<string> }
|
|
126
|
-
export const getUser =
|
|
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 {
|
|
141
|
-
export const getEmail =
|
|
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 =
|
|
191
|
+
export const getPhone = pikkuSessionlessFunc({
|
|
145
192
|
func: async () => ({ phone: '555' as Private<string> })
|
|
146
193
|
})
|
|
147
|
-
export const getSafe =
|
|
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 {
|
|
159
|
-
export const getEmails =
|
|
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 {
|
|
172
|
-
export const getMap =
|
|
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 {
|
|
185
|
-
export const getUser =
|
|
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
package/src/types.ts
CHANGED
|
@@ -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 `
|
|
5
|
-
* the structural marker emitted by `Private<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
|
|
8
|
-
* system as an intersection whose constituents include a type with a `
|
|
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 `
|
|
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
|
-
// Private<T> = T & { readonly
|
|
27
|
-
// where one constituent has a `
|
|
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
|
|
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()) {
|
|
@@ -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(
|
|
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(
|
|
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(
|
|
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('
|
|
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
|
|
@@ -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 || []),
|