@pikku/inspector 0.12.11 → 0.12.13
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 +32 -0
- package/dist/add/add-cli.js +10 -3
- package/dist/add/add-credential.js +2 -1
- package/dist/add/add-functions.js +48 -1
- package/dist/add/add-http-route.js +24 -5
- package/dist/add/add-keyed-wiring.js +3 -1
- package/dist/add/add-middleware.js +33 -4
- package/dist/add/add-permission.js +7 -7
- package/dist/add/add-workflow-graph.js +20 -1
- package/dist/error-codes.d.ts +3 -1
- package/dist/error-codes.js +3 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/inspector.js +2 -5
- package/dist/types.d.ts +10 -19
- package/dist/utils/check-pii-output.d.ts +14 -0
- package/dist/utils/check-pii-output.js +63 -0
- package/dist/utils/extract-function-name.js +6 -0
- package/dist/utils/filter-inspector-state.js +187 -59
- package/dist/utils/filter-utils.js +13 -5
- package/dist/utils/get-property-value.d.ts +10 -0
- package/dist/utils/get-property-value.js +30 -0
- package/dist/utils/post-process.d.ts +2 -3
- package/dist/utils/post-process.js +3 -23
- package/dist/utils/resolve-addon-package.d.ts +4 -5
- package/dist/utils/resolve-addon-package.js +64 -16
- package/dist/utils/resolve-deploy-target.d.ts +28 -0
- package/dist/utils/resolve-deploy-target.js +56 -0
- package/dist/utils/resolve-versions.js +79 -0
- package/dist/utils/schema-generator.js +31 -12
- package/package.json +2 -2
- package/src/add/add-cli.ts +10 -3
- package/src/add/add-credential.ts +3 -0
- package/src/add/add-functions.test.ts +149 -0
- package/src/add/add-functions.ts +61 -1
- package/src/add/add-gateway.ts +5 -1
- package/src/add/add-http-route.ts +26 -6
- package/src/add/add-keyed-wiring.ts +7 -1
- package/src/add/add-mcp-prompt.ts +5 -1
- package/src/add/add-mcp-resource.ts +5 -1
- package/src/add/add-middleware.ts +42 -4
- package/src/add/add-permission.ts +7 -7
- package/src/add/add-schedule.ts +5 -1
- package/src/add/add-workflow-graph.ts +19 -1
- package/src/add/pii-check.test.ts +197 -0
- package/src/add/wire-name-literal.test.ts +114 -0
- package/src/error-codes.ts +4 -0
- package/src/index.ts +1 -0
- package/src/inspector.ts +1 -5
- package/src/types.ts +19 -15
- package/src/utils/check-pii-output.ts +76 -0
- package/src/utils/extract-function-name.ts +8 -0
- package/src/utils/filter-inspector-state.test.ts +168 -64
- package/src/utils/filter-inspector-state.ts +290 -64
- package/src/utils/filter-utils.test.ts +30 -15
- package/src/utils/filter-utils.ts +14 -5
- package/src/utils/get-property-value.ts +40 -0
- package/src/utils/post-process.ts +3 -38
- package/src/utils/resolve-addon-package.ts +65 -14
- package/src/utils/resolve-deploy-target.test.ts +105 -0
- package/src/utils/resolve-deploy-target.ts +63 -0
- package/src/utils/resolve-versions.test.ts +108 -0
- package/src/utils/resolve-versions.ts +86 -0
- package/src/utils/schema-generator.ts +37 -13
- package/tsconfig.tsbuildinfo +1 -1
package/src/add/add-functions.ts
CHANGED
|
@@ -14,10 +14,12 @@ import {
|
|
|
14
14
|
getPropertyValue,
|
|
15
15
|
getCommonWireMetaData,
|
|
16
16
|
} from '../utils/get-property-value.js'
|
|
17
|
+
import { canonicalJSON, hashString } from '../utils/hash.js'
|
|
17
18
|
import { resolveMiddleware } from '../utils/middleware.js'
|
|
18
19
|
import { resolvePermissions } from '../utils/permissions.js'
|
|
19
20
|
import { extractWireNames } from '../utils/post-process.js'
|
|
20
21
|
import { ErrorCode } from '../error-codes.js'
|
|
22
|
+
import { findPiiPaths } from '../utils/check-pii-output.js'
|
|
21
23
|
import type { NodeType } from '@pikku/core/node'
|
|
22
24
|
|
|
23
25
|
const isValidVariableName = (name: string) => {
|
|
@@ -312,6 +314,29 @@ const areCompatibleFunctionIds = (
|
|
|
312
314
|
return existingParsed.baseName === incomingParsed.baseName
|
|
313
315
|
}
|
|
314
316
|
|
|
317
|
+
function printNode(node: ts.Node): string {
|
|
318
|
+
return ts
|
|
319
|
+
.createPrinter({ removeComments: true })
|
|
320
|
+
.printNode(ts.EmitHint.Unspecified, node, node.getSourceFile())
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function computeImplementationHash(args: {
|
|
324
|
+
wrapper: string
|
|
325
|
+
handler: ts.ArrowFunction | ts.FunctionExpression
|
|
326
|
+
objectNode?: ts.ObjectLiteralExpression
|
|
327
|
+
isDirectFunction: boolean
|
|
328
|
+
}): string {
|
|
329
|
+
const { wrapper, handler, objectNode, isDirectFunction } = args
|
|
330
|
+
return hashString(
|
|
331
|
+
canonicalJSON({
|
|
332
|
+
wrapper,
|
|
333
|
+
isDirectFunction,
|
|
334
|
+
handler: printNode(handler),
|
|
335
|
+
config: objectNode ? printNode(objectNode) : null,
|
|
336
|
+
})
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
|
|
315
340
|
/**
|
|
316
341
|
* Inspect pikkuFunc calls, extract input/output and first-arg destructuring,
|
|
317
342
|
* then push into state.functions.meta.
|
|
@@ -566,7 +591,12 @@ export const addFunctions: AddWiring = (
|
|
|
566
591
|
}
|
|
567
592
|
|
|
568
593
|
if (version !== undefined) {
|
|
569
|
-
|
|
594
|
+
let baseName = explicitName || exportedName || pikkuFuncId
|
|
595
|
+
// Strip trailing VN suffix if it matches the version (e.g. getDataV1 + version:1 → getData@v1)
|
|
596
|
+
const vSuffix = `V${version}`
|
|
597
|
+
if (baseName.endsWith(vSuffix) && baseName.length > vSuffix.length) {
|
|
598
|
+
baseName = baseName.slice(0, -vSuffix.length)
|
|
599
|
+
}
|
|
570
600
|
pikkuFuncId = formatVersionedId(baseName, version)
|
|
571
601
|
}
|
|
572
602
|
|
|
@@ -853,6 +883,29 @@ export const addFunctions: AddWiring = (
|
|
|
853
883
|
}
|
|
854
884
|
}
|
|
855
885
|
|
|
886
|
+
// ── PII brand check ───────────────────────────────────────────────────────
|
|
887
|
+
// Walk the function body's ACTUAL inferred return type looking for Private<T>
|
|
888
|
+
// / Secret<T> brands (__pii__ property). This runs for every function,
|
|
889
|
+
// including those with a Zod output schema, because the TS return type
|
|
890
|
+
// reflects what the body actually returns before any Zod coercion.
|
|
891
|
+
{
|
|
892
|
+
const sig = checker.getSignatureFromDeclaration(handler)
|
|
893
|
+
if (sig) {
|
|
894
|
+
const rawRet = checker.getReturnTypeOfSignature(sig)
|
|
895
|
+
const unwrapped = unwrapPromise(checker, rawRet)
|
|
896
|
+
const piiPaths = findPiiPaths(checker, unwrapped)
|
|
897
|
+
if (piiPaths.length > 0) {
|
|
898
|
+
logger.critical(
|
|
899
|
+
ErrorCode.PII_IN_OUTPUT,
|
|
900
|
+
`Function '${name}' exposes PII-classified field(s) in its return type: ` +
|
|
901
|
+
piiPaths.map((p) => `'${p}'`).join(', ') +
|
|
902
|
+
`.\n Either strip these fields before returning or mark the column ` +
|
|
903
|
+
`@public in the migration if it is safe to expose.`
|
|
904
|
+
)
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
856
909
|
// --- resolve middleware ---
|
|
857
910
|
let middleware = objectNode
|
|
858
911
|
? resolveMiddleware(state, objectNode, tags, checker)
|
|
@@ -894,6 +947,12 @@ export const addFunctions: AddWiring = (
|
|
|
894
947
|
}
|
|
895
948
|
|
|
896
949
|
const sessionless = expression.text !== 'pikkuFunc'
|
|
950
|
+
const implementationHash = computeImplementationHash({
|
|
951
|
+
wrapper: expression.text,
|
|
952
|
+
handler,
|
|
953
|
+
objectNode,
|
|
954
|
+
isDirectFunction,
|
|
955
|
+
})
|
|
897
956
|
|
|
898
957
|
state.functions.meta[pikkuFuncId] = {
|
|
899
958
|
pikkuFuncId,
|
|
@@ -914,6 +973,7 @@ export const addFunctions: AddWiring = (
|
|
|
914
973
|
deploy: deploy || undefined,
|
|
915
974
|
approvalRequired: approvalRequired || undefined,
|
|
916
975
|
approvalDescription: approvalDescription || undefined,
|
|
976
|
+
implementationHash,
|
|
917
977
|
version,
|
|
918
978
|
title,
|
|
919
979
|
tags: tags || undefined,
|
package/src/add/add-gateway.ts
CHANGED
|
@@ -70,7 +70,11 @@ export const addGateway: AddWiring = (
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
const packageName = ts.isIdentifier(funcInitializer)
|
|
73
|
-
? resolveAddonName(
|
|
73
|
+
? resolveAddonName(
|
|
74
|
+
funcInitializer,
|
|
75
|
+
checker,
|
|
76
|
+
state.rpc.wireAddonDeclarations
|
|
77
|
+
)
|
|
74
78
|
: null
|
|
75
79
|
|
|
76
80
|
if (!nameValue || !typeValue) {
|
|
@@ -273,12 +273,32 @@ export function registerHTTPRoute({
|
|
|
273
273
|
|
|
274
274
|
const input = fnMeta.inputs?.[0] || null
|
|
275
275
|
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
const inputTypes = state.typesLookup.get(
|
|
276
|
+
const getRouteInputKeys = (): string[] | null => {
|
|
277
|
+
const targetFuncName = refAddonTarget ?? funcName
|
|
278
|
+
const inputTypes = state.typesLookup.get(targetFuncName)
|
|
279
279
|
if (inputTypes && inputTypes.length > 0) {
|
|
280
|
-
|
|
280
|
+
return extractTypeKeys(inputTypes[0])
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const targetMeta = resolveFunctionMeta(state, targetFuncName)
|
|
284
|
+
if (targetMeta?.inputSchemaName) {
|
|
285
|
+
const schema = state.schemas[targetMeta.inputSchemaName] as any
|
|
286
|
+
const properties = schema?.properties
|
|
287
|
+
if (properties && typeof properties === 'object') {
|
|
288
|
+
return Object.keys(properties)
|
|
289
|
+
}
|
|
290
|
+
}
|
|
281
291
|
|
|
292
|
+
return null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Validate that route params and query params exist in function input type
|
|
296
|
+
if (params.length > 0 || query.length > 0) {
|
|
297
|
+
const inputKeys = getRouteInputKeys()
|
|
298
|
+
if (!inputKeys) {
|
|
299
|
+
// Input shape isn't inspectable at this phase (e.g. addon ref or opaque handler).
|
|
300
|
+
// Skip param/query validation rather than emitting a false positive.
|
|
301
|
+
} else {
|
|
282
302
|
// Check path params
|
|
283
303
|
if (params.length > 0) {
|
|
284
304
|
const missingParams = params.filter((p) => !inputKeys.includes(p))
|
|
@@ -286,7 +306,7 @@ export function registerHTTPRoute({
|
|
|
286
306
|
logger.critical(
|
|
287
307
|
ErrorCode.ROUTE_PARAM_MISMATCH,
|
|
288
308
|
`Route '${fullRoute}' has path parameter(s) [${missingParams.join(', ')}] ` +
|
|
289
|
-
`not found in function '${funcName}' input type. ` +
|
|
309
|
+
`not found in function '${refAddonTarget ?? funcName}' input type. ` +
|
|
290
310
|
`Input type has: [${inputKeys.join(', ')}]`
|
|
291
311
|
)
|
|
292
312
|
return
|
|
@@ -300,7 +320,7 @@ export function registerHTTPRoute({
|
|
|
300
320
|
logger.critical(
|
|
301
321
|
ErrorCode.ROUTE_QUERY_MISMATCH,
|
|
302
322
|
`Route '${fullRoute}' has query parameter(s) [${missingQuery.join(', ')}] ` +
|
|
303
|
-
`not found in function '${funcName}' input type. ` +
|
|
323
|
+
`not found in function '${refAddonTarget ?? funcName}' input type. ` +
|
|
304
324
|
`Input type has: [${inputKeys.join(', ')}]`
|
|
305
325
|
)
|
|
306
326
|
return
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
getPropertyValue,
|
|
4
|
+
assertStringLiteralProperty,
|
|
5
|
+
} from '../utils/get-property-value.js'
|
|
3
6
|
import type { AddWiring, InspectorState } from '../types.js'
|
|
4
7
|
import { ErrorCode } from '../error-codes.js'
|
|
5
8
|
import { detectSchemaVendorOrError } from '../utils/detect-schema-vendor.js'
|
|
@@ -39,6 +42,9 @@ export const createAddKeyedWiring = (config: KeyedWiringConfig): AddWiring => {
|
|
|
39
42
|
if (ts.isObjectLiteralExpression(firstArg)) {
|
|
40
43
|
const obj = firstArg
|
|
41
44
|
|
|
45
|
+
assertStringLiteralProperty(obj, 'name', config.label, logger)
|
|
46
|
+
assertStringLiteralProperty(obj, config.idField, config.label, logger)
|
|
47
|
+
|
|
42
48
|
const nameValue = getPropertyValue(obj, 'name') as string | null
|
|
43
49
|
const displayNameValue = getPropertyValue(obj, 'displayName') as
|
|
44
50
|
| string
|
|
@@ -74,7 +74,11 @@ export const addMCPPrompt: AddWiring = (
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
const packageName = ts.isIdentifier(funcInitializer)
|
|
77
|
-
? resolveAddonName(
|
|
77
|
+
? resolveAddonName(
|
|
78
|
+
funcInitializer,
|
|
79
|
+
checker,
|
|
80
|
+
state.rpc.wireAddonDeclarations
|
|
81
|
+
)
|
|
78
82
|
: null
|
|
79
83
|
|
|
80
84
|
ensureFunctionMetadata(
|
|
@@ -83,7 +83,11 @@ export const addMCPResource: AddWiring = (
|
|
|
83
83
|
}
|
|
84
84
|
|
|
85
85
|
const packageName = ts.isIdentifier(funcInitializer)
|
|
86
|
-
? resolveAddonName(
|
|
86
|
+
? resolveAddonName(
|
|
87
|
+
funcInitializer,
|
|
88
|
+
checker,
|
|
89
|
+
state.rpc.wireAddonDeclarations
|
|
90
|
+
)
|
|
87
91
|
: null
|
|
88
92
|
|
|
89
93
|
ensureFunctionMetadata(
|
|
@@ -245,7 +245,7 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
245
245
|
return
|
|
246
246
|
}
|
|
247
247
|
|
|
248
|
-
if (expression.text === '
|
|
248
|
+
if (expression.text === 'addTagMiddleware') {
|
|
249
249
|
const tagArg = args[0]
|
|
250
250
|
const middlewareArrayArg = args[1]
|
|
251
251
|
|
|
@@ -257,13 +257,13 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
257
257
|
}
|
|
258
258
|
|
|
259
259
|
if (!tag) {
|
|
260
|
-
logger.warn(`•
|
|
260
|
+
logger.warn(`• addTagMiddleware call without valid tag string`)
|
|
261
261
|
return
|
|
262
262
|
}
|
|
263
263
|
|
|
264
264
|
if (!ts.isArrayLiteralExpression(middlewareArrayArg)) {
|
|
265
265
|
logger.error(
|
|
266
|
-
`•
|
|
266
|
+
`• addTagMiddleware('${tag}', ...) must have a literal array as second argument`
|
|
267
267
|
)
|
|
268
268
|
return
|
|
269
269
|
}
|
|
@@ -329,7 +329,7 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
329
329
|
if (!isFactory && exportedName) {
|
|
330
330
|
logger.warn(
|
|
331
331
|
`• Middleware group '${exportedName}' for tag '${tag}' is not wrapped in a factory function. ` +
|
|
332
|
-
`For tree-shaking, use: export const ${exportedName} = () =>
|
|
332
|
+
`For tree-shaking, use: export const ${exportedName} = () => addTagMiddleware('${tag}', [...])`
|
|
333
333
|
)
|
|
334
334
|
}
|
|
335
335
|
|
|
@@ -352,6 +352,43 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
352
352
|
return
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
if (expression.text === 'addGlobalMiddleware') {
|
|
356
|
+
const middlewareArrayArg = args[0]
|
|
357
|
+
if (
|
|
358
|
+
!middlewareArrayArg ||
|
|
359
|
+
!ts.isArrayLiteralExpression(middlewareArrayArg)
|
|
360
|
+
) {
|
|
361
|
+
logger.error(
|
|
362
|
+
`• addGlobalMiddleware(...) must have a literal array as its only argument`
|
|
363
|
+
)
|
|
364
|
+
return
|
|
365
|
+
}
|
|
366
|
+
const refs = extractMiddlewareRefs(
|
|
367
|
+
middlewareArrayArg,
|
|
368
|
+
checker,
|
|
369
|
+
state.rootDir
|
|
370
|
+
)
|
|
371
|
+
const definitionIds = refs.map((r) => r.definitionId)
|
|
372
|
+
if (definitionIds.length > 0) {
|
|
373
|
+
renameTempDefinitions(state, definitionIds, 'global', 'middleware')
|
|
374
|
+
}
|
|
375
|
+
const sourceFile = node.getSourceFile().fileName
|
|
376
|
+
for (let i = 0; i < refs.length; i++) {
|
|
377
|
+
const instanceId = makeContextBasedId('global', 'middleware', String(i))
|
|
378
|
+
state.middleware.instances[instanceId] = {
|
|
379
|
+
definitionId: definitionIds[i],
|
|
380
|
+
sourceFile,
|
|
381
|
+
position: node.getStart(),
|
|
382
|
+
isFactoryCall: refs[i].isFactoryCall,
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
// Without this, bootstrap codegen's "import every file with a wire-call"
|
|
386
|
+
// pass skips middleware-only files and the registration never runs.
|
|
387
|
+
state.http.files.add(sourceFile)
|
|
388
|
+
logger.debug(`• Found global middleware group with ${refs.length} entries`)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
|
|
355
392
|
if (expression.text === 'addHTTPMiddleware') {
|
|
356
393
|
const patternArg = args[0]
|
|
357
394
|
const middlewareArrayArg = args[1]
|
|
@@ -452,6 +489,7 @@ export const addMiddleware: AddWiring = (logger, node, checker, state) => {
|
|
|
452
489
|
instanceIds,
|
|
453
490
|
isFactory,
|
|
454
491
|
})
|
|
492
|
+
state.http.files.add(sourceFile)
|
|
455
493
|
|
|
456
494
|
logger.debug(
|
|
457
495
|
`• Found HTTP route middleware group: ${pattern} -> [${instanceIds.join(', ')}] (${isFactory ? 'factory' : 'direct'})`
|
|
@@ -45,7 +45,7 @@ function isInsidePermissionContainer(node: ts.Node): boolean {
|
|
|
45
45
|
ts.isCallExpression(current) &&
|
|
46
46
|
ts.isIdentifier(current.expression) &&
|
|
47
47
|
(current.expression.text === 'pikkuPermissionFactory' ||
|
|
48
|
-
current.expression.text === '
|
|
48
|
+
current.expression.text === 'addTagPermission' ||
|
|
49
49
|
current.expression.text === 'addHTTPPermission')
|
|
50
50
|
) {
|
|
51
51
|
return true
|
|
@@ -338,9 +338,9 @@ export const addPermission: AddWiring = (logger, node, checker, state) => {
|
|
|
338
338
|
|
|
339
339
|
// Handle addPermission('tag', [permission1, permission2])
|
|
340
340
|
// Supports two patterns:
|
|
341
|
-
// 1. export const x = () =>
|
|
342
|
-
// 2. export const x =
|
|
343
|
-
if (expression.text === '
|
|
341
|
+
// 1. export const x = () => addTagPermission('tag', [...]) (factory - tree-shakeable)
|
|
342
|
+
// 2. export const x = addTagPermission('tag', [...]) (direct - no tree-shaking)
|
|
343
|
+
if (expression.text === 'addTagPermission') {
|
|
344
344
|
const tagArg = args[0]
|
|
345
345
|
const permissionsArrayArg = args[1]
|
|
346
346
|
|
|
@@ -353,7 +353,7 @@ export const addPermission: AddWiring = (logger, node, checker, state) => {
|
|
|
353
353
|
}
|
|
354
354
|
|
|
355
355
|
if (!tag) {
|
|
356
|
-
logger.warn(`•
|
|
356
|
+
logger.warn(`• addTagPermission call without valid tag string`)
|
|
357
357
|
return
|
|
358
358
|
}
|
|
359
359
|
|
|
@@ -363,7 +363,7 @@ export const addPermission: AddWiring = (logger, node, checker, state) => {
|
|
|
363
363
|
!ts.isObjectLiteralExpression(permissionsArrayArg)
|
|
364
364
|
) {
|
|
365
365
|
logger.error(
|
|
366
|
-
`•
|
|
366
|
+
`• addTagPermission('${tag}', ...) must have a literal array or object as second argument`
|
|
367
367
|
)
|
|
368
368
|
return
|
|
369
369
|
}
|
|
@@ -416,7 +416,7 @@ export const addPermission: AddWiring = (logger, node, checker, state) => {
|
|
|
416
416
|
if (!isFactory && exportedName) {
|
|
417
417
|
logger.warn(
|
|
418
418
|
`• Permission group '${exportedName}' for tag '${tag}' is not wrapped in a factory function. ` +
|
|
419
|
-
`For tree-shaking, use: export const ${exportedName} = () =>
|
|
419
|
+
`For tree-shaking, use: export const ${exportedName} = () => addTagPermission('${tag}', [...])`
|
|
420
420
|
)
|
|
421
421
|
}
|
|
422
422
|
|
package/src/add/add-schedule.ts
CHANGED
|
@@ -73,7 +73,11 @@ export const addSchedule: AddWiring = (
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
const packageName = ts.isIdentifier(funcInitializer)
|
|
76
|
-
? resolveAddonName(
|
|
76
|
+
? resolveAddonName(
|
|
77
|
+
funcInitializer,
|
|
78
|
+
checker,
|
|
79
|
+
state.rpc.wireAddonDeclarations
|
|
80
|
+
)
|
|
77
81
|
: null
|
|
78
82
|
|
|
79
83
|
if (!nameValue || !scheduleValue) {
|
|
@@ -357,6 +357,8 @@ function extractGraphFromNewFormat(
|
|
|
357
357
|
input: {},
|
|
358
358
|
next: undefined,
|
|
359
359
|
onError: undefined,
|
|
360
|
+
retries: undefined,
|
|
361
|
+
retryDelay: undefined,
|
|
360
362
|
}
|
|
361
363
|
}
|
|
362
364
|
|
|
@@ -382,6 +384,8 @@ function extractGraphFromNewFormat(
|
|
|
382
384
|
nodes[nodeId].next = nodeConfig.next
|
|
383
385
|
nodes[nodeId].onError = nodeConfig.onError
|
|
384
386
|
nodes[nodeId].input = nodeConfig.input
|
|
387
|
+
nodes[nodeId].retries = nodeConfig.retries
|
|
388
|
+
nodes[nodeId].retryDelay = nodeConfig.retryDelay
|
|
385
389
|
}
|
|
386
390
|
}
|
|
387
391
|
}
|
|
@@ -400,10 +404,14 @@ function extractNodeConfigFromObject(
|
|
|
400
404
|
next: any
|
|
401
405
|
onError: any
|
|
402
406
|
input: Record<string, any>
|
|
407
|
+
retries: number | undefined
|
|
408
|
+
retryDelay: string | number | undefined
|
|
403
409
|
} {
|
|
404
410
|
let next: any = undefined
|
|
405
411
|
let onError: any = undefined
|
|
406
412
|
let input: Record<string, any> = {}
|
|
413
|
+
let retries: number | undefined = undefined
|
|
414
|
+
let retryDelay: string | number | undefined = undefined
|
|
407
415
|
|
|
408
416
|
for (const prop of obj.properties) {
|
|
409
417
|
if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
|
|
@@ -416,10 +424,20 @@ function extractNodeConfigFromObject(
|
|
|
416
424
|
onError = extractNextConfig(prop.initializer, checker)
|
|
417
425
|
} else if (propName === 'input') {
|
|
418
426
|
input = extractInputMapping(prop.initializer, checker)
|
|
427
|
+
} else if (propName === 'retries') {
|
|
428
|
+
if (ts.isNumericLiteral(prop.initializer)) {
|
|
429
|
+
retries = Number(prop.initializer.text)
|
|
430
|
+
}
|
|
431
|
+
} else if (propName === 'retryDelay') {
|
|
432
|
+
if (ts.isNumericLiteral(prop.initializer)) {
|
|
433
|
+
retryDelay = Number(prop.initializer.text)
|
|
434
|
+
} else if (ts.isStringLiteral(prop.initializer)) {
|
|
435
|
+
retryDelay = prop.initializer.text
|
|
436
|
+
}
|
|
419
437
|
}
|
|
420
438
|
}
|
|
421
439
|
|
|
422
|
-
return { next, onError, input }
|
|
440
|
+
return { next, onError, input, retries, retryDelay }
|
|
423
441
|
}
|
|
424
442
|
|
|
425
443
|
/**
|
|
@@ -0,0 +1,197 @@
|
|
|
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 { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
|
|
10
|
+
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
function makeLogger() {
|
|
13
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
14
|
+
const logger: InspectorLogger = {
|
|
15
|
+
debug: () => {},
|
|
16
|
+
info: () => {},
|
|
17
|
+
warn: () => {},
|
|
18
|
+
error: () => {},
|
|
19
|
+
critical: (code, message) => criticals.push({ code, message }),
|
|
20
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
21
|
+
}
|
|
22
|
+
return { logger, criticals }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Inline Private<T>/Secret<T> definitions that the test source files use.
|
|
27
|
+
* Mirrors what schema.d.ts emits so the TypeScript program sees the correct
|
|
28
|
+
* structural brand type even without @pikku/core being importable from /tmp.
|
|
29
|
+
*/
|
|
30
|
+
const BRAND_TYPES = `
|
|
31
|
+
type Private<T> = T & { readonly __pii__: 'private' }
|
|
32
|
+
type Secret<T> = T & { readonly __pii__: 'secret' }
|
|
33
|
+
`
|
|
34
|
+
|
|
35
|
+
async function runInspect(sourceCode: string) {
|
|
36
|
+
const tmpDir = await mkdtemp(join(tmpdir(), 'pikku-pii-test-'))
|
|
37
|
+
const file = join(tmpDir, 'funcs.ts')
|
|
38
|
+
await writeFile(file, sourceCode)
|
|
39
|
+
const { logger, criticals } = makeLogger()
|
|
40
|
+
try {
|
|
41
|
+
await inspect(logger, [file], { rootDir: tmpDir })
|
|
42
|
+
} finally {
|
|
43
|
+
await rm(tmpDir, { recursive: true, force: true })
|
|
44
|
+
}
|
|
45
|
+
return criticals
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── findPiiPaths unit tests via full inspect() round-trip ────────────────────
|
|
49
|
+
|
|
50
|
+
describe('PII output check — PKU910', () => {
|
|
51
|
+
test('flags a top-level Private<string> field', async () => {
|
|
52
|
+
const criticals = await runInspect(`
|
|
53
|
+
${BRAND_TYPES}
|
|
54
|
+
import { pikkuFunc } from '@pikku/core'
|
|
55
|
+
export const getUser = pikkuFunc({
|
|
56
|
+
func: async () => {
|
|
57
|
+
const email = 'test@example.com' as Private<string>
|
|
58
|
+
return { id: 1, email }
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
`)
|
|
62
|
+
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/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('flags a top-level Secret<string> field', async () => {
|
|
68
|
+
const criticals = await runInspect(`
|
|
69
|
+
${BRAND_TYPES}
|
|
70
|
+
import { pikkuFunc } from '@pikku/core'
|
|
71
|
+
export const getToken = pikkuFunc({
|
|
72
|
+
func: async () => {
|
|
73
|
+
const token = 'abc' as Secret<string>
|
|
74
|
+
return { token }
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
`)
|
|
78
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
79
|
+
assert.ok(hit)
|
|
80
|
+
assert.match(hit.message, /token/)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
test('flags a nested Private field', async () => {
|
|
84
|
+
const criticals = await runInspect(`
|
|
85
|
+
${BRAND_TYPES}
|
|
86
|
+
import { pikkuFunc } from '@pikku/core'
|
|
87
|
+
export const getProfile = pikkuFunc({
|
|
88
|
+
func: async () => {
|
|
89
|
+
const email = 'x@y.com' as Private<string>
|
|
90
|
+
return { user: { id: 1, email } }
|
|
91
|
+
}
|
|
92
|
+
})
|
|
93
|
+
`)
|
|
94
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
95
|
+
assert.ok(hit)
|
|
96
|
+
assert.match(hit.message, /user\.email/)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
test('does not flag a plain string return', async () => {
|
|
100
|
+
const criticals = await runInspect(`
|
|
101
|
+
import { pikkuFunc } from '@pikku/core'
|
|
102
|
+
export const getPublicData = pikkuFunc({
|
|
103
|
+
func: async () => ({ id: 1, status: 'active', count: 42 })
|
|
104
|
+
})
|
|
105
|
+
`)
|
|
106
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
107
|
+
assert.equal(hit, undefined, `Expected no PKU910 but got: ${JSON.stringify(hit)}`)
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('does not flag a void-returning function', async () => {
|
|
111
|
+
const criticals = await runInspect(`
|
|
112
|
+
import { pikkuFunc } from '@pikku/core'
|
|
113
|
+
export const doWork = pikkuFunc({
|
|
114
|
+
func: async () => { /* no return */ }
|
|
115
|
+
})
|
|
116
|
+
`)
|
|
117
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
118
|
+
assert.equal(hit, undefined)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('flags a function that returns a typed alias with Private field', async () => {
|
|
122
|
+
const criticals = await runInspect(`
|
|
123
|
+
${BRAND_TYPES}
|
|
124
|
+
import { pikkuFunc } from '@pikku/core'
|
|
125
|
+
type UserRow = { id: number; email: Private<string> }
|
|
126
|
+
export const getUser = pikkuFunc({
|
|
127
|
+
func: async (): Promise<UserRow> => {
|
|
128
|
+
return { id: 1, email: 'x' as Private<string> }
|
|
129
|
+
}
|
|
130
|
+
})
|
|
131
|
+
`)
|
|
132
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
133
|
+
assert.ok(hit)
|
|
134
|
+
assert.match(hit.message, /email/)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test('flags across multiple functions in the same file', async () => {
|
|
138
|
+
const criticals = await runInspect(`
|
|
139
|
+
${BRAND_TYPES}
|
|
140
|
+
import { pikkuFunc } from '@pikku/core'
|
|
141
|
+
export const getEmail = pikkuFunc({
|
|
142
|
+
func: async () => ({ email: 'x' as Private<string> })
|
|
143
|
+
})
|
|
144
|
+
export const getPhone = pikkuFunc({
|
|
145
|
+
func: async () => ({ phone: '555' as Private<string> })
|
|
146
|
+
})
|
|
147
|
+
export const getSafe = pikkuFunc({
|
|
148
|
+
func: async () => ({ name: 'Alice' })
|
|
149
|
+
})
|
|
150
|
+
`)
|
|
151
|
+
const hits = criticals.filter((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
152
|
+
assert.equal(hits.length, 2, `Expected 2 PKU910 but got ${hits.length}`)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test('flags branded values inside arrays', async () => {
|
|
156
|
+
const criticals = await runInspect(`
|
|
157
|
+
${BRAND_TYPES}
|
|
158
|
+
import { pikkuFunc } from '@pikku/core'
|
|
159
|
+
export const getEmails = pikkuFunc({
|
|
160
|
+
func: async () => ({ emails: ['x@y.com' as Private<string>] })
|
|
161
|
+
})
|
|
162
|
+
`)
|
|
163
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
164
|
+
assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
|
|
165
|
+
assert.match(hit.message, /emails/)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
test('flags branded values inside string-indexed records', async () => {
|
|
169
|
+
const criticals = await runInspect(`
|
|
170
|
+
${BRAND_TYPES}
|
|
171
|
+
import { pikkuFunc } from '@pikku/core'
|
|
172
|
+
export const getMap = pikkuFunc({
|
|
173
|
+
func: async () => ({ byId: { a: 'x@y.com' as Private<string> } as Record<string, Private<string>> })
|
|
174
|
+
})
|
|
175
|
+
`)
|
|
176
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
177
|
+
assert.ok(hit, `Expected PKU910 but got: ${JSON.stringify(criticals)}`)
|
|
178
|
+
assert.match(hit.message, /byId/)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
test('does not flag when branded field is stripped before return', async () => {
|
|
182
|
+
const criticals = await runInspect(`
|
|
183
|
+
${BRAND_TYPES}
|
|
184
|
+
import { pikkuFunc } from '@pikku/core'
|
|
185
|
+
export const getUser = pikkuFunc({
|
|
186
|
+
func: async () => {
|
|
187
|
+
const raw: { email: Private<string> } = { email: 'x' as Private<string> }
|
|
188
|
+
const safe: { email: string } = { email: raw.email as string }
|
|
189
|
+
return safe
|
|
190
|
+
}
|
|
191
|
+
})
|
|
192
|
+
`)
|
|
193
|
+
// The explicit type annotation on 'safe' strips the brand from the inferred return type
|
|
194
|
+
const hit = criticals.find((c) => c.code === ErrorCode.PII_IN_OUTPUT)
|
|
195
|
+
assert.equal(hit, undefined)
|
|
196
|
+
})
|
|
197
|
+
})
|