@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.
Files changed (65) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/dist/add/add-cli.js +10 -3
  3. package/dist/add/add-credential.js +2 -1
  4. package/dist/add/add-functions.js +48 -1
  5. package/dist/add/add-http-route.js +24 -5
  6. package/dist/add/add-keyed-wiring.js +3 -1
  7. package/dist/add/add-middleware.js +33 -4
  8. package/dist/add/add-permission.js +7 -7
  9. package/dist/add/add-workflow-graph.js +20 -1
  10. package/dist/error-codes.d.ts +3 -1
  11. package/dist/error-codes.js +3 -0
  12. package/dist/index.d.ts +1 -0
  13. package/dist/index.js +1 -0
  14. package/dist/inspector.js +2 -5
  15. package/dist/types.d.ts +10 -19
  16. package/dist/utils/check-pii-output.d.ts +14 -0
  17. package/dist/utils/check-pii-output.js +63 -0
  18. package/dist/utils/extract-function-name.js +6 -0
  19. package/dist/utils/filter-inspector-state.js +187 -59
  20. package/dist/utils/filter-utils.js +13 -5
  21. package/dist/utils/get-property-value.d.ts +10 -0
  22. package/dist/utils/get-property-value.js +30 -0
  23. package/dist/utils/post-process.d.ts +2 -3
  24. package/dist/utils/post-process.js +3 -23
  25. package/dist/utils/resolve-addon-package.d.ts +4 -5
  26. package/dist/utils/resolve-addon-package.js +64 -16
  27. package/dist/utils/resolve-deploy-target.d.ts +28 -0
  28. package/dist/utils/resolve-deploy-target.js +56 -0
  29. package/dist/utils/resolve-versions.js +79 -0
  30. package/dist/utils/schema-generator.js +31 -12
  31. package/package.json +2 -2
  32. package/src/add/add-cli.ts +10 -3
  33. package/src/add/add-credential.ts +3 -0
  34. package/src/add/add-functions.test.ts +149 -0
  35. package/src/add/add-functions.ts +61 -1
  36. package/src/add/add-gateway.ts +5 -1
  37. package/src/add/add-http-route.ts +26 -6
  38. package/src/add/add-keyed-wiring.ts +7 -1
  39. package/src/add/add-mcp-prompt.ts +5 -1
  40. package/src/add/add-mcp-resource.ts +5 -1
  41. package/src/add/add-middleware.ts +42 -4
  42. package/src/add/add-permission.ts +7 -7
  43. package/src/add/add-schedule.ts +5 -1
  44. package/src/add/add-workflow-graph.ts +19 -1
  45. package/src/add/pii-check.test.ts +197 -0
  46. package/src/add/wire-name-literal.test.ts +114 -0
  47. package/src/error-codes.ts +4 -0
  48. package/src/index.ts +1 -0
  49. package/src/inspector.ts +1 -5
  50. package/src/types.ts +19 -15
  51. package/src/utils/check-pii-output.ts +76 -0
  52. package/src/utils/extract-function-name.ts +8 -0
  53. package/src/utils/filter-inspector-state.test.ts +168 -64
  54. package/src/utils/filter-inspector-state.ts +290 -64
  55. package/src/utils/filter-utils.test.ts +30 -15
  56. package/src/utils/filter-utils.ts +14 -5
  57. package/src/utils/get-property-value.ts +40 -0
  58. package/src/utils/post-process.ts +3 -38
  59. package/src/utils/resolve-addon-package.ts +65 -14
  60. package/src/utils/resolve-deploy-target.test.ts +105 -0
  61. package/src/utils/resolve-deploy-target.ts +63 -0
  62. package/src/utils/resolve-versions.test.ts +108 -0
  63. package/src/utils/resolve-versions.ts +86 -0
  64. package/src/utils/schema-generator.ts +37 -13
  65. package/tsconfig.tsbuildinfo +1 -1
@@ -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
- const baseName = explicitName || exportedName || pikkuFuncId
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,
@@ -70,7 +70,11 @@ export const addGateway: AddWiring = (
70
70
  }
71
71
 
72
72
  const packageName = ts.isIdentifier(funcInitializer)
73
- ? resolveAddonName(funcInitializer, checker, state.rpc.wireAddonDeclarations)
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
- // Validate that route params and query params exist in function input type
277
- if (params.length > 0 || query.length > 0) {
278
- const inputTypes = state.typesLookup.get(funcName)
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
- const inputKeys = extractTypeKeys(inputTypes[0])
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 { getPropertyValue } from '../utils/get-property-value.js'
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(funcInitializer, checker, state.rpc.wireAddonDeclarations)
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(funcInitializer, checker, state.rpc.wireAddonDeclarations)
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 === 'addMiddleware') {
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(`• addMiddleware call without valid tag string`)
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
- `• addMiddleware('${tag}', ...) must have a literal array as second argument`
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} = () => addMiddleware('${tag}', [...])`
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 === 'addPermission' ||
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 = () => addPermission('tag', [...]) (factory - tree-shakeable)
342
- // 2. export const x = addPermission('tag', [...]) (direct - no tree-shaking)
343
- if (expression.text === 'addPermission') {
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(`• addPermission call without valid tag string`)
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
- `• addPermission('${tag}', ...) must have a literal array or object as second argument`
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} = () => addPermission('${tag}', [...])`
419
+ `For tree-shaking, use: export const ${exportedName} = () => addTagPermission('${tag}', [...])`
420
420
  )
421
421
  }
422
422
 
@@ -73,7 +73,11 @@ export const addSchedule: AddWiring = (
73
73
  }
74
74
 
75
75
  const packageName = ts.isIdentifier(funcInitializer)
76
- ? resolveAddonName(funcInitializer, checker, state.rpc.wireAddonDeclarations)
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
+ })