@pikku/inspector 0.12.25 → 0.12.27

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,105 @@
1
+ ## 0.12.27
2
+
3
+ ### Patch Changes
4
+
5
+ - 41ff485: fix(inspector): register functions in a dedicated pass before wiring resolution
6
+
7
+ The deterministic-codegen change sorted `program.getSourceFiles()` so generated
8
+ output is byte-identical across runs. But function registration (`addFunctions`)
9
+ ran in the same sweep as wiring resolution (`visitRoutes`), so once traversal
10
+ became alphabetical, a wiring file could be visited before the file that defines
11
+ the function it references — e.g. an addon contract (`hello.contracts.ts`)
12
+ before `hello.functions.ts` — producing a spurious `PKU559` ("No function
13
+ metadata found for channel handler").
14
+
15
+ Function registration now runs in its own pass (`visitFunctions`) over the
16
+ sorted files, completing before any transport/wiring resolution, so resolution
17
+ no longer depends on source-file order. Also sort the `register.gen.ts` schema
18
+ imports (driven by a `Set`) so that file is stable too, and opt the PII-check
19
+ tests into the now-opt-in classification scan.
20
+
21
+ - d2078c8: fix(inspector): make codegen output deterministic across runs
22
+
23
+ Two sources of non-reproducible `pikku all` output are fixed:
24
+ 1. **Random placeholder ids.** Anonymous/unnamed functions and inline (non-exported) permissions were given a `__temp_${randomUUID()}` id, so a referenced-but-not-exported `pikkuPermission` const (e.g. `permissions: { admin: [requiresPlatformAdmin] }`) produced a fresh UUID in the generated meta on every run. The placeholder is now derived deterministically from the call expression's source location (relative path + start offset), still `__temp_`-prefixed so downstream name resolution is unchanged.
25
+ 2. **Unstable file-traversal order.** The two inspector sweeps iterated `program.getSourceFiles()` in glob + import-graph order, which varies run to run, so meta keys (and anything serialized in insertion order) were emitted in a different order each time — making a plain `git diff` of generated files look like functions were appearing/vanishing when the set was identical. Source files are now sorted by file name before the sweeps.
26
+
27
+ Net effect: byte-identical generated output across repeated runs with no source changes (verified across the full `.pikku` tree of a 331-function project).
28
+
29
+ - e6fd12b: perf(inspector,cli): persist generated TS schemas to disk across runs
30
+
31
+ `generateAllSchemas` already cached its `ts-json-schema-generator` output
32
+ in-memory (keyed by the generated custom-types content), so the 2nd and 3rd
33
+ inspector passes within a single `pikku all` were near-free. But the cache
34
+ never survived the process, so every fresh `pikku all` paid the full cold cost
35
+ of building a second TS program + running ts-json-schema-generator — on a
36
+ 331-function project that's ~2.2s, the single largest line item of a run.
37
+
38
+ The cache is now also persisted to disk (`node_modules/.cache/pikku/ts-schemas.json`,
39
+ gitignored by convention), keyed by a hash of the custom-types content plus the
40
+ generator options that affect output. A warm `pikku all` whose function types
41
+ are unchanged loads the schemas from disk and skips schema generation entirely;
42
+ the cold first pass drops by ~3.4s in practice (it also primes the in-memory
43
+ cache for the re-inspect passes). Zod schemas are still regenerated every run
44
+ (already ~1ms each). Output is byte-identical to a cold run (verified across the
45
+ full generated tree). The key is derived from the same content the in-memory
46
+ cache uses, so any type change busts it. It also folds in the `@pikku/inspector`
47
+ package version, so upgrading the inspector (the channel a schema-format change
48
+ ships through) auto-invalidates every cache; `SCHEMA_CACHE_VERSION` remains a
49
+ manual lever for in-development format changes between releases.
50
+
51
+ Opt-out: omit `schemaConfig.cacheDir` (the CLI sets it by default).
52
+
53
+ - 244d892: perf(cli,inspector): make the data-classification scan opt-in (`pikku all --security`)
54
+
55
+ `pikku all` was spending the bulk of its wall-clock on the data-classification
56
+ leak check. For every function, on every inspector pass, it called
57
+ `checker.getReturnTypeOfSignature` to infer the handler's return type and scan it
58
+ for `Private`/`Pii`/`Secret` brands — the single most expensive type-checker
59
+ operation. On a 331-function project that was ~7.3s (≈half the total), repeated
60
+ across all three inspector passes, even though the scan only emits diagnostics
61
+ and never affects generated output.
62
+
63
+ The scan is a security lint, not codegen, so it's now **off by default** and gated
64
+ behind a new `--security` flag (or `security: true` in the config). A plain
65
+ `pikku all` skips return-type inference entirely; run `pikku all --security`
66
+ (optionally with `--fail-on-error`) in CI/pre-deploy to enforce it. On the
67
+ 331-function project this cut `pikku all` from ~15.3s to ~9.6s.
68
+
69
+ Also: the `all` command now reads back the run's recorded per-step durations and,
70
+ under `PIKKU_TIMING=1`, prints a slowest-first timing table — making it easy to
71
+ see where codegen time goes without adding any hot-path instrumentation.
72
+
73
+ - 940c253: Populate `plannedSteps` and `deterministic` on serialized DSL workflow graphs. For a DSL workflow with no loops (fanout), the inspector now records every named step in source order, so a UI can render the run's step skeleton up front without executing it or hand-listing steps. `deterministic` is `true` only for a flat, loopless, branch-free workflow (exact sequence known ahead of time); a branchy-but-loopless workflow lists all possible steps with `deterministic: false`; any fanout makes the count runtime-dependent so neither field is emitted (just `deterministic: false`). Only `source: 'dsl'` workflows are planned — `complex` step trees omit inline branches and flatten loops, so their plans would misreport determinism. The runtime already threads these fields from workflow meta onto each run via `getRun`.
74
+ - Updated dependencies [4be205f]
75
+ - Updated dependencies [061c717]
76
+ - Updated dependencies [2c55e13]
77
+ - Updated dependencies [c745c26]
78
+ - Updated dependencies [57900b5]
79
+ - Updated dependencies [72694f6]
80
+ - @pikku/core@0.12.39
81
+
82
+ ## 0.12.26
83
+
84
+ ### Patch Changes
85
+
86
+ - ed548d5: fix(auth): skip the generated global `betterAuthSession()` when the user registers their own
87
+
88
+ The CLI's `auth.gen.ts` unconditionally wired a global
89
+ `addHTTPMiddleware('*', [betterAuthSession()])` (default map) on the stateful
90
+ path. A project that needs a customized session bridge — `mapSession`,
91
+ `impersonation`, `apiKey` — had to register a second global
92
+ `betterAuthSession({...})`, leaving two in the chain; the generated default ran
93
+ first and short-circuited (`if (session) next()`) so the custom one never took
94
+ effect.
95
+
96
+ The inspector now records `state.auth.hasUserSessionMiddleware` when it sees a
97
+ user-authored **global** `betterAuthSession` registration (route-scoped and
98
+ `.gen.ts` registrations are ignored, so regeneration never self-suppresses).
99
+ The CLI omits its own global `betterAuthSession()` from `auth.gen.ts` when that
100
+ flag is set — exactly one session bridge in the chain, the user's. Mirrors the
101
+ existing stateless skip (`userStatelessSession`, #754).
102
+
1
103
  ## 0.12.25
2
104
 
3
105
  ### Patch Changes
@@ -137,6 +137,17 @@ export const addAuth = (logger, node, _checker, state) => {
137
137
  isInsideGlobalMiddlewareRegistration(node)) {
138
138
  state.auth.userStatelessSession = true;
139
139
  }
140
+ // Same rule for the stateful variant: a user-registered global
141
+ // betterAuthSession(...) (custom mapSession/impersonation/apiKey) means the CLI
142
+ // must NOT auto-generate its own default one — the generated one runs first and
143
+ // pre-empts the user's via the `if (session) next()` short-circuit. Stateful
144
+ // analogue of the betterAuthStatelessSession skip above.
145
+ if (ts.isIdentifier(expression) &&
146
+ expression.text === 'betterAuthSession' &&
147
+ !node.getSourceFile().fileName.endsWith('.gen.ts') &&
148
+ isInsideGlobalMiddlewareRegistration(node)) {
149
+ state.auth.hasUserSessionMiddleware = true;
150
+ }
140
151
  if (!ts.isIdentifier(expression) || expression.text !== 'pikkuBetterAuth')
141
152
  return;
142
153
  const sourceFile = node.getSourceFile().fileName;
@@ -647,7 +647,12 @@ export const addFunctions = (logger, node, checker, state, options) => {
647
647
  // secret → never returned by any exposed function (sessioned or not)
648
648
  // private → only visible to authenticated (sessioned) users; ok for pikkuFunc
649
649
  // public → safe for sessionless functions
650
- {
650
+ // Opt-in only: inferring every handler's return type (getReturnTypeOfSignature)
651
+ // is the single most expensive checker operation and dominates `pikku all`
652
+ // wall-clock. The classification leak scan is a security lint, not codegen, so
653
+ // it runs ONLY when explicitly requested (`pikku all --security`) — see the
654
+ // classificationCheck option. Default codegen skips it entirely.
655
+ if (options.classificationCheck) {
651
656
  const sig = checker.getSignatureFromDeclaration(handler);
652
657
  if (sig) {
653
658
  const rawRet = checker.getReturnTypeOfSignature(sig);
package/dist/inspector.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as ts from 'typescript';
2
2
  import { performance } from 'perf_hooks';
3
3
  import { resolve } from 'path';
4
- import { visitSetup, visitRoutes } from './visit.js';
4
+ import { visitSetup, visitFunctions, visitRoutes } from './visit.js';
5
5
  import { TypesMap } from './types-map.js';
6
6
  import { getFilesAndMethods } from './utils/get-files-and-methods.js';
7
7
  import { findCommonAncestor } from './utils/find-root-dir.js';
@@ -222,10 +222,17 @@ export const inspect = async (logger, routeFiles, options = {}) => {
222
222
  // node_modules under rootDir (e.g. a locally-installed addon) is a
223
223
  // dependency, not project source — scanning it double-counts the addon's
224
224
  // own application types (CoreConfig/Services/SingletonServices).
225
+ // Sort by file name so the sweeps populate state in a stable order. The
226
+ // program's own file order depends on glob + import-graph resolution, which
227
+ // varies run to run — leaving generated meta keys (and anything serialized
228
+ // in insertion order) non-reproducible across identical `pikku all` runs.
229
+ // Safe because function registration is a dedicated pass (visitFunctions)
230
+ // that completes before any order-sensitive wiring resolution in visitRoutes.
225
231
  const sourceFiles = program
226
232
  .getSourceFiles()
227
233
  .filter((sf) => sf.fileName.startsWith(rootDir) &&
228
- !sf.fileName.includes('/node_modules/'));
234
+ !sf.fileName.includes('/node_modules/'))
235
+ .sort((a, b) => a.fileName < b.fileName ? -1 : a.fileName > b.fileName ? 1 : 0);
229
236
  logger.debug(`Got source files in ${(performance.now() - startSourceFiles).toFixed(2)}ms`);
230
237
  const state = getInitialInspectorState(rootDir);
231
238
  // First sweep: add all functions
@@ -238,7 +245,15 @@ export const inspect = async (logger, routeFiles, options = {}) => {
238
245
  // Load addon function metadata so wirings can reference addon functions
239
246
  await loadAddonFunctionsMeta(logger, state);
240
247
  if (!options.setupOnly) {
241
- // Second sweep: add all transports
248
+ // Function sweep: register every function before transports/wirings resolve
249
+ // them, so resolution doesn't depend on source-file order.
250
+ const startFunctions = performance.now();
251
+ for (const sourceFile of sourceFiles) {
252
+ const sourceOptions = { ...options, sourceFile };
253
+ ts.forEachChild(sourceFile, (child) => visitFunctions(logger, checker, child, state, sourceOptions));
254
+ }
255
+ logger.debug(`Visit functions phase completed in ${(performance.now() - startFunctions).toFixed(0)}ms`);
256
+ // Transport sweep: add all transports/wirings
242
257
  const startRoutes = performance.now();
243
258
  for (const sourceFile of sourceFiles) {
244
259
  const sourceOptions = { ...options, sourceFile };
package/dist/types.d.ts CHANGED
@@ -220,6 +220,13 @@ export type InspectorOptions = Partial<{
220
220
  schema?: {
221
221
  additionalProperties?: boolean;
222
222
  };
223
+ /**
224
+ * Directory for the on-disk TS-schema cache. When set, generated TS schemas
225
+ * are persisted here keyed by a hash of the custom-types content, so a warm
226
+ * `pikku all` whose function types are unchanged skips ts-json-schema-generator
227
+ * entirely (the single largest cold-run cost). Omit to disable disk caching.
228
+ */
229
+ cacheDir?: string;
223
230
  };
224
231
  openAPI: {
225
232
  additionalInfo: OpenAPISpecInfo;
@@ -227,6 +234,12 @@ export type InspectorOptions = Partial<{
227
234
  tags: string[];
228
235
  manifest: VersionManifest;
229
236
  oldProgram: ts.Program | undefined;
237
+ /**
238
+ * Run the data-classification leak scan (Private/Pii/Secret brands in function
239
+ * return types). Off by default — it forces return-type inference on every
240
+ * function, which is expensive. Enabled via `pikku all --security`.
241
+ */
242
+ classificationCheck: boolean;
230
243
  }>;
231
244
  export interface InspectorLogger {
232
245
  info: (message: string) => void;
@@ -436,6 +449,12 @@ export interface InspectorState {
436
449
  * own default-map stateless middleware, which would otherwise pre-empt the
437
450
  * user's custom mapSession (pikkujs/pikku#754). */
438
451
  userStatelessSession?: boolean;
452
+ /** True when a user (non-generated) file already registers a global
453
+ * `betterAuthSession(...)`. The CLI then skips auto-generating its own
454
+ * default stateful middleware, which would otherwise run first and pre-empt
455
+ * the user's config (mapSession/impersonation/apiKey). Stateful analogue of
456
+ * `userStatelessSession`. */
457
+ hasUserSessionMiddleware?: boolean;
439
458
  };
440
459
  secrets: {
441
460
  definitions: SecretDefinitions;
@@ -1,6 +1,23 @@
1
1
  import * as ts from 'typescript';
2
- import { randomUUID } from 'crypto';
2
+ import { createHash } from 'crypto';
3
+ import { relative } from 'path';
3
4
  import { formatVersionedId } from '@pikku/core';
5
+ /**
6
+ * Deterministic placeholder id for an anonymous/unnamed pikku function or
7
+ * permission. Derived from the call expression's source location (relative path
8
+ * + start position) so `pikku all` produces byte-identical output across runs —
9
+ * a `randomUUID()` here made generated meta non-reproducible. Still `__temp_`
10
+ * prefixed so downstream resolution (which keys off that prefix) is unchanged.
11
+ */
12
+ function tempFuncId(callExpr, rootDir) {
13
+ const sourceFile = callExpr.getSourceFile();
14
+ const relPath = relative(rootDir, sourceFile.fileName);
15
+ const hash = createHash('sha1')
16
+ .update(`${relPath}:${callExpr.getStart()}`)
17
+ .digest('hex')
18
+ .slice(0, 16);
19
+ return `__temp_${hash}`;
20
+ }
4
21
  export function makeContextBasedId(wiringType, ...segments) {
5
22
  return [wiringType, ...segments].join(':');
6
23
  }
@@ -88,7 +105,7 @@ export function extractFunctionName(callExpr, checker, rootDir) {
88
105
  result.pikkuFuncId = decl.name.text;
89
106
  }
90
107
  if (!result.pikkuFuncId) {
91
- result.pikkuFuncId = `__temp_${randomUUID()}`;
108
+ result.pikkuFuncId = tempFuncId(callExpr, rootDir);
92
109
  }
93
110
  populateNameByPriority(result);
94
111
  return result;
@@ -335,7 +352,7 @@ export function extractFunctionName(callExpr, checker, rootDir) {
335
352
  result.pikkuFuncId = result.exportedName;
336
353
  }
337
354
  else {
338
- result.pikkuFuncId = `__temp_${randomUUID()}`;
355
+ result.pikkuFuncId = tempFuncId(callExpr, rootDir);
339
356
  }
340
357
  if (result.version !== null) {
341
358
  // Strip trailing VN suffix if it matches the version (e.g. createCardV1 + version:1 → createCard@v1)
@@ -6,4 +6,5 @@ export declare function generateAllSchemas(logger: InspectorLogger, config: {
6
6
  schema?: {
7
7
  additionalProperties?: boolean;
8
8
  };
9
+ cacheDir?: string;
9
10
  }, state: InspectorState): Promise<Record<string, JSONValue>>;
@@ -1,4 +1,6 @@
1
1
  import * as ts from 'typescript';
2
+ import { createHash } from 'crypto';
3
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
2
4
  import { dirname, join, resolve } from 'path';
3
5
  import { createGenerator, RootlessError } from 'ts-json-schema-generator';
4
6
  import { register } from 'tsx/esm/api';
@@ -55,6 +57,61 @@ let cachedParsedConfig;
55
57
  let cachedTsconfigPath;
56
58
  let cachedCustomTypesContent;
57
59
  let cachedTSSchemas;
60
+ const SCHEMA_CACHE_VERSION = 1;
61
+ // This package's own version — folded into the cache key so that upgrading
62
+ // @pikku/inspector (the channel through which a schema-format change ships)
63
+ // auto-invalidates every on-disk cache, without relying on someone remembering
64
+ // to bump SCHEMA_CACHE_VERSION. Read once; falls back to the constant if the
65
+ // package.json can't be located (e.g. an unexpected bundling layout).
66
+ const inspectorVersion = (() => {
67
+ try {
68
+ const pkgUrl = new URL('../../package.json', import.meta.url);
69
+ const pkg = JSON.parse(readFileSync(pkgUrl, 'utf-8'));
70
+ return typeof pkg.version === 'string' ? pkg.version : `v${SCHEMA_CACHE_VERSION}`;
71
+ }
72
+ catch {
73
+ return `v${SCHEMA_CACHE_VERSION}`;
74
+ }
75
+ })();
76
+ // Key the TS-schema cache on everything that affects its output: the generated
77
+ // custom-types source, the generator options that change schema shape, and the
78
+ // inspector version (schema-format changes ship with a version bump).
79
+ function tsSchemaCacheKey(customTypesContent, config) {
80
+ return createHash('sha1')
81
+ .update(`v${SCHEMA_CACHE_VERSION}\0`)
82
+ .update(`pkg:${inspectorVersion}\0`)
83
+ .update(`ap:${config.schema?.additionalProperties ? 1 : 0}\0`)
84
+ .update(`ft:${(config.schemasFromTypes ?? []).join(',')}\0`)
85
+ .update(customTypesContent)
86
+ .digest('hex');
87
+ }
88
+ function schemaCacheFile(cacheDir) {
89
+ return join(cacheDir, 'ts-schemas.json');
90
+ }
91
+ function readDiskTSSchemas(logger, cacheDir, key) {
92
+ const file = schemaCacheFile(cacheDir);
93
+ if (!existsSync(file))
94
+ return null;
95
+ try {
96
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
97
+ if (parsed?.key === key && parsed.schemas)
98
+ return parsed.schemas;
99
+ }
100
+ catch (e) {
101
+ logger.debug(`Ignoring unreadable TS-schema cache: ${e.message}`);
102
+ }
103
+ return null;
104
+ }
105
+ function writeDiskTSSchemas(logger, cacheDir, key, schemas) {
106
+ const file = schemaCacheFile(cacheDir);
107
+ try {
108
+ mkdirSync(dirname(file), { recursive: true });
109
+ writeFileSync(file, JSON.stringify({ key, schemas }));
110
+ }
111
+ catch (e) {
112
+ logger.debug(`Failed to persist TS-schema cache: ${e.message}`);
113
+ }
114
+ }
58
115
  function createProgramWithVirtualFile(tsconfig, virtualFilePath, virtualFileContent) {
59
116
  const configPath = resolve(tsconfig);
60
117
  // Cache the parsed tsconfig — it doesn't change between runs
@@ -330,12 +387,31 @@ export async function generateAllSchemas(logger, config, state) {
330
387
  const zodSchemas = await generateZodSchemas(logger, state.schemaLookup, state.functions.typesMap);
331
388
  const requiredTypes = new Set();
332
389
  const customTypesContent = generateCustomTypes(state.functions.typesMap, requiredTypes);
390
+ // Fast path: same process, types unchanged — reuse the in-memory result.
333
391
  if (cachedTSSchemas && cachedCustomTypesContent === customTypesContent) {
334
392
  logger.debug('Reusing cached TS schemas (types unchanged)');
335
393
  return { ...cachedTSSchemas, ...zodSchemas };
336
394
  }
395
+ // Disk path: a prior `pikku all` left a cache whose key matches the current
396
+ // custom types — load it and skip ts-json-schema-generator (the dominant
397
+ // cold-run cost). Zod schemas are always regenerated (cheap, ~1ms/schema).
398
+ const cacheKey = config.cacheDir
399
+ ? tsSchemaCacheKey(customTypesContent, config)
400
+ : null;
401
+ if (config.cacheDir && cacheKey) {
402
+ const disk = readDiskTSSchemas(logger, config.cacheDir, cacheKey);
403
+ if (disk) {
404
+ logger.debug('Reusing on-disk TS schemas (types unchanged across runs)');
405
+ cachedCustomTypesContent = customTypesContent;
406
+ cachedTSSchemas = disk;
407
+ return { ...disk, ...zodSchemas };
408
+ }
409
+ }
337
410
  const tsSchemas = generateTSSchemas(logger, config.tsconfig, customTypesContent, state.functions.typesMap, state.functions.meta, state.http.meta, config.schemasFromTypes, config.schema?.additionalProperties, zodSchemas);
338
411
  cachedCustomTypesContent = customTypesContent;
339
412
  cachedTSSchemas = tsSchemas;
413
+ if (config.cacheDir && cacheKey) {
414
+ writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas);
415
+ }
340
416
  return { ...tsSchemas, ...zodSchemas };
341
417
  }
@@ -0,0 +1,20 @@
1
+ import type { WorkflowStepMeta, WorkflowPlannedStep } from '@pikku/core/workflow';
2
+ /**
3
+ * Derive the static UI plan for a DSL workflow from its extracted step tree.
4
+ *
5
+ * `plannedSteps` is the ordered list of every named step the workflow can run,
6
+ * so a frontend can render the step skeleton up front without executing the
7
+ * workflow or hand-listing steps. It is populated whenever the workflow has NO
8
+ * loops (fanout) — loops make the step COUNT runtime-dependent, so a workflow
9
+ * containing any fanout gets neither field.
10
+ *
11
+ * `deterministic` is true only when the exact executed sequence is known up
12
+ * front: a flat list of named steps with no branches/switches (which pick a
13
+ * path at runtime) and no loops. A branchy-but-loopless workflow is therefore
14
+ * `deterministic: false` but still gets `plannedSteps` (the full set of
15
+ * possible steps, in source order).
16
+ */
17
+ export declare function deriveWorkflowPlan(steps: WorkflowStepMeta[]): {
18
+ deterministic: boolean;
19
+ plannedSteps?: WorkflowPlannedStep[];
20
+ };
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Derive the static UI plan for a DSL workflow from its extracted step tree.
3
+ *
4
+ * `plannedSteps` is the ordered list of every named step the workflow can run,
5
+ * so a frontend can render the step skeleton up front without executing the
6
+ * workflow or hand-listing steps. It is populated whenever the workflow has NO
7
+ * loops (fanout) — loops make the step COUNT runtime-dependent, so a workflow
8
+ * containing any fanout gets neither field.
9
+ *
10
+ * `deterministic` is true only when the exact executed sequence is known up
11
+ * front: a flat list of named steps with no branches/switches (which pick a
12
+ * path at runtime) and no loops. A branchy-but-loopless workflow is therefore
13
+ * `deterministic: false` but still gets `plannedSteps` (the full set of
14
+ * possible steps, in source order).
15
+ */
16
+ export function deriveWorkflowPlan(steps) {
17
+ if (containsLoop(steps)) {
18
+ return { deterministic: false };
19
+ }
20
+ return {
21
+ deterministic: !containsConditional(steps),
22
+ plannedSteps: collectNamedSteps(steps),
23
+ };
24
+ }
25
+ /** Any fanout (loop) anywhere in the tree — including inside branches/switches. */
26
+ function containsLoop(steps) {
27
+ return steps.some((step) => {
28
+ switch (step.type) {
29
+ case 'fanout':
30
+ return true;
31
+ case 'branch':
32
+ return (step.branches.some((b) => containsLoop(b.steps)) ||
33
+ (step.elseSteps ? containsLoop(step.elseSteps) : false));
34
+ case 'switch':
35
+ return (step.cases.some((c) => containsLoop(c.steps)) ||
36
+ (step.defaultSteps ? containsLoop(step.defaultSteps) : false));
37
+ default:
38
+ return false;
39
+ }
40
+ });
41
+ }
42
+ /** Any branch/switch — the run takes a runtime-decided path. */
43
+ function containsConditional(steps) {
44
+ return steps.some((step) => step.type === 'branch' || step.type === 'switch');
45
+ }
46
+ /** Flatten named steps (rpc/inline/sleep/parallel children) in source order. */
47
+ function collectNamedSteps(steps) {
48
+ const planned = [];
49
+ for (const step of steps) {
50
+ switch (step.type) {
51
+ case 'rpc':
52
+ case 'inline':
53
+ case 'sleep':
54
+ planned.push({ stepName: step.stepName });
55
+ break;
56
+ case 'parallel':
57
+ for (const child of step.children) {
58
+ planned.push({ stepName: child.stepName });
59
+ }
60
+ break;
61
+ case 'branch':
62
+ for (const b of step.branches)
63
+ planned.push(...collectNamedSteps(b.steps));
64
+ if (step.elseSteps)
65
+ planned.push(...collectNamedSteps(step.elseSteps));
66
+ break;
67
+ case 'switch':
68
+ for (const c of step.cases)
69
+ planned.push(...collectNamedSteps(c.steps));
70
+ if (step.defaultSteps) {
71
+ planned.push(...collectNamedSteps(step.defaultSteps));
72
+ }
73
+ break;
74
+ // set / return / cancel / filter / arrayPredicate produce no named step
75
+ }
76
+ }
77
+ return planned;
78
+ }
@@ -1,6 +1,7 @@
1
1
  import { isVersionedId, formatVersionedId, parseVersionedId } from '@pikku/core';
2
2
  import { canonicalJSON, hashString } from '../../hash.js';
3
3
  import { convertDslToGraph } from './convert-dsl-to-graph.js';
4
+ import { deriveWorkflowPlan } from '../derive-workflow-plan.js';
4
5
  export function finalizeWorkflows(state) {
5
6
  const { workflows, functions } = state;
6
7
  const functionsMeta = functions.meta;
@@ -9,6 +10,20 @@ export function finalizeWorkflows(state) {
9
10
  stampVersionsOnGraph(graph, functionsMeta);
10
11
  computeStepHashes(graph, functionsMeta);
11
12
  graph.graphHash = computeGraphHash(graph);
13
+ // Predictable (loopless) DSL workflows carry their full step list so a UI
14
+ // can render the skeleton up front without executing the run. Only DSL is
15
+ // gated: a complex workflow's step tree is incomplete (inline JS branches
16
+ // aren't captured) and flattens loops into plain steps, so its plan would
17
+ // lie about determinism.
18
+ if (graph.source === 'dsl') {
19
+ const { deterministic, plannedSteps } = deriveWorkflowPlan(meta.steps);
20
+ graph.deterministic = deterministic;
21
+ // Omit an empty list — a deterministic workflow with no plannedSteps is
22
+ // simply one with no named steps (e.g. a bare return).
23
+ if (plannedSteps?.length) {
24
+ graph.plannedSteps = plannedSteps;
25
+ }
26
+ }
12
27
  workflows.graphMeta[name] = graph;
13
28
  }
14
29
  for (const graph of Object.values(workflows.graphMeta)) {
@@ -69,7 +69,7 @@ export interface NodeOptions {
69
69
  * Flow node types for control flow (no RPC call)
70
70
  */
71
71
  export type FlowType = 'sleep' | 'branch' | 'parallel' | 'fanout' | 'inline' | 'switch' | 'filter' | 'arrayPredicate' | 'return' | 'cancel' | 'set';
72
- import type { ContextVariable, WorkflowContext } from '@pikku/core/workflow';
72
+ import type { ContextVariable, WorkflowContext, WorkflowPlannedStep } from '@pikku/core/workflow';
73
73
  export type { ContextVariable, WorkflowContext };
74
74
  /**
75
75
  * Base node properties shared by all node types
@@ -151,6 +151,19 @@ export interface SerializedWorkflowGraph {
151
151
  entryNodeIds: string[];
152
152
  /** Hash of graph topology (nodes, edges, input mappings) */
153
153
  graphHash?: string;
154
+ /**
155
+ * True when the exact executed step sequence is known up front: a loopless
156
+ * DSL workflow with no branches/switches. Lets a UI render the run as a fixed
157
+ * pipeline. Loops (fanout) → omitted; branchy-but-loopless → false.
158
+ */
159
+ deterministic?: boolean;
160
+ /**
161
+ * Every named step the workflow can run, in source order — so a frontend can
162
+ * render the step skeleton before the run starts. Populated for any loopless
163
+ * DSL workflow (a branchy one lists all possible steps); omitted when the step
164
+ * count is runtime-dependent (any fanout).
165
+ */
166
+ plannedSteps?: WorkflowPlannedStep[];
154
167
  /** Wire entry points (HTTP, channel, queue, etc.) that trigger this workflow */
155
168
  wires?: WorkflowWires;
156
169
  }
package/dist/visit.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import * as ts from 'typescript';
2
2
  import type { InspectorState, InspectorLogger, InspectorOptions } from './types.js';
3
3
  export declare const visitSetup: (logger: InspectorLogger, checker: ts.TypeChecker, node: ts.Node, state: InspectorState, options: InspectorOptions) => void;
4
+ export declare const visitFunctions: (logger: InspectorLogger, checker: ts.TypeChecker, node: ts.Node, state: InspectorState, options: InspectorOptions) => void;
4
5
  export declare const visitRoutes: (logger: InspectorLogger, checker: ts.TypeChecker, node: ts.Node, state: InspectorState, options: InspectorOptions) => void;
package/dist/visit.js CHANGED
@@ -41,12 +41,25 @@ export const visitSetup = (logger, checker, node, state, options) => {
41
41
  addWorkflow(logger, node, checker, state, options);
42
42
  ts.forEachChild(node, (child) => visitSetup(logger, checker, child, state, options));
43
43
  };
44
+ // Register every pikku function before transports/wirings are resolved, so that
45
+ // resolution (e.g. a channel handler referencing a function defined in another
46
+ // file) is independent of source-file traversal order. Runs between visitSetup
47
+ // and visitRoutes.
48
+ export const visitFunctions = (logger, checker, node, state, options) => {
49
+ const nextOptions = ts.isSourceFile(node)
50
+ ? { ...options, sourceFile: node }
51
+ : options;
52
+ addFunctions(logger, node, checker, state, nextOptions);
53
+ ts.forEachChild(node, (child) => visitFunctions(logger, checker, child, state, nextOptions));
54
+ };
44
55
  export const visitRoutes = (logger, checker, node, state, options) => {
45
56
  const nextOptions = ts.isSourceFile(node)
46
57
  ? { ...options, sourceFile: node }
47
58
  : options;
48
59
  checkAddonBans(logger, node, checker, state, nextOptions);
49
- addFunctions(logger, node, checker, state, nextOptions);
60
+ // NOTE: addFunctions runs in its own earlier pass (visitFunctions) so that
61
+ // every function is registered before any wiring (channels, CLI, etc.)
62
+ // resolves it — wiring resolution must not depend on source-file order.
50
63
  addAuth(logger, node, checker, state, nextOptions);
51
64
  addSecret(logger, node, checker, state, nextOptions);
52
65
  addCredential(logger, node, checker, state, nextOptions);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.25",
3
+ "version": "0.12.27",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "BUSL-1.1",
6
6
  "type": "module",
@@ -35,7 +35,7 @@
35
35
  },
36
36
  "dependencies": {
37
37
  "@openapi-contrib/json-schema-to-openapi-schema": "^4.3.1",
38
- "@pikku/core": "^0.12.37",
38
+ "@pikku/core": "^0.12.39",
39
39
  "openapi-types": "^12.1.3",
40
40
  "path-to-regexp": "^8.3.0",
41
41
  "ts-json-schema-generator": "^2.5.0",
@@ -616,4 +616,54 @@ describe('addAuth inspector', () => {
616
616
  await rm(rootDir, { recursive: true, force: true })
617
617
  }
618
618
  })
619
+
620
+ test('user-registered global betterAuthSession sets hasUserSessionMiddleware', async () => {
621
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-session-'))
622
+ const file = join(rootDir, 'middleware.ts')
623
+ await writeFile(
624
+ file,
625
+ [
626
+ "import { addHTTPMiddleware } from '#pikku'",
627
+ "import { betterAuthSession } from '@pikku/better-auth'",
628
+ "addHTTPMiddleware('*', [",
629
+ ' betterAuthSession({',
630
+ ' impersonation: { loadUser: (id: string) => ({ id }) },',
631
+ ' }),',
632
+ '])',
633
+ ].join('\n')
634
+ )
635
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
636
+ try {
637
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
638
+ assert.equal(state.auth.hasUserSessionMiddleware, true)
639
+ } finally {
640
+ await rm(rootDir, { recursive: true, force: true })
641
+ }
642
+ })
643
+
644
+ test('betterAuthSession in a .gen.ts file does NOT set hasUserSessionMiddleware', async () => {
645
+ // Critical: the CLI's own auth.gen.ts contains addHTTPMiddleware('*',
646
+ // [betterAuthSession()]). It must not count as a user registration, or it
647
+ // would suppress itself and leave the chain with no session middleware.
648
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-auth-session-gen-'))
649
+ const file = join(rootDir, 'auth.gen.ts')
650
+ await writeFile(
651
+ file,
652
+ [
653
+ "import { addHTTPMiddleware } from '#pikku'",
654
+ "import { betterAuthSession } from '@pikku/better-auth'",
655
+ "addHTTPMiddleware('*', [betterAuthSession()])",
656
+ ].join('\n')
657
+ )
658
+ const criticals: Array<{ code: ErrorCode; message: string }> = []
659
+ try {
660
+ const state = await inspect(makeLogger(criticals), [file], { rootDir })
661
+ assert.ok(
662
+ !state.auth.hasUserSessionMiddleware,
663
+ 'generated file must not self-trigger the skip'
664
+ )
665
+ } finally {
666
+ await rm(rootDir, { recursive: true, force: true })
667
+ }
668
+ })
619
669
  })