@pikku/inspector 0.12.46 → 0.12.48

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 (51) hide show
  1. package/CHANGELOG.md +197 -0
  2. package/README.md +28 -2
  3. package/dist/add/add-feature.d.ts +19 -0
  4. package/dist/add/add-feature.js +200 -0
  5. package/dist/add/add-functions.js +24 -9
  6. package/dist/add/add-workflow.js +22 -29
  7. package/dist/error-codes.d.ts +3 -0
  8. package/dist/error-codes.js +3 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/inspector.js +39 -2
  11. package/dist/types.d.ts +43 -0
  12. package/dist/utils/extract-function-name.js +27 -0
  13. package/dist/utils/post-process.d.ts +26 -0
  14. package/dist/utils/post-process.js +98 -0
  15. package/dist/utils/serialize-inspector-state.d.ts +2 -1
  16. package/dist/utils/serialize-inspector-state.js +2 -0
  17. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +203 -19
  18. package/dist/utils/workflow/dsl/patterns.d.ts +21 -0
  19. package/dist/utils/workflow/dsl/patterns.js +30 -0
  20. package/dist/utils/workflow/dsl/validation.d.ts +5 -0
  21. package/dist/utils/workflow/dsl/validation.js +9 -0
  22. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +36 -0
  23. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -1
  24. package/dist/visit.js +2 -0
  25. package/package.json +4 -3
  26. package/src/add/add-feature.test.ts +306 -0
  27. package/src/add/add-feature.ts +253 -0
  28. package/src/add/add-functions.ts +29 -9
  29. package/src/add/add-scenario-step.test.ts +311 -0
  30. package/src/add/add-workflow-silent-drops.test.ts +98 -0
  31. package/src/add/add-workflow.ts +25 -37
  32. package/src/add/scenario-not-extractable.test.ts +159 -0
  33. package/src/add/scenario-services.test.ts +103 -0
  34. package/src/add/scenario-skip.test.ts +83 -0
  35. package/src/error-codes.ts +3 -0
  36. package/src/index.ts +1 -0
  37. package/src/inspector.ts +50 -1
  38. package/src/tsconfig-paths.test.ts +90 -0
  39. package/src/types.ts +45 -0
  40. package/src/utils/extract-function-name.ts +36 -0
  41. package/src/utils/post-process.test.ts +36 -2
  42. package/src/utils/post-process.ts +111 -0
  43. package/src/utils/serialize-inspector-state.ts +8 -1
  44. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
  45. package/src/utils/workflow/dsl/patterns.ts +38 -0
  46. package/src/utils/workflow/dsl/validation.ts +12 -0
  47. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
  48. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
  49. package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
  50. package/src/visit.ts +2 -0
  51. package/tsconfig.tsbuildinfo +1 -1
package/dist/index.d.ts CHANGED
@@ -12,6 +12,7 @@ export { resolveDeployTarget } from './utils/resolve-deploy-target.js';
12
12
  export { generateCustomTypes, sanitizeTypeName, } from './utils/custom-types-generator.js';
13
13
  export { createEmptyManifest, serializeManifest, } from './utils/contract-hashes.js';
14
14
  export type { ContractEntry, VersionHashEntry, VersionValidateError, VersionManifest, VersionManifestEntry, } from './utils/contract-hashes.js';
15
+ export type { InspectorFeature, InspectorFeatureEntry } from './types.js';
15
16
  export { serializeMCPJson } from './utils/serialize-mcp-json.js';
16
17
  export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js';
17
18
  export { deserializeDslWorkflow, deserializeGraphWorkflow, deserializeAllDslWorkflows, } from './utils/workflow/dsl/index.js';
package/dist/inspector.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import * as ts from 'typescript';
2
2
  import { performance } from 'perf_hooks';
3
- import { resolve } from 'path';
3
+ import { dirname, resolve } from 'path';
4
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';
8
8
  import { createNestedProjectFilter } from './utils/nested-project-filter.js';
9
- import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, computeDiagnostics, validateSchemaWiringSeparation, validateWorkflowGraphAddons, } from './utils/post-process.js';
9
+ import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, computeDiagnostics, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateWorkflowGraphAddons, } from './utils/post-process.js';
10
10
  import { generateOpenAPISpec } from './utils/serialize-openapi-json.js';
11
11
  import { pikkuState } from '@pikku/core/internal';
12
12
  import { resolveLatestVersions } from './utils/resolve-versions.js';
@@ -86,6 +86,7 @@ export function getInitialInspectorState(rootDir) {
86
86
  files: new Map(),
87
87
  graphMeta: {},
88
88
  graphFiles: new Map(),
89
+ featureFiles: new Map(),
89
90
  invokedWorkflows: new Set(),
90
91
  },
91
92
  rpc: {
@@ -204,9 +205,43 @@ export function getInitialInspectorState(rootDir) {
204
205
  program: null,
205
206
  };
206
207
  }
208
+ /**
209
+ * The path mappings the inspector's program has to share with the project.
210
+ *
211
+ * The inspector builds its own program rather than reusing the project's, which
212
+ * is what keeps a cold run affordable — but a project that reaches its generated
213
+ * code through a tsconfig `paths` entry resolves that import through those
214
+ * mappings and nothing else. Without them the factory an inspected function is
215
+ * passed to resolves to `any`, so a function whose input type comes from the
216
+ * factory's contextual type (rather than an explicit generic or a named schema)
217
+ * silently records no input at all.
218
+ *
219
+ * Only resolution keys are copied. Type-inference options such as `strict` stay
220
+ * the inspector's own, because changing those would change the recorded types.
221
+ */
222
+ const readProjectPathMappings = (logger, tsconfig) => {
223
+ if (!tsconfig) {
224
+ return {};
225
+ }
226
+ const configPath = resolve(tsconfig);
227
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile);
228
+ if (configFile.error || !configFile.config) {
229
+ logger.debug(`Could not read ${configPath} for path mappings; imports that rely on them will not resolve`);
230
+ return {};
231
+ }
232
+ const { options: projectOptions } = ts.parseJsonConfigFileContent(configFile.config, ts.sys, dirname(configPath));
233
+ const { baseUrl, paths, rootDirs, pathsBasePath } = projectOptions;
234
+ return {
235
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
236
+ ...(paths !== undefined ? { paths } : {}),
237
+ ...(rootDirs !== undefined ? { rootDirs } : {}),
238
+ ...(pathsBasePath !== undefined ? { pathsBasePath } : {}),
239
+ };
240
+ };
207
241
  export const inspect = async (logger, routeFiles, options = {}) => {
208
242
  const normalizedRouteFiles = routeFiles.map((file) => resolve(file));
209
243
  const compilerOptions = {
244
+ ...readProjectPathMappings(logger, options.tsconfig),
210
245
  target: ts.ScriptTarget.ESNext,
211
246
  module: ts.ModuleKind.Node16,
212
247
  skipLibCheck: true,
@@ -310,6 +345,8 @@ export const inspect = async (logger, routeFiles, options = {}) => {
310
345
  computeDiagnostics(state);
311
346
  validateSchemaWiringSeparation(logger, state);
312
347
  validateWorkflowGraphAddons(logger, state);
348
+ validateScenarioServices(logger, state);
349
+ validateScenarioSteps(logger, state);
313
350
  if (options.openAPI) {
314
351
  state.openAPISpec = await generateOpenAPISpec(logger, state.functions.meta, state.http.meta, state.schemas, options.openAPI.additionalInfo, pikkuState(null, 'misc', 'errors'));
315
352
  }
package/dist/types.d.ts CHANGED
@@ -205,6 +205,12 @@ export type InspectorOptions = Partial<{
205
205
  rootDir: string;
206
206
  isAddon: boolean;
207
207
  sourceFile: ts.SourceFile;
208
+ /**
209
+ * The project's tsconfig, read for its path mappings only. The inspector's
210
+ * program needs them to resolve imports the project reaches through `paths`;
211
+ * unlike `schemaConfig.tsconfig` this is also needed on a setup-only run.
212
+ */
213
+ tsconfig: string;
208
214
  types: Partial<{
209
215
  configFileType: string;
210
216
  userSessionType: string;
@@ -346,6 +352,34 @@ export interface AuthDefinition {
346
352
  */
347
353
  services: FunctionServicesMeta;
348
354
  }
355
+ /** One statically resolved entry of a feature's `scenarios` array. */
356
+ export interface InspectorFeatureEntry {
357
+ /** The scenario's declared export name, with any import alias followed back. */
358
+ scenario: string;
359
+ /** A `{ scenario, data }` entry's literal data — gherkin's `Examples:`. */
360
+ data?: unknown;
361
+ }
362
+ /** A `pikkuFeature` export, as far as it can be read from the source. */
363
+ export interface InspectorFeature {
364
+ path: string;
365
+ exportedName: string;
366
+ name?: string;
367
+ description?: string;
368
+ tags?: string[];
369
+ entries: InspectorFeatureEntry[];
370
+ /**
371
+ * Entries that are not literal — a spread, a `.map()`, a call. Counted rather
372
+ * than dropped so the console can say the listing is partial instead of
373
+ * quietly showing a short feature.
374
+ */
375
+ unresolvedEntries: number;
376
+ /**
377
+ * Feature hooks run once around the whole group, never per scenario, and are
378
+ * runtime-only — so only their presence is recorded.
379
+ */
380
+ hasBefore: boolean;
381
+ hasAfter: boolean;
382
+ }
349
383
  export interface InspectorState {
350
384
  rootDir: string;
351
385
  singletonServicesTypeImportMap: PathToNameAndType;
@@ -395,6 +429,15 @@ export interface InspectorState {
395
429
  path: string;
396
430
  exportedName: string;
397
431
  }>;
432
+ /**
433
+ * `pikkuFeature` exports, keyed by export identifier.
434
+ *
435
+ * Everything the config states literally is extracted here, because a
436
+ * feature is the document structure the console renders. Entries built by
437
+ * an ordinary loop cannot be enumerated statically — those are counted in
438
+ * `unresolvedEntries` and resolved at runtime by object identity instead.
439
+ */
440
+ featureFiles: Map<string, InspectorFeature>;
398
441
  invokedWorkflows: Set<string>;
399
442
  };
400
443
  rpc: {
@@ -28,6 +28,32 @@ export function funcIdToTypeName(id) {
28
28
  .map((s) => s.charAt(0).toUpperCase() + s.slice(1))
29
29
  .join('');
30
30
  }
31
+ /**
32
+ * `pikkuScenarioStep({ name })` is the one wrapper whose `name` is its identity:
33
+ * scenarios reference a step by that string, so it must become the pikkuFuncId
34
+ * (and hence the generated step map's key), not just a cosmetic label the way
35
+ * `pikkuMiddleware`/`pikkuAgent` use it.
36
+ */
37
+ function extractScenarioStepName(callExpr, result) {
38
+ if (!ts.isCallExpression(callExpr) ||
39
+ !ts.isIdentifier(callExpr.expression) ||
40
+ callExpr.expression.text !== 'pikkuScenarioStep') {
41
+ return;
42
+ }
43
+ const firstArg = callExpr.arguments[0];
44
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
45
+ return;
46
+ }
47
+ for (const prop of firstArg.properties) {
48
+ if (ts.isPropertyAssignment(prop) &&
49
+ ts.isIdentifier(prop.name) &&
50
+ prop.name.text === 'name' &&
51
+ ts.isStringLiteral(prop.initializer)) {
52
+ result.explicitName = prop.initializer.text;
53
+ return;
54
+ }
55
+ }
56
+ }
31
57
  export function extractFunctionName(callExpr, checker, rootDir) {
32
58
  const parent = callExpr.parent;
33
59
  // Initialize the result
@@ -40,6 +66,7 @@ export function extractFunctionName(callExpr, checker, rootDir) {
40
66
  isHelper: false,
41
67
  version: null,
42
68
  };
69
+ extractScenarioStepName(callExpr, result);
43
70
  const workflowHelpers = new Set([
44
71
  'workflow',
45
72
  'workflowStart',
@@ -50,6 +50,32 @@ export declare function computeMiddlewareGroupsMeta(state: InspectorState): void
50
50
  export declare function computePermissionsGroupsMeta(state: InspectorState): void;
51
51
  export declare function computeRequiredSchemas(state: InspectorState, options: InspectorOptions): void;
52
52
  export declare function validateAgentModels(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
53
+ /**
54
+ * Scenarios are pure stories of remote RPCs (same rule as client-side CLI
55
+ * renderers): the func may only destructure logger/config — everything else
56
+ * must go through actor steps so the flow runs against the TARGET
57
+ * environment, never local services.
58
+ *
59
+ * This runs in post-processing rather than in `addWorkflow` because
60
+ * `addWorkflow` runs in the `visitSetup` pass, which completes before
61
+ * `addFunctions` (`visitFunctions`) has populated `state.functions.meta` — so
62
+ * the check read `undefined` there and never fired.
63
+ */
64
+ export declare function validateScenarioServices(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
65
+ /**
66
+ * Walk every scenario step, wherever it is nested, and validate the two things
67
+ * that can only be known once both the workflow meta and the function meta
68
+ * exist:
69
+ *
70
+ * - the step target must be a static string literal (PKU678) — otherwise it
71
+ * can't be bundled, typed or drawn;
72
+ * - a `browser: true` step must be given an actor (PKU677), since the browser
73
+ * context signs in as that persona.
74
+ *
75
+ * It also marks the step's function as used, so `scenario run` boots the
76
+ * singletons the step actually needs.
77
+ */
78
+ export declare function validateScenarioSteps(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
53
79
  /**
54
80
  * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
55
81
  * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
@@ -4,6 +4,7 @@ import { extractTypeKeys } from './type-utils.js';
4
4
  import { ErrorCode } from '../error-codes.js';
5
5
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js';
6
6
  import { flattenScopeDefinitions } from '@pikku/core/scope';
7
+ import { DYNAMIC_SCENARIO_STEP_TARGET } from './workflow/dsl/patterns.js';
7
8
  /**
8
9
  * Stamp the inspected authorize/callbacks service set onto the generated auth
9
10
  * handler's function meta.
@@ -120,6 +121,19 @@ export function aggregateRequiredServices(state) {
120
121
  addServices(funcMeta.services);
121
122
  }
122
123
  });
124
+ // 1b. Services the auth factory touches, read from the definition rather than
125
+ // from the generated handler.
126
+ //
127
+ // `stampAuthHandlerServices` copies them onto that handler's meta, but on the
128
+ // first run in a clean checkout the handler's file has not been written yet,
129
+ // so there is nothing to stamp and nothing to aggregate — and the services map
130
+ // comes out claiming `kysely` (or whatever else `authorize` reads) is unused.
131
+ // The second run, with the file on disk, says the opposite. The definition is
132
+ // inspected from hand-written source either way, so taking the answer from
133
+ // there makes a clean build agree with an incremental one.
134
+ if (state.auth?.definition) {
135
+ addServices(state.auth.definition.services);
136
+ }
123
137
  // 2. Services from used middleware (individual + groups)
124
138
  usedMiddleware.forEach((middlewareName) => {
125
139
  const middlewareMeta = state.middleware.definitions[middlewareName];
@@ -539,6 +553,90 @@ export function validateAgentModels(logger, state) {
539
553
  }
540
554
  }
541
555
  }
556
+ /**
557
+ * Scenarios are pure stories of remote RPCs (same rule as client-side CLI
558
+ * renderers): the func may only destructure logger/config — everything else
559
+ * must go through actor steps so the flow runs against the TARGET
560
+ * environment, never local services.
561
+ *
562
+ * This runs in post-processing rather than in `addWorkflow` because
563
+ * `addWorkflow` runs in the `visitSetup` pass, which completes before
564
+ * `addFunctions` (`visitFunctions`) has populated `state.functions.meta` — so
565
+ * the check read `undefined` there and never fired.
566
+ */
567
+ export function validateScenarioServices(logger, state) {
568
+ for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
569
+ if (!meta.scenario)
570
+ continue;
571
+ const funcMeta = state.functions.meta[meta.pikkuFuncId];
572
+ if (!funcMeta?.services)
573
+ continue;
574
+ const disallowed = funcMeta.services.services.filter((svc) => svc !== 'logger' && svc !== 'config');
575
+ if (disallowed.length > 0) {
576
+ logger.critical(ErrorCode.SCENARIO_HAS_SERVICES, `Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
577
+ `Scenarios may only use 'logger'/'config' — drive everything else through ` +
578
+ `actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
579
+ `runs against the target environment.`);
580
+ }
581
+ }
582
+ }
583
+ /**
584
+ * Walk every scenario step, wherever it is nested, and validate the two things
585
+ * that can only be known once both the workflow meta and the function meta
586
+ * exist:
587
+ *
588
+ * - the step target must be a static string literal (PKU678) — otherwise it
589
+ * can't be bundled, typed or drawn;
590
+ * - a `browser: true` step must be given an actor (PKU677), since the browser
591
+ * context signs in as that persona.
592
+ *
593
+ * It also marks the step's function as used, so `scenario run` boots the
594
+ * singletons the step actually needs.
595
+ */
596
+ export function validateScenarioSteps(logger, state) {
597
+ const visit = (workflowName, steps) => {
598
+ for (const step of steps) {
599
+ if (step.type === 'scenarioStep') {
600
+ if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
601
+ logger.critical(ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC, `Scenario '${workflowName}' calls step '${step.stepName}' with a target that isn't a string literal. ` +
602
+ `A step target must be statically resolvable so it can be bundled, typed and drawn — ` +
603
+ `loop over data sets instead of computing the step name.`);
604
+ continue;
605
+ }
606
+ state.serviceAggregation.usedFunctions.add(step.stepFunc);
607
+ const stepMeta = state.functions.meta[step.stepFunc];
608
+ if (stepMeta?.scenarioStepBrowser && !step.actor) {
609
+ logger.critical(ErrorCode.SCENARIO_BROWSER_STEP_NEEDS_ACTOR, `Scenario '${workflowName}' calls browser step '${step.stepFunc}' without an actor. ` +
610
+ `Pass { actor: actors.<name> } so the browser context signs in as that persona.`);
611
+ }
612
+ continue;
613
+ }
614
+ if (step.type === 'branch') {
615
+ for (const branch of step.branches)
616
+ visit(workflowName, branch.steps);
617
+ if (step.elseSteps)
618
+ visit(workflowName, step.elseSteps);
619
+ }
620
+ else if (step.type === 'switch') {
621
+ for (const c of step.cases ?? []) {
622
+ if (c.steps)
623
+ visit(workflowName, c.steps);
624
+ }
625
+ if (step.defaultSteps)
626
+ visit(workflowName, step.defaultSteps);
627
+ }
628
+ else if (step.type === 'fanout' && step.body) {
629
+ visit(workflowName, step.body);
630
+ }
631
+ else if (step.type === 'parallel' && step.children) {
632
+ visit(workflowName, step.children);
633
+ }
634
+ }
635
+ };
636
+ for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
637
+ visit(workflowName, meta.steps ?? []);
638
+ }
639
+ }
542
640
  /**
543
641
  * A `pikkuWorkflowGraph` node that references the `graph:` namespace (e.g.
544
642
  * `graph:editFields`) needs @pikku/addon-graph wired — otherwise the RPC never
@@ -1,5 +1,5 @@
1
1
  import type { JSONValue } from '@pikku/core';
2
- import type { InspectorDiagnostic, InspectorState } from '../types.js';
2
+ import type { InspectorDiagnostic, InspectorFeature, InspectorState } from '../types.js';
3
3
  /**
4
4
  * Serializable version of InspectorState that can be saved to JSON
5
5
  * Omits typesLookup (contains non-serializable ts.Type objects) and converts Maps/Sets to arrays
@@ -160,6 +160,7 @@ export interface SerializableInspectorState {
160
160
  path: string;
161
161
  exportedName: string;
162
162
  }]>;
163
+ featureFiles: Array<[string, InspectorFeature]>;
163
164
  invokedWorkflows: string[];
164
165
  };
165
166
  rpc: {
@@ -69,6 +69,7 @@ export function serializeInspectorState(state) {
69
69
  files: Array.from(state.workflows.files.entries()),
70
70
  graphMeta: state.workflows.graphMeta,
71
71
  graphFiles: Array.from(state.workflows.graphFiles.entries()),
72
+ featureFiles: Array.from(state.workflows.featureFiles.entries()),
72
73
  invokedWorkflows: Array.from(state.workflows.invokedWorkflows),
73
74
  },
74
75
  rpc: {
@@ -232,6 +233,7 @@ export function deserializeInspectorState(data) {
232
233
  files: new Map(data.workflows.files),
233
234
  graphMeta: data.workflows.graphMeta || {},
234
235
  graphFiles: new Map(data.workflows.graphFiles || []),
236
+ featureFiles: new Map(data.workflows.featureFiles || []),
235
237
  invokedWorkflows: new Set(data.workflows.invokedWorkflows || []),
236
238
  },
237
239
  rpc: {