@pikku/inspector 0.12.20 → 0.12.22

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.
@@ -39,6 +39,9 @@ describe('addFunctions duplicate name handling', () => {
39
39
  info: () => {},
40
40
  warn: () => {},
41
41
  error: () => {},
42
+ diagnostic: ({ code, message }) => {
43
+ criticals.push({ code, message })
44
+ },
42
45
  critical: (code: ErrorCode, message: string) => {
43
46
  criticals.push({ code, message })
44
47
  },
@@ -91,6 +94,9 @@ describe('addFunctions duplicate name handling', () => {
91
94
  info: () => {},
92
95
  warn: () => {},
93
96
  error: () => {},
97
+ diagnostic: ({ code, message }) => {
98
+ criticals.push({ code, message })
99
+ },
94
100
  critical: (code: ErrorCode, message: string) => {
95
101
  criticals.push({ code, message })
96
102
  },
@@ -142,6 +148,7 @@ describe('addFunctions duplicate name handling', () => {
142
148
  info: () => {},
143
149
  warn: () => {},
144
150
  error: () => {},
151
+ diagnostic: () => {},
145
152
  critical: () => {},
146
153
  hasCriticalErrors: () => false,
147
154
  }
@@ -204,6 +211,9 @@ describe('addFunctions duplicate name handling', () => {
204
211
  info: () => {},
205
212
  warn: () => {},
206
213
  error: () => {},
214
+ diagnostic: ({ code, message }) => {
215
+ criticals.push({ code, message })
216
+ },
207
217
  critical: (code: ErrorCode, message: string) => {
208
218
  criticals.push({ code, message })
209
219
  },
@@ -250,6 +260,7 @@ describe('addFunctions implementationHash', () => {
250
260
  info: () => {},
251
261
  warn: () => {},
252
262
  error: () => {},
263
+ diagnostic: () => {},
253
264
  critical: () => {},
254
265
  hasCriticalErrors: () => false,
255
266
  }
@@ -296,6 +307,7 @@ describe('addFunctions implementationHash', () => {
296
307
  info: () => {},
297
308
  warn: () => {},
298
309
  error: () => {},
310
+ diagnostic: () => {},
299
311
  critical: () => {},
300
312
  hasCriticalErrors: () => false,
301
313
  }
@@ -349,6 +361,7 @@ describe('pikkuChannelConnectionFunc generic mapping', () => {
349
361
  info: () => {},
350
362
  warn: () => {},
351
363
  error: () => {},
364
+ diagnostic: () => {},
352
365
  critical: () => {},
353
366
  hasCriticalErrors: () => false,
354
367
  }
@@ -931,23 +931,27 @@ export const addFunctions: AddWiring = (
931
931
  .map((f) => f.path)
932
932
 
933
933
  if (secretPaths.length > 0) {
934
- logger.critical(
935
- ErrorCode.PII_IN_OUTPUT,
936
- `Function '${name}' exposes secret-classified field(s) in its return type: ` +
934
+ logger.diagnostic({
935
+ severity: 'error',
936
+ code: ErrorCode.PII_IN_OUTPUT,
937
+ message:
938
+ `Function '${name}' exposes secret-classified field(s) in its return type: ` +
937
939
  secretPaths.map((p) => `'${p}'`).join(', ') +
938
940
  `.\n Secret fields must never appear in function output. ` +
939
- `Strip these fields before returning or change the column classification.`
940
- )
941
+ `Strip these fields before returning or change the column classification.`,
942
+ })
941
943
  }
942
944
 
943
945
  if (sessionless && privatePaths.length > 0) {
944
- logger.critical(
945
- ErrorCode.PII_IN_OUTPUT,
946
- `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
946
+ logger.diagnostic({
947
+ severity: 'error',
948
+ code: ErrorCode.PII_IN_OUTPUT,
949
+ message:
950
+ `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
947
951
  privatePaths.map((p) => `'${p}'`).join(', ') +
948
952
  `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
949
- `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`
950
- )
953
+ `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`,
954
+ })
951
955
  }
952
956
  }
953
957
  }
@@ -0,0 +1,106 @@
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
+ diagnostic: ({ code, message }) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ critical: (code: any, message: string) => {
21
+ criticals.push({ code, message })
22
+ },
23
+ hasCriticalErrors: () => criticals.length > 0,
24
+ }
25
+ }
26
+
27
+ const STEP_FILE = [
28
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
29
+ 'export const processEventLeadsStep = pikkuSessionlessFunc({',
30
+ ' func: async ({ logger }) => ({ persistedCount: 1 }),',
31
+ '})',
32
+ ].join('\n')
33
+
34
+ describe('addWorkflow — Promise.all fanout RPC detection', () => {
35
+ test('registers fanout RPC when captured with `const x = await Promise.all(map(...))`', async () => {
36
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-fanout-const-'))
37
+ const wfFile = join(rootDir, 'leads.workflow.ts')
38
+ const stepFile = join(rootDir, 'leads.steps.ts')
39
+
40
+ await writeFile(stepFile, STEP_FILE)
41
+ await writeFile(
42
+ wfFile,
43
+ [
44
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
45
+ 'export const extractLeadsWorkflow = pikkuWorkflowFunc(async (_, data, { workflow }) => {',
46
+ ' const events = [{ id: "a", name: "x" }]',
47
+ ' const processed = await Promise.all(',
48
+ ' events.map((event) =>',
49
+ " workflow.do(`Enrich event ${event.id ?? event.name}`, 'processEventLeadsStep', { event })",
50
+ ' )',
51
+ ' )',
52
+ ' return { count: processed.length }',
53
+ '})',
54
+ ].join('\n')
55
+ )
56
+
57
+ const criticals: Array<{ code: string; message: string }> = []
58
+ try {
59
+ const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
60
+ rootDir,
61
+ })
62
+ assert.ok(
63
+ state.rpc.invokedFunctions.has('processEventLeadsStep'),
64
+ 'processEventLeadsStep should be registered when fanout is captured with const'
65
+ )
66
+ } finally {
67
+ await rm(rootDir, { recursive: true, force: true })
68
+ }
69
+ })
70
+
71
+ test('registers fanout RPC with string-concatenation (`+`) step name, same as template literal', async () => {
72
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-fanout-concat-'))
73
+ const wfFile = join(rootDir, 'leads.workflow.ts')
74
+ const stepFile = join(rootDir, 'leads.steps.ts')
75
+
76
+ await writeFile(stepFile, STEP_FILE)
77
+ await writeFile(
78
+ wfFile,
79
+ [
80
+ "import { pikkuWorkflowFunc } from '@pikku/core/workflow'",
81
+ 'export const extractLeadsWorkflow = pikkuWorkflowFunc(async (_, data, { workflow }) => {',
82
+ ' const events = [{ id: "a", name: "x" }]',
83
+ ' await Promise.all(',
84
+ ' events.map((event) =>',
85
+ " workflow.do('Enrich event ' + (event.id ?? event.name), 'processEventLeadsStep', { event })",
86
+ ' )',
87
+ ' )',
88
+ ' return { ok: true }',
89
+ '})',
90
+ ].join('\n')
91
+ )
92
+
93
+ const criticals: Array<{ code: string; message: string }> = []
94
+ try {
95
+ const state = await inspect(makeLogger(criticals), [stepFile, wfFile], {
96
+ rootDir,
97
+ })
98
+ assert.ok(
99
+ state.rpc.invokedFunctions.has('processEventLeadsStep'),
100
+ 'processEventLeadsStep should be registered even when the step name uses `+` concatenation with a non-static operand'
101
+ )
102
+ } finally {
103
+ await rm(rootDir, { recursive: true, force: true })
104
+ }
105
+ })
106
+ })
@@ -14,6 +14,9 @@ function makeLogger(
14
14
  info: () => {},
15
15
  warn: () => {},
16
16
  error: () => {},
17
+ diagnostic: ({ code, message }) => {
18
+ criticals.push({ code, message })
19
+ },
17
20
  critical: (code: any, message: string) => {
18
21
  criticals.push({ code, message })
19
22
  },
@@ -16,6 +16,20 @@ import {
16
16
  getPropertyValue,
17
17
  } from '../utils/get-property-value.js'
18
18
  import { extractDSLWorkflow } from '../utils/workflow/dsl/extract-dsl-workflow.js'
19
+ import { getSourceText } from '../utils/workflow/dsl/patterns.js'
20
+
21
+ /**
22
+ * Extract a workflow step's display name without letting a non-static name
23
+ * (e.g. a function call) abort the scan. The step name is cosmetic, so a
24
+ * resolution failure must never prevent the RPC from being registered.
25
+ */
26
+ function extractStepName(node: ts.Node, checker: ts.TypeChecker): string {
27
+ try {
28
+ return extractStringLiteral(node, checker)
29
+ } catch {
30
+ return getSourceText(node)
31
+ }
32
+ }
19
33
 
20
34
  /**
21
35
  * Recursively check if any step has inline type (non-serializable)
@@ -99,7 +113,7 @@ function getWorkflowInvocations(
99
113
  const optionsArg =
100
114
  args.length >= 4 ? args[args.length - 1] : undefined
101
115
 
102
- const stepName = extractStringLiteral(stepNameArg, checker)
116
+ const stepName = extractStepName(stepNameArg, checker)
103
117
  const description =
104
118
  extractDescription(optionsArg, checker) ?? undefined
105
119
 
@@ -126,7 +140,7 @@ function getWorkflowInvocations(
126
140
  const stepNameArg = args[0]
127
141
  const durationArg = args[1]
128
142
 
129
- const stepName = extractStringLiteral(stepNameArg, checker)
143
+ const stepName = extractStepName(stepNameArg, checker)
130
144
  const duration = extractDuration(durationArg, checker)
131
145
 
132
146
  steps.push({
@@ -10,12 +10,16 @@ import type { InspectorLogger } from '../types.js'
10
10
  // ── helpers ──────────────────────────────────────────────────────────────────
11
11
 
12
12
  function makeLogger() {
13
+ // Collects every coded diagnostic regardless of severity. PKU910 is now
14
+ // emitted at 'error' severity (surface, don't block the dev server) so it
15
+ // arrives via `diagnostic`, not `critical`.
13
16
  const criticals: Array<{ code: ErrorCode; message: string }> = []
14
17
  const logger: InspectorLogger = {
15
18
  debug: () => {},
16
19
  info: () => {},
17
20
  warn: () => {},
18
21
  error: () => {},
22
+ diagnostic: ({ code, message }) => criticals.push({ code, message }),
19
23
  critical: (code, message) => criticals.push({ code, message }),
20
24
  hasCriticalErrors: () => criticals.length > 0,
21
25
  }
@@ -13,6 +13,9 @@ const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
13
13
  info: () => {},
14
14
  warn: () => {},
15
15
  error: () => {},
16
+ diagnostic: ({ code, message }) => {
17
+ criticals.push({ code, message })
18
+ },
16
19
  critical: (code: ErrorCode, message: string) => {
17
20
  criticals.push({ code, message })
18
21
  },
@@ -85,3 +85,17 @@ export enum ErrorCode {
85
85
  // Data classification errors
86
86
  PII_IN_OUTPUT = 'PKU910',
87
87
  }
88
+
89
+ /**
90
+ * Severity of a tracked, coded diagnostic. `critical` always blocks the build;
91
+ * `error`/`warn` only block when the CLI is told to via `--fail-on-error` /
92
+ * `--fail-on-warn` (default: critical only). All severities are still printed.
93
+ */
94
+ export type DiagnosticSeverity = 'warn' | 'error' | 'critical'
95
+
96
+ /** A coded diagnostic emitted via `logger.diagnostic(...)`. */
97
+ export interface CodedDiagnostic {
98
+ severity: DiagnosticSeverity
99
+ code: ErrorCode
100
+ message: string
101
+ }
package/src/index.ts CHANGED
@@ -3,6 +3,7 @@ export type { TypesMap } from './types-map.js'
3
3
  export type * from './types.js'
4
4
  export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js'
5
5
  export { ErrorCode } from './error-codes.js'
6
+ export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js'
6
7
  export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js'
7
8
  export {
8
9
  serializeInspectorState,
package/src/inspector.ts CHANGED
@@ -256,9 +256,16 @@ export const inspect = async (
256
256
  const rootDir = options.rootDir || findCommonAncestor(routeFiles)
257
257
 
258
258
  const startSourceFiles = performance.now()
259
+ // node_modules under rootDir (e.g. a locally-installed addon) is a
260
+ // dependency, not project source — scanning it double-counts the addon's
261
+ // own application types (CoreConfig/Services/SingletonServices).
259
262
  const sourceFiles = program
260
263
  .getSourceFiles()
261
- .filter((sf) => sf.fileName.startsWith(rootDir))
264
+ .filter(
265
+ (sf) =>
266
+ sf.fileName.startsWith(rootDir) &&
267
+ !sf.fileName.includes('/node_modules/')
268
+ )
262
269
  logger.debug(
263
270
  `Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`
264
271
  )
package/src/types.ts CHANGED
@@ -25,7 +25,7 @@ import type {
25
25
  JSONValue,
26
26
  } from '@pikku/core'
27
27
  import type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
28
- import type { ErrorCode } from './error-codes.js'
28
+ import type { ErrorCode, CodedDiagnostic } from './error-codes.js'
29
29
  import type {
30
30
  VersionManifest,
31
31
  VersionValidateError,
@@ -238,6 +238,15 @@ export interface InspectorLogger {
238
238
  error: (message: string) => void
239
239
  warn: (message: string) => void
240
240
  debug: (message: string) => void
241
+ /**
242
+ * Emit a tracked, coded diagnostic. It is recorded and printed; `error`/`warn`
243
+ * only block the build when the CLI is run with `--fail-on-error` /
244
+ * `--fail-on-warn` (default: critical only). Use this for issues worth
245
+ * surfacing (e.g. data-classification leaks) that should not stop the dev
246
+ * server from starting.
247
+ */
248
+ diagnostic: (diagnostic: CodedDiagnostic) => void
249
+ /** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
241
250
  critical: (code: ErrorCode, message: string) => void
242
251
  hasCriticalErrors: () => boolean
243
252
  }
@@ -318,6 +327,9 @@ export interface AuthDefinition {
318
327
  * `auth-meta.gen.json` so the console SSO page can show which plugins are
319
328
  * enabled. */
320
329
  plugins: string[]
330
+ /** Whether `session.cookieCache` is enabled — drives the stateless session
331
+ * middleware split in the auth codegen. Absent/false ⇒ stateful middleware. */
332
+ cookieCache?: boolean
321
333
  /**
322
334
  * Singleton services the generated auth handler must have available at
323
335
  * runtime — the services the `pikkuBetterAuth` factory reaches for (either
@@ -1,7 +1,7 @@
1
1
  import { test, describe } from 'node:test'
2
2
  import { strict as assert } from 'node:assert'
3
3
  import * as ts from 'typescript'
4
- import { extractDescription } from './extract-node-value'
4
+ import { extractDescription, extractStringLiteral } from './extract-node-value'
5
5
 
6
6
  const createChecker = (source: string) => {
7
7
  const sourceFile = ts.createSourceFile(
@@ -67,3 +67,51 @@ describe('extractDescription', () => {
67
67
  assert.equal(extractDescription(sourceFile, checker), null)
68
68
  })
69
69
  })
70
+
71
+ describe('extractStringLiteral — concatenation/template symmetry', () => {
72
+ const findInitializer = (node: ts.Node): ts.Expression | undefined => {
73
+ if (ts.isVariableDeclaration(node) && node.initializer) {
74
+ return node.initializer
75
+ }
76
+ let result: ts.Expression | undefined
77
+ ts.forEachChild(node, (child) => {
78
+ if (!result) result = findInitializer(child)
79
+ })
80
+ return result
81
+ }
82
+
83
+ test('a `+` operand that cannot be statically resolved becomes a ${...} placeholder', () => {
84
+ const { checker, sourceFile } = createChecker(
85
+ `const x = 'Enrich event ' + (event.id ?? event.name)`
86
+ )
87
+ const init = findInitializer(sourceFile)!
88
+ assert.equal(
89
+ extractStringLiteral(init, checker),
90
+ 'Enrich event ${event.id ?? event.name}'
91
+ )
92
+ })
93
+
94
+ test('`+` concatenation and template literal produce the same display string', () => {
95
+ const concat = createChecker(
96
+ `const x = 'Enrich event ' + (event.id ?? event.name)`
97
+ )
98
+ const template = createChecker(
99
+ 'const x = `Enrich event ${event.id ?? event.name}`'
100
+ )
101
+ const concatValue = extractStringLiteral(
102
+ findInitializer(concat.sourceFile)!,
103
+ concat.checker
104
+ )
105
+ const templateValue = extractStringLiteral(
106
+ findInitializer(template.sourceFile)!,
107
+ template.checker
108
+ )
109
+ assert.equal(concatValue, templateValue)
110
+ })
111
+
112
+ test('still resolves fully-static concatenation exactly', () => {
113
+ const { checker, sourceFile } = createChecker(`const x = 'a' + 'b' + 'c'`)
114
+ const init = findInitializer(sourceFile)!
115
+ assert.equal(extractStringLiteral(init, checker), 'abc')
116
+ })
117
+ })
@@ -37,8 +37,8 @@ export function extractStringLiteral(
37
37
  node.operatorToken.kind === ts.SyntaxKind.PlusToken
38
38
  ) {
39
39
  return (
40
- extractStringLiteral(node.left, checker) +
41
- extractStringLiteral(node.right, checker)
40
+ extractConcatOperand(node.left, checker) +
41
+ extractConcatOperand(node.right, checker)
42
42
  )
43
43
  }
44
44
 
@@ -59,6 +59,23 @@ export function extractStringLiteral(
59
59
  throw new Error('Unable to extract string literal from node')
60
60
  }
61
61
 
62
+ /**
63
+ * Resolve one operand of a `+` string concatenation.
64
+ *
65
+ * An operand that can't be statically resolved (e.g. `a ?? b`) becomes a
66
+ * `${...}` placeholder rather than throwing — mirroring the TemplateExpression
67
+ * branch above, so `'x ' + expr` and `` `x ${expr}` `` produce the same string.
68
+ * This keeps an unresolvable display name from aborting the whole extraction.
69
+ */
70
+ function extractConcatOperand(node: ts.Node, checker: ts.TypeChecker): string {
71
+ try {
72
+ return extractStringLiteral(node, checker)
73
+ } catch {
74
+ const inner = ts.isParenthesizedExpression(node) ? node.expression : node
75
+ return '${' + inner.getText() + '}'
76
+ }
77
+ }
78
+
62
79
  /**
63
80
  * Check if node is string-like (string literal or template expression)
64
81
  */
@@ -344,6 +344,7 @@ const mockLogger = {
344
344
  error: () => {},
345
345
  warn: () => {},
346
346
  debug: () => {},
347
+ diagnostic: () => {},
347
348
  critical: () => {},
348
349
  hasCriticalErrors: () => false,
349
350
  }
@@ -10,6 +10,7 @@ describe('matchesFilters', () => {
10
10
  error: () => {},
11
11
  warn: () => {},
12
12
  debug: () => {},
13
+ diagnostic: () => {},
13
14
  critical: () => {},
14
15
  hasCriticalErrors: () => false,
15
16
  }
@@ -43,6 +43,7 @@ function makeLogger() {
43
43
  info: () => {},
44
44
  warn: () => {},
45
45
  error: (msg: string) => errors.push(msg),
46
+ diagnostic: ({ message }: { message: string }) => errors.push(message),
46
47
  critical: (_code: string, msg: string) => errors.push(msg),
47
48
  },
48
49
  errors,
@@ -386,6 +386,22 @@ function extractVariableDeclaration(
386
386
  return step
387
387
  }
388
388
  }
389
+
390
+ // Promise.all fanout/group captured into a variable
391
+ // (const results = await Promise.all(array.map(...)))
392
+ if (isParallelFanout(call) || isParallelGroup(call)) {
393
+ const step = isParallelFanout(call)
394
+ ? extractParallelFanout(call, context)
395
+ : extractParallelGroup(call, context)
396
+ if (step) {
397
+ const type = context.checker.getTypeAtLocation(decl)
398
+ context.outputVars.set(varName, { type, node: decl })
399
+ if (isArrayType(type, context.checker)) {
400
+ context.arrayVars.add(varName)
401
+ }
402
+ return step
403
+ }
404
+ }
389
405
  }
390
406
 
391
407
  // Check for array.filter(...)