@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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,34 @@
1
+ ## 0.12.22
2
+
3
+ ### Patch Changes
4
+
5
+ - 06234a9: Fix DSL `Promise.all` fanout silently failing to register its child RPC (causing a runtime "Function not found").
6
+
7
+ Two distinct causes are addressed:
8
+ - A fanout/group captured into a variable (`const results = await Promise.all(array.map(e => workflow.do(...)))`) was dropped entirely, because the `const`-declaration path had no `Promise.all` branch — fanout handling only ran on the bare/assignment path. The declaration path now extracts fanout and parallel groups too.
9
+ - `extractStringLiteral` threw on a `+` concatenation with a non-static operand (e.g. `'Enrich ' + (e.id ?? e.name)`), unlike a template literal (`` `Enrich ${e.id ?? e.name}` ``) which never threw. The throw was uncaught while scanning workflow invocations and aborted the run. The `+` branch now falls back to `${...}` placeholders to match template literals, and a step's cosmetic display name can no longer block RPC registration.
10
+
11
+ - 8e72c93: Exclude `node_modules` from inspector source scanning. A locally-installed addon (under the project's `node_modules`) is a dependency, not project source — scanning it double-counted the addon's own application types (`CoreConfig`/`CoreServices`/`CoreSingletonServices`) and failed `pikku all` with "More than one … found". Addons still contribute via their generated metadata, not by being re-scanned as source.
12
+ - 6645e7a: Add a severity model for coded diagnostics so security findings can surface without blocking the dev server.
13
+ - `InspectorLogger` gains `diagnostic({ severity, code, message })` (`severity: 'warn' | 'error' | 'critical'`). `critical(code, message)` is now sugar for `diagnostic({ severity: 'critical', ... })`.
14
+ - The CLI fails the build only on `critical` diagnostics by default. New global flags `--fail-on-error` and `--fail-on-warn` (implies `--fail-on-error`) opt into stricter gating; `--fail-on-critical` is always on.
15
+ - Data-classification leaks (`PKU910`) are now emitted at `error` severity instead of `critical`. They are still printed, but no longer abort `pikku all` / the dev server — pass `--fail-on-error` (e.g. at deploy) to make them blocking and recommend a fix.
16
+ - Contract-immutability drift (`PKU861`) during `pikku versions update` (run inside `pikku all`) no longer calls `process.exit(1)`. It is surfaced as an `error` diagnostic and skips saving the manifest, so a stale baseline can't crash-loop the dev server. `pikku versions check` remains the hard gate, and `--fail-on-error` makes `pikku all` block on it at deploy.
17
+
18
+ - Updated dependencies [6bca38f]
19
+ - @pikku/core@0.12.35
20
+
21
+ ## 0.12.21
22
+
23
+ ### Patch Changes
24
+
25
+ - ef50347: Tree-shake the better-auth server out of non-auth units.
26
+ - `@pikku/better-auth`: add `betterAuthStatelessSession()` — a session middleware that verifies the signed better-auth cookie cache via `better-auth/cookies` (`getCookieCache`) using only `BETTER_AUTH_SECRET`, with no `services.auth()`, DB round-trip, or full server import. Mark the package `sideEffects: false` so unused barrel re-exports drop.
27
+ - `@pikku/cli`: when `session.cookieCache` is enabled in the better-auth config, generate the stateless session middleware into a separate `auth-middleware.gen.ts` and wire it globally, keeping the full `/api/auth/**` server only in the auth unit. Deploy artifacts (esbuild metafile + sourcemap) are now off by default; `--debug-artifacts` re-enables them.
28
+ - `@pikku/inspector`: ensure the orphan `auth-middleware.gen.ts` (imported by nothing) is still inspected so its global `addHTTPMiddleware('*')` registration is not dropped.
29
+
30
+ Net effect: a non-auth unit carries ~22KB (cookie-verify floor) instead of the full ~1.25MB better-auth backend.
31
+
1
32
  ## 0.12.20
2
33
 
3
34
  ### Patch Changes
@@ -136,10 +136,24 @@ export const addAuth = (logger, node, _checker, state) => {
136
136
  // Find the inner betterAuth({...}) call to read providers/basePath/credentials.
137
137
  let basePath = DEFAULT_BASE_PATH;
138
138
  let hasCredentials = false;
139
+ let cookieCache = false;
139
140
  const betterAuthCall = findBetterAuthCall(factory);
140
141
  const config = betterAuthCall?.arguments[0];
141
142
  if (config && ts.isObjectLiteralExpression(config)) {
142
143
  basePath = readStringProp(config, 'basePath') ?? DEFAULT_BASE_PATH;
144
+ // Detect `session.cookieCache.enabled` → drives the stateless middleware split.
145
+ const session = readObjectProp(config, 'session');
146
+ if (session) {
147
+ const cookieCacheBlock = readObjectProp(session, 'cookieCache');
148
+ if (cookieCacheBlock) {
149
+ const enabledProp = cookieCacheBlock.properties.find((p) => ts.isPropertyAssignment(p) &&
150
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
151
+ p.name.text === 'enabled');
152
+ cookieCache =
153
+ !enabledProp ||
154
+ enabledProp.initializer.kind !== ts.SyntaxKind.FalseKeyword;
155
+ }
156
+ }
143
157
  const emailAndPassword = readObjectProp(config, 'emailAndPassword');
144
158
  if (emailAndPassword) {
145
159
  // `emailAndPassword: { enabled: true }` — treat a present block without an
@@ -182,6 +196,7 @@ export const addAuth = (logger, node, _checker, state) => {
182
196
  sourceFile,
183
197
  basePath,
184
198
  hasCredentials,
199
+ cookieCache,
185
200
  plugins: [...state.auth.plugins],
186
201
  services,
187
202
  };
@@ -7,6 +7,7 @@ import { getPropertyValue } from '../utils/get-property-value.js';
7
7
  import { resolveIdentifier } from '../utils/resolve-identifier.js';
8
8
  import { resolveAddonName } from '../utils/resolve-addon-package.js';
9
9
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js';
10
+ import { extractServicesFromFunction } from '../utils/extract-services.js';
10
11
  // Track if we've warned about missing Config type to avoid duplicate warnings
11
12
  const configTypeWarningShown = new Set();
12
13
  /**
@@ -628,7 +629,7 @@ function parseCommandPattern(pattern) {
628
629
  export const addCLIRenderers = (logger, node, typeChecker, inspectorState, options) => {
629
630
  if (!ts.isCallExpression(node))
630
631
  return;
631
- const { expression, arguments: args, typeArguments } = node;
632
+ const { expression, arguments: args } = node;
632
633
  // Only handle pikkuCLIRender calls
633
634
  if (!ts.isIdentifier(expression) || expression.text !== 'pikkuCLIRender') {
634
635
  return;
@@ -640,27 +641,12 @@ export const addCLIRenderers = (logger, node, typeChecker, inspectorState, optio
640
641
  // Get the source file path
641
642
  const sourceFile = node.getSourceFile();
642
643
  const filePath = sourceFile.fileName;
643
- // Extract services from type parameters (second type param is Services)
644
- const services = {
645
- optimized: true,
646
- services: [],
647
- };
648
- if (typeArguments && typeArguments.length >= 2) {
649
- // Second type parameter is the Services type
650
- const servicesTypeNode = typeArguments[1];
651
- if (servicesTypeNode) {
652
- const servicesType = typeChecker.getTypeFromTypeNode(servicesTypeNode);
653
- // Extract property names from the Services type
654
- const properties = servicesType.getProperties();
655
- for (const prop of properties) {
656
- services.services.push(prop.getName());
657
- }
658
- // If no specific services found, it might be using the full services object
659
- if (properties.length === 0) {
660
- services.optimized = false;
661
- }
662
- }
663
- }
644
+ // Renderer service usage is defined by the callback's first parameter shape,
645
+ // the same way function/auth service usage is tracked elsewhere in the inspector.
646
+ const renderFunc = args[0];
647
+ const services = ts.isArrowFunction(renderFunc) || ts.isFunctionExpression(renderFunc)
648
+ ? extractServicesFromFunction(renderFunc)
649
+ : { optimized: true, services: [] };
664
650
  // Store renderer metadata
665
651
  inspectorState.cli.meta.renderers[pikkuFuncId] = {
666
652
  name: pikkuFuncId,
@@ -660,16 +660,24 @@ export const addFunctions = (logger, node, checker, state, options) => {
660
660
  .filter((f) => f.classification === 'private' || f.classification === 'pii')
661
661
  .map((f) => f.path);
662
662
  if (secretPaths.length > 0) {
663
- logger.critical(ErrorCode.PII_IN_OUTPUT, `Function '${name}' exposes secret-classified field(s) in its return type: ` +
664
- secretPaths.map((p) => `'${p}'`).join(', ') +
665
- `.\n Secret fields must never appear in function output. ` +
666
- `Strip these fields before returning or change the column classification.`);
663
+ logger.diagnostic({
664
+ severity: 'error',
665
+ code: ErrorCode.PII_IN_OUTPUT,
666
+ message: `Function '${name}' exposes secret-classified field(s) in its return type: ` +
667
+ secretPaths.map((p) => `'${p}'`).join(', ') +
668
+ `.\n Secret fields must never appear in function output. ` +
669
+ `Strip these fields before returning or change the column classification.`,
670
+ });
667
671
  }
668
672
  if (sessionless && privatePaths.length > 0) {
669
- logger.critical(ErrorCode.PII_IN_OUTPUT, `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
670
- privatePaths.map((p) => `'${p}'`).join(', ') +
671
- `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
672
- `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`);
673
+ logger.diagnostic({
674
+ severity: 'error',
675
+ code: ErrorCode.PII_IN_OUTPUT,
676
+ message: `Sessionless function '${name}' exposes private-classified field(s) in its return type: ` +
677
+ privatePaths.map((p) => `'${p}'`).join(', ') +
678
+ `.\n Private fields are only safe to return from authenticated (sessioned) functions. ` +
679
+ `Either require a session (use pikkuFunc) or mark the column @public if it is safe to expose publicly.`,
680
+ });
673
681
  }
674
682
  }
675
683
  }
@@ -5,6 +5,20 @@ import { ErrorCode } from '../error-codes.js';
5
5
  import { extractStringLiteral, isStringLike, isFunctionLike, extractDescription, extractDuration, } from '../utils/extract-node-value.js';
6
6
  import { getCommonWireMetaData, getPropertyValue, } from '../utils/get-property-value.js';
7
7
  import { extractDSLWorkflow } from '../utils/workflow/dsl/extract-dsl-workflow.js';
8
+ import { getSourceText } from '../utils/workflow/dsl/patterns.js';
9
+ /**
10
+ * Extract a workflow step's display name without letting a non-static name
11
+ * (e.g. a function call) abort the scan. The step name is cosmetic, so a
12
+ * resolution failure must never prevent the RPC from being registered.
13
+ */
14
+ function extractStepName(node, checker) {
15
+ try {
16
+ return extractStringLiteral(node, checker);
17
+ }
18
+ catch {
19
+ return getSourceText(node);
20
+ }
21
+ }
8
22
  /**
9
23
  * Recursively check if any step has inline type (non-serializable)
10
24
  */
@@ -89,7 +103,7 @@ function getWorkflowInvocations(node, checker, state, workflowName, steps) {
89
103
  const stepNameArg = args[0];
90
104
  const secondArg = args[1];
91
105
  const optionsArg = args.length >= 4 ? args[args.length - 1] : undefined;
92
- const stepName = extractStringLiteral(stepNameArg, checker);
106
+ const stepName = extractStepName(stepNameArg, checker);
93
107
  const description = extractDescription(optionsArg, checker) ?? undefined;
94
108
  // Determine form by checking 2nd argument type
95
109
  if (isStringLike(secondArg, checker)) {
@@ -115,7 +129,7 @@ function getWorkflowInvocations(node, checker, state, workflowName, steps) {
115
129
  // workflow.sleep(stepName, duration)
116
130
  const stepNameArg = args[0];
117
131
  const durationArg = args[1];
118
- const stepName = extractStringLiteral(stepNameArg, checker);
132
+ const stepName = extractStepName(stepNameArg, checker);
119
133
  const duration = extractDuration(durationArg, checker);
120
134
  steps.push({
121
135
  type: 'sleep',
@@ -59,3 +59,15 @@ export declare enum ErrorCode {
59
59
  WORKFLOW_MULTI_QUEUE_NOT_SUPPORTED = "PKU901",
60
60
  PII_IN_OUTPUT = "PKU910"
61
61
  }
62
+ /**
63
+ * Severity of a tracked, coded diagnostic. `critical` always blocks the build;
64
+ * `error`/`warn` only block when the CLI is told to via `--fail-on-error` /
65
+ * `--fail-on-warn` (default: critical only). All severities are still printed.
66
+ */
67
+ export type DiagnosticSeverity = 'warn' | 'error' | 'critical';
68
+ /** A coded diagnostic emitted via `logger.diagnostic(...)`. */
69
+ export interface CodedDiagnostic {
70
+ severity: DiagnosticSeverity;
71
+ code: ErrorCode;
72
+ message: string;
73
+ }
package/dist/index.d.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 { serializeInspectorState, deserializeInspectorState, } from './utils/serialize-inspector-state.js';
8
9
  export type { SerializableInspectorState } from './utils/serialize-inspector-state.js';
package/dist/inspector.js CHANGED
@@ -209,9 +209,13 @@ export const inspect = async (logger, routeFiles, options = {}) => {
209
209
  // Use provided rootDir or infer from source files
210
210
  const rootDir = options.rootDir || findCommonAncestor(routeFiles);
211
211
  const startSourceFiles = performance.now();
212
+ // node_modules under rootDir (e.g. a locally-installed addon) is a
213
+ // dependency, not project source — scanning it double-counts the addon's
214
+ // own application types (CoreConfig/Services/SingletonServices).
212
215
  const sourceFiles = program
213
216
  .getSourceFiles()
214
- .filter((sf) => sf.fileName.startsWith(rootDir));
217
+ .filter((sf) => sf.fileName.startsWith(rootDir) &&
218
+ !sf.fileName.includes('/node_modules/'));
215
219
  logger.debug(`Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`);
216
220
  const state = getInitialInspectorState(rootDir);
217
221
  // First sweep: add all functions
package/dist/types.d.ts CHANGED
@@ -16,7 +16,7 @@ import type { VariableDefinitions } from '@pikku/core/variable';
16
16
  import type { TypesMap } from './types-map.js';
17
17
  import type { FunctionsMeta, FunctionServicesMeta, FunctionWiresMeta, JSONValue } from '@pikku/core';
18
18
  import type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js';
19
- import type { ErrorCode } from './error-codes.js';
19
+ import type { ErrorCode, CodedDiagnostic } from './error-codes.js';
20
20
  import type { VersionManifest, VersionValidateError } from './utils/contract-hashes.js';
21
21
  import type { SerializedWorkflowGraphs } from './utils/workflow/graph/workflow-graph.types.js';
22
22
  export type PathToNameAndType = Map<string, {
@@ -192,6 +192,15 @@ export interface InspectorLogger {
192
192
  error: (message: string) => void;
193
193
  warn: (message: string) => void;
194
194
  debug: (message: string) => void;
195
+ /**
196
+ * Emit a tracked, coded diagnostic. It is recorded and printed; `error`/`warn`
197
+ * only block the build when the CLI is run with `--fail-on-error` /
198
+ * `--fail-on-warn` (default: critical only). Use this for issues worth
199
+ * surfacing (e.g. data-classification leaks) that should not stop the dev
200
+ * server from starting.
201
+ */
202
+ diagnostic: (diagnostic: CodedDiagnostic) => void;
203
+ /** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
195
204
  critical: (code: ErrorCode, message: string) => void;
196
205
  hasCriticalErrors: () => boolean;
197
206
  }
@@ -263,6 +272,9 @@ export interface AuthDefinition {
263
272
  * `auth-meta.gen.json` so the console SSO page can show which plugins are
264
273
  * enabled. */
265
274
  plugins: string[];
275
+ /** Whether `session.cookieCache` is enabled — drives the stateless session
276
+ * middleware split in the auth codegen. Absent/false ⇒ stateful middleware. */
277
+ cookieCache?: boolean;
266
278
  /**
267
279
  * Singleton services the generated auth handler must have available at
268
280
  * runtime — the services the `pikkuBetterAuth` factory reaches for (either
@@ -26,8 +26,8 @@ export function extractStringLiteral(node, checker) {
26
26
  }
27
27
  if (ts.isBinaryExpression(node) &&
28
28
  node.operatorToken.kind === ts.SyntaxKind.PlusToken) {
29
- return (extractStringLiteral(node.left, checker) +
30
- extractStringLiteral(node.right, checker));
29
+ return (extractConcatOperand(node.left, checker) +
30
+ extractConcatOperand(node.right, checker));
31
31
  }
32
32
  // Try to evaluate constant identifiers
33
33
  if (ts.isIdentifier(node)) {
@@ -42,6 +42,23 @@ export function extractStringLiteral(node, checker) {
42
42
  }
43
43
  throw new Error('Unable to extract string literal from node');
44
44
  }
45
+ /**
46
+ * Resolve one operand of a `+` string concatenation.
47
+ *
48
+ * An operand that can't be statically resolved (e.g. `a ?? b`) becomes a
49
+ * `${...}` placeholder rather than throwing — mirroring the TemplateExpression
50
+ * branch above, so `'x ' + expr` and `` `x ${expr}` `` produce the same string.
51
+ * This keeps an unresolvable display name from aborting the whole extraction.
52
+ */
53
+ function extractConcatOperand(node, checker) {
54
+ try {
55
+ return extractStringLiteral(node, checker);
56
+ }
57
+ catch {
58
+ const inner = ts.isParenthesizedExpression(node) ? node.expression : node;
59
+ return '${' + inner.getText() + '}';
60
+ }
61
+ }
45
62
  /**
46
63
  * Check if node is string-like (string literal or template expression)
47
64
  */
@@ -255,6 +255,21 @@ function extractVariableDeclaration(statement, context) {
255
255
  return step;
256
256
  }
257
257
  }
258
+ // Promise.all fanout/group captured into a variable
259
+ // (const results = await Promise.all(array.map(...)))
260
+ if (isParallelFanout(call) || isParallelGroup(call)) {
261
+ const step = isParallelFanout(call)
262
+ ? extractParallelFanout(call, context)
263
+ : extractParallelGroup(call, context);
264
+ if (step) {
265
+ const type = context.checker.getTypeAtLocation(decl);
266
+ context.outputVars.set(varName, { type, node: decl });
267
+ if (isArrayType(type, context.checker)) {
268
+ context.arrayVars.add(varName);
269
+ }
270
+ return step;
271
+ }
272
+ }
258
273
  }
259
274
  // Check for array.filter(...)
260
275
  if (ts.isCallExpression(init)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.20",
3
+ "version": "0.12.22",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -35,7 +35,8 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
38
- "@pikku/core": "^0.12.32",
38
+ "@pikku/core": "^0.12.35",
39
+ "openapi-types": "^12.1.3",
39
40
  "path-to-regexp": "^8.3.0",
40
41
  "ts-json-schema-generator": "^2.5.0",
41
42
  "tsx": "^4.21.0",
@@ -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
  },
@@ -50,6 +53,7 @@ describe('addAuth inspector', () => {
50
53
  sourceFile: file,
51
54
  basePath: '/api/auth',
52
55
  hasCredentials: false,
56
+ cookieCache: false,
53
57
  plugins: [],
54
58
  services: { optimized: true, services: [] },
55
59
  })
@@ -95,6 +99,88 @@ describe('addAuth inspector', () => {
95
99
  }
96
100
  })
97
101
 
102
+ test('detects session.cookieCache for the stateless middleware split', async () => {
103
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-cookiecache-'))
104
+ const file = join(rootDir, 'auth.ts')
105
+
106
+ await writeFile(
107
+ file,
108
+ [
109
+ "import { pikkuBetterAuth } from '@pikku/better-auth'",
110
+ "import { betterAuth } from 'better-auth'",
111
+ 'export const auth = pikkuBetterAuth(() =>',
112
+ ' betterAuth({',
113
+ ' session: { cookieCache: { enabled: true } },',
114
+ " emailAndPassword: { enabled: true },",
115
+ ' })',
116
+ ')',
117
+ ].join('\n')
118
+ )
119
+
120
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
121
+ try {
122
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
123
+ assert.equal(criticals.length, 0)
124
+ assert.equal(state.auth.definition?.cookieCache, true)
125
+ } finally {
126
+ await rm(rootDir, { recursive: true, force: true })
127
+ }
128
+ })
129
+
130
+ test('cookieCache: { enabled: false } does not enable the stateless split', async () => {
131
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-nocache-'))
132
+ const file = join(rootDir, 'auth.ts')
133
+
134
+ await writeFile(
135
+ file,
136
+ [
137
+ "import { pikkuBetterAuth } from '@pikku/better-auth'",
138
+ "import { betterAuth } from 'better-auth'",
139
+ 'export const auth = pikkuBetterAuth(() =>',
140
+ ' betterAuth({',
141
+ ' session: { cookieCache: { enabled: false } },',
142
+ ' })',
143
+ ')',
144
+ ].join('\n')
145
+ )
146
+
147
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
148
+ try {
149
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
150
+ assert.equal(criticals.length, 0)
151
+ assert.equal(state.auth.definition?.cookieCache, false)
152
+ } finally {
153
+ await rm(rootDir, { recursive: true, force: true })
154
+ }
155
+ })
156
+
157
+ test('cookieCache: { "enabled": false } (string-literal key) is honoured', async () => {
158
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-strkey-'))
159
+ const file = join(rootDir, 'auth.ts')
160
+
161
+ await writeFile(
162
+ file,
163
+ [
164
+ "import { pikkuBetterAuth } from '@pikku/better-auth'",
165
+ "import { betterAuth } from 'better-auth'",
166
+ 'export const auth = pikkuBetterAuth(() =>',
167
+ ' betterAuth({',
168
+ ' session: { cookieCache: { "enabled": false } },',
169
+ ' })',
170
+ ')',
171
+ ].join('\n')
172
+ )
173
+
174
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
175
+ try {
176
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
177
+ assert.equal(criticals.length, 0)
178
+ assert.equal(state.auth.definition?.cookieCache, false)
179
+ } finally {
180
+ await rm(rootDir, { recursive: true, force: true })
181
+ }
182
+ })
183
+
98
184
  test('extracts plugin ids from the betterAuth plugins array', async () => {
99
185
  const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-plugins-'))
100
186
  const file = join(rootDir, 'auth.ts')
@@ -188,12 +188,30 @@ export const addAuth: AddWiring = (logger, node, _checker, state) => {
188
188
  // Find the inner betterAuth({...}) call to read providers/basePath/credentials.
189
189
  let basePath = DEFAULT_BASE_PATH
190
190
  let hasCredentials = false
191
+ let cookieCache = false
191
192
  const betterAuthCall = findBetterAuthCall(factory)
192
193
  const config = betterAuthCall?.arguments[0]
193
194
 
194
195
  if (config && ts.isObjectLiteralExpression(config)) {
195
196
  basePath = readStringProp(config, 'basePath') ?? DEFAULT_BASE_PATH
196
197
 
198
+ // Detect `session.cookieCache.enabled` → drives the stateless middleware split.
199
+ const session = readObjectProp(config, 'session')
200
+ if (session) {
201
+ const cookieCacheBlock = readObjectProp(session, 'cookieCache')
202
+ if (cookieCacheBlock) {
203
+ const enabledProp = cookieCacheBlock.properties.find(
204
+ (p) =>
205
+ ts.isPropertyAssignment(p) &&
206
+ (ts.isIdentifier(p.name) || ts.isStringLiteral(p.name)) &&
207
+ p.name.text === 'enabled'
208
+ ) as ts.PropertyAssignment | undefined
209
+ cookieCache =
210
+ !enabledProp ||
211
+ enabledProp.initializer.kind !== ts.SyntaxKind.FalseKeyword
212
+ }
213
+ }
214
+
197
215
  const emailAndPassword = readObjectProp(config, 'emailAndPassword')
198
216
  if (emailAndPassword) {
199
217
  // `emailAndPassword: { enabled: true }` — treat a present block without an
@@ -244,6 +262,7 @@ export const addAuth: AddWiring = (logger, node, _checker, state) => {
244
262
  sourceFile,
245
263
  basePath,
246
264
  hasCredentials,
265
+ cookieCache,
247
266
  plugins: [...state.auth.plugins],
248
267
  services,
249
268
  }
@@ -0,0 +1,74 @@
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
+ const makeLogger = () =>
10
+ ({
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ diagnostic: () => {},
16
+ critical: () => {},
17
+ hasCriticalErrors: () => false,
18
+ }) satisfies InspectorLogger
19
+
20
+ describe('addCLIRenderers inspector', () => {
21
+ test('extracts destructured renderer services from the callback param', async () => {
22
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-cli-renderer-'))
23
+ const file = join(rootDir, 'cli.ts')
24
+
25
+ await writeFile(
26
+ file,
27
+ [
28
+ "import { wireCLI, pikkuCLIRender } from '@pikku/core/cli'",
29
+ 'export const render = pikkuCLIRender(({ logger }, data) => {',
30
+ ' logger.info(data.message)',
31
+ '})',
32
+ "wireCLI({ program: 'pikku', render, commands: {} })",
33
+ ].join('\n')
34
+ )
35
+
36
+ try {
37
+ const state = await inspect(makeLogger(), [file], { rootDir })
38
+ assert.deepEqual(state.cli.meta.renderers['render'], {
39
+ name: 'render',
40
+ exportedName: 'render',
41
+ services: { optimized: true, services: ['logger'] },
42
+ filePath: file,
43
+ })
44
+ } finally {
45
+ await rm(rootDir, { recursive: true, force: true })
46
+ }
47
+ })
48
+
49
+ test('marks non-destructured renderer services as unoptimized', async () => {
50
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-cli-renderer-all-'))
51
+ const file = join(rootDir, 'cli.ts')
52
+
53
+ await writeFile(
54
+ file,
55
+ [
56
+ "import { wireCLI, pikkuCLIRender } from '@pikku/core/cli'",
57
+ 'export const render = pikkuCLIRender((services, data) => {',
58
+ ' services.logger.info(data.message)',
59
+ '})',
60
+ "wireCLI({ program: 'pikku', render, commands: {} })",
61
+ ].join('\n')
62
+ )
63
+
64
+ try {
65
+ const state = await inspect(makeLogger(), [file], { rootDir })
66
+ assert.deepEqual(state.cli.meta.renderers['render']?.services, {
67
+ optimized: false,
68
+ services: [],
69
+ })
70
+ } finally {
71
+ await rm(rootDir, { recursive: true, force: true })
72
+ }
73
+ })
74
+ })
@@ -18,6 +18,7 @@ import { getPropertyValue } from '../utils/get-property-value.js'
18
18
  import { resolveIdentifier } from '../utils/resolve-identifier.js'
19
19
  import { resolveAddonName } from '../utils/resolve-addon-package.js'
20
20
  import { validateAuthSessionless } from '../utils/validate-auth-sessionless.js'
21
+ import { extractServicesFromFunction } from '../utils/extract-services.js'
21
22
 
22
23
  // Track if we've warned about missing Config type to avoid duplicate warnings
23
24
  const configTypeWarningShown = new Set<string>()
@@ -924,7 +925,7 @@ export const addCLIRenderers: AddWiring = (
924
925
  ) => {
925
926
  if (!ts.isCallExpression(node)) return
926
927
 
927
- const { expression, arguments: args, typeArguments } = node
928
+ const { expression, arguments: args } = node
928
929
 
929
930
  // Only handle pikkuCLIRender calls
930
931
  if (!ts.isIdentifier(expression) || expression.text !== 'pikkuCLIRender') {
@@ -944,30 +945,13 @@ export const addCLIRenderers: AddWiring = (
944
945
  const sourceFile = node.getSourceFile()
945
946
  const filePath = sourceFile.fileName
946
947
 
947
- // Extract services from type parameters (second type param is Services)
948
- const services: { optimized: boolean; services: string[] } = {
949
- optimized: true,
950
- services: [],
951
- }
952
-
953
- if (typeArguments && typeArguments.length >= 2) {
954
- // Second type parameter is the Services type
955
- const servicesTypeNode = typeArguments[1]
956
- if (servicesTypeNode) {
957
- const servicesType = typeChecker.getTypeFromTypeNode(servicesTypeNode)
958
-
959
- // Extract property names from the Services type
960
- const properties = servicesType.getProperties()
961
- for (const prop of properties) {
962
- services.services.push(prop.getName())
963
- }
964
-
965
- // If no specific services found, it might be using the full services object
966
- if (properties.length === 0) {
967
- services.optimized = false
968
- }
969
- }
970
- }
948
+ // Renderer service usage is defined by the callback's first parameter shape,
949
+ // the same way function/auth service usage is tracked elsewhere in the inspector.
950
+ const renderFunc = args[0]
951
+ const services =
952
+ ts.isArrowFunction(renderFunc) || ts.isFunctionExpression(renderFunc)
953
+ ? extractServicesFromFunction(renderFunc)
954
+ : { optimized: true, services: [] }
971
955
 
972
956
  // Store renderer metadata
973
957
  inspectorState.cli.meta.renderers[pikkuFuncId] = {