@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
@@ -0,0 +1,103 @@
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 stepFile = (dir: string) => join(dir, 'my.steps.ts')
28
+ const stepSource = [
29
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
30
+ 'export const getTodos = pikkuSessionlessFunc({',
31
+ ' func: async ({ logger }) => [{ done: true }],',
32
+ '})',
33
+ ].join('\n')
34
+
35
+ describe('scenarios may only destructure logger/config', () => {
36
+ test('a scenario destructuring a real service is a PKU673 critical', async () => {
37
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-sc-svc-'))
38
+ const scenarioFile = join(rootDir, 'my.scenario.ts')
39
+ await writeFile(stepFile(rootDir), stepSource)
40
+ await writeFile(
41
+ scenarioFile,
42
+ [
43
+ "import { pikkuScenario } from '@pikku/core/workflow'",
44
+ 'declare const actors: Record<string, any>',
45
+ 'export const badFlow = pikkuScenario(async ({ kysely }: any, _input, { workflow }) => {',
46
+ " await workflow.do('Read todos', 'getTodos', {}, { actor: actors.pm })",
47
+ ' return { ok: true }',
48
+ '})',
49
+ ].join('\n')
50
+ )
51
+
52
+ const criticals: Array<{ code: string; message: string }> = []
53
+ try {
54
+ await inspect(makeLogger(criticals), [stepFile(rootDir), scenarioFile], {
55
+ rootDir,
56
+ })
57
+ const pku673 = criticals.find((c) => c.code === 'PKU673')
58
+ assert.ok(
59
+ pku673,
60
+ `expected a PKU673 critical for a scenario destructuring 'kysely', got: ${JSON.stringify(criticals)}`
61
+ )
62
+ assert.ok(
63
+ pku673.message.includes('kysely'),
64
+ `PKU673 should name the offending service, got: ${pku673.message}`
65
+ )
66
+ } finally {
67
+ await rm(rootDir, { recursive: true, force: true })
68
+ }
69
+ })
70
+
71
+ test('a scenario destructuring only logger is allowed', async () => {
72
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-sc-ok-'))
73
+ const scenarioFile = join(rootDir, 'my.scenario.ts')
74
+ await writeFile(stepFile(rootDir), stepSource)
75
+ await writeFile(
76
+ scenarioFile,
77
+ [
78
+ "import { pikkuScenario } from '@pikku/core/workflow'",
79
+ 'declare const actors: Record<string, any>',
80
+ 'export const goodFlow = pikkuScenario(async ({ logger }, _input, { workflow }) => {',
81
+ " await workflow.do('Read todos', 'getTodos', {}, { actor: actors.pm })",
82
+ ' return { ok: true }',
83
+ '})',
84
+ ].join('\n')
85
+ )
86
+
87
+ const criticals: Array<{ code: string; message: string }> = []
88
+ try {
89
+ const state = await inspect(
90
+ makeLogger(criticals),
91
+ [stepFile(rootDir), scenarioFile],
92
+ { rootDir }
93
+ )
94
+ assert.ok(
95
+ !criticals.some((c) => c.code === 'PKU673'),
96
+ `logger is allowed in a scenario; got: ${JSON.stringify(criticals)}`
97
+ )
98
+ assert.equal((state.workflows.meta as any).goodFlow?.scenario, true)
99
+ } finally {
100
+ await rm(rootDir, { recursive: true, force: true })
101
+ }
102
+ })
103
+ })
@@ -0,0 +1,83 @@
1
+ /**
2
+ * A scenario can declare why it is held out of a default run. The reason has to
3
+ * reach the meta, because the runner reports skips from there — if it were
4
+ * dropped in extraction the scenario would silently run anyway, which is the
5
+ * opposite of what the author asked for.
6
+ */
7
+ import { strict as assert } from 'assert'
8
+ import { describe, test } from 'node:test'
9
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import { inspect } from '../inspector.js'
13
+ import type { InspectorLogger } from '../types.js'
14
+
15
+ const logger = {
16
+ debug: () => {},
17
+ info: () => {},
18
+ warn: () => {},
19
+ error: () => {},
20
+ diagnostic: () => {},
21
+ critical: () => {},
22
+ hasCriticalErrors: () => false,
23
+ } as unknown as InspectorLogger
24
+
25
+ const STEPS = [
26
+ "import { pikkuScenarioStep } from '@pikku/core'",
27
+ 'export const buysAnApple = pikkuScenarioStep({',
28
+ " name: 'buysAnApple',",
29
+ " description: 'buys an apple',",
30
+ ' func: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
31
+ '})',
32
+ ].join('\n')
33
+
34
+ const scenarioSource = (properties: string[]) =>
35
+ [
36
+ "import { pikkuScenario } from '@pikku/core/workflow'",
37
+ 'export const shopFlow = pikkuScenario({',
38
+ ...properties.map((line) => ` ${line}`),
39
+ ' func: async (_services, _input, { scenario }: any) => {',
40
+ " await scenario.when('buys an apple', 'buysAnApple', { qty: 1 })",
41
+ ' return { ok: true }',
42
+ ' },',
43
+ '})',
44
+ ].join('\n')
45
+
46
+ async function inspectScenario(properties: string[]) {
47
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-skip-'))
48
+ const stepsFile = join(rootDir, 'shop.steps.ts')
49
+ const scenarioFile = join(rootDir, 'shop.scenario.ts')
50
+ await writeFile(stepsFile, STEPS)
51
+ await writeFile(scenarioFile, scenarioSource(properties))
52
+ const state = await inspect(logger, [stepsFile, scenarioFile], { rootDir })
53
+ return {
54
+ meta: state.workflows.meta['shopFlow'] as any,
55
+ cleanup: () => rm(rootDir, { recursive: true, force: true }),
56
+ }
57
+ }
58
+
59
+ describe('scenario skip', () => {
60
+ test('a declared skip reason reaches the meta', async () => {
61
+ const { meta, cleanup } = await inspectScenario([
62
+ "title: 'Installs an addon',",
63
+ "skip: 'mutates the project — needs a fresh server',",
64
+ ])
65
+ try {
66
+ assert.equal(meta.skip, 'mutates the project — needs a fresh server')
67
+ } finally {
68
+ await cleanup()
69
+ }
70
+ })
71
+
72
+ test('a scenario that declares none carries none', async () => {
73
+ const { meta, cleanup } = await inspectScenario([
74
+ "title: 'Installs an addon',",
75
+ ])
76
+ try {
77
+ assert.equal(meta.skip, undefined)
78
+ assert.equal(meta.scenario, true)
79
+ } finally {
80
+ await cleanup()
81
+ }
82
+ })
83
+ })
@@ -22,6 +22,9 @@ export enum ErrorCode {
22
22
  CLI_CLIENTSIDE_RENDERER_HAS_SERVICES = 'PKU672',
23
23
  SCENARIO_HAS_SERVICES = 'PKU673',
24
24
  EXPECT_EVENTUALLY_SCENARIO_ONLY = 'PKU675',
25
+ SCENARIO_BROWSER_STEP_NEEDS_ACTOR = 'PKU677',
26
+ SCENARIO_STEP_TARGET_NOT_STATIC = 'PKU678',
27
+ SCENARIO_NOT_EXTRACTABLE = 'PKU679',
25
28
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
26
29
  INVALID_DSL_WORKFLOW = 'PKU641',
27
30
  WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
package/src/index.ts CHANGED
@@ -27,6 +27,7 @@ export type {
27
27
  VersionManifest,
28
28
  VersionManifestEntry,
29
29
  } from './utils/contract-hashes.js'
30
+ export type { InspectorFeature, InspectorFeatureEntry } from './types.js'
30
31
  export { serializeMCPJson } from './utils/serialize-mcp-json.js'
31
32
  export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
32
33
  export {
package/src/inspector.ts CHANGED
@@ -1,6 +1,6 @@
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 type {
@@ -27,6 +27,8 @@ import {
27
27
  computeRequiredSchemas,
28
28
  computeDiagnostics,
29
29
  validateSchemaWiringSeparation,
30
+ validateScenarioServices,
31
+ validateScenarioSteps,
30
32
  validateWorkflowGraphAddons,
31
33
  } from './utils/post-process.js'
32
34
  import { generateOpenAPISpec } from './utils/serialize-openapi-json.js'
@@ -121,6 +123,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
121
123
  files: new Map(),
122
124
  graphMeta: {},
123
125
  graphFiles: new Map(),
126
+ featureFiles: new Map(),
124
127
  invokedWorkflows: new Set(),
125
128
  },
126
129
  rpc: {
@@ -240,6 +243,49 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
240
243
  }
241
244
  }
242
245
 
246
+ /**
247
+ * The path mappings the inspector's program has to share with the project.
248
+ *
249
+ * The inspector builds its own program rather than reusing the project's, which
250
+ * is what keeps a cold run affordable — but a project that reaches its generated
251
+ * code through a tsconfig `paths` entry resolves that import through those
252
+ * mappings and nothing else. Without them the factory an inspected function is
253
+ * passed to resolves to `any`, so a function whose input type comes from the
254
+ * factory's contextual type (rather than an explicit generic or a named schema)
255
+ * silently records no input at all.
256
+ *
257
+ * Only resolution keys are copied. Type-inference options such as `strict` stay
258
+ * the inspector's own, because changing those would change the recorded types.
259
+ */
260
+ const readProjectPathMappings = (
261
+ logger: InspectorLogger,
262
+ tsconfig: string | undefined
263
+ ): ts.CompilerOptions => {
264
+ if (!tsconfig) {
265
+ return {}
266
+ }
267
+ const configPath = resolve(tsconfig)
268
+ const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
269
+ if (configFile.error || !configFile.config) {
270
+ logger.debug(
271
+ `Could not read ${configPath} for path mappings; imports that rely on them will not resolve`
272
+ )
273
+ return {}
274
+ }
275
+ const { options: projectOptions } = ts.parseJsonConfigFileContent(
276
+ configFile.config,
277
+ ts.sys,
278
+ dirname(configPath)
279
+ )
280
+ const { baseUrl, paths, rootDirs, pathsBasePath } = projectOptions
281
+ return {
282
+ ...(baseUrl !== undefined ? { baseUrl } : {}),
283
+ ...(paths !== undefined ? { paths } : {}),
284
+ ...(rootDirs !== undefined ? { rootDirs } : {}),
285
+ ...(pathsBasePath !== undefined ? { pathsBasePath } : {}),
286
+ }
287
+ }
288
+
243
289
  export const inspect = async (
244
290
  logger: InspectorLogger,
245
291
  routeFiles: string[],
@@ -247,6 +293,7 @@ export const inspect = async (
247
293
  ): Promise<InspectorState> => {
248
294
  const normalizedRouteFiles = routeFiles.map((file) => resolve(file))
249
295
  const compilerOptions: ts.CompilerOptions = {
296
+ ...readProjectPathMappings(logger, options.tsconfig),
250
297
  target: ts.ScriptTarget.ESNext,
251
298
  module: ts.ModuleKind.Node16,
252
299
  skipLibCheck: true,
@@ -407,6 +454,8 @@ export const inspect = async (
407
454
  computeDiagnostics(state)
408
455
  validateSchemaWiringSeparation(logger, state)
409
456
  validateWorkflowGraphAddons(logger, state)
457
+ validateScenarioServices(logger, state)
458
+ validateScenarioSteps(logger, state)
410
459
 
411
460
  if (options.openAPI) {
412
461
  state.openAPISpec = await generateOpenAPISpec(
@@ -0,0 +1,90 @@
1
+ import { strict as assert } from 'assert'
2
+ import { describe, test } from 'node:test'
3
+ import { mkdir, 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 logger: InspectorLogger = {
10
+ debug: () => {},
11
+ info: () => {},
12
+ warn: () => {},
13
+ error: () => {},
14
+ diagnostic: () => {},
15
+ critical: () => {},
16
+ hasCriticalErrors: () => false,
17
+ }
18
+
19
+ const FACTORY = [
20
+ 'export type Config = {',
21
+ ' name: string',
22
+ ' func: (services: any, data: { qty: number }) => Promise<{ ok: boolean }>',
23
+ '}',
24
+ 'export const pikkuSessionlessFunc = (config: Config): Config => config',
25
+ ].join('\n')
26
+
27
+ // The parameter is deliberately unannotated: its type can only come from the
28
+ // factory's contextual type, which is exactly what an unresolved import loses.
29
+ const funcSource = (specifier: string) =>
30
+ [
31
+ `import { pikkuSessionlessFunc } from '${specifier}'`,
32
+ 'export const buysAnApple = pikkuSessionlessFunc({',
33
+ " name: 'buysAnApple',",
34
+ ' func: async (_services, data) => ({ ok: data.qty > 0 }),',
35
+ '})',
36
+ ].join('\n')
37
+
38
+ const makeProject = async (specifier: string) => {
39
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-tsconfig-paths-'))
40
+ await mkdir(join(rootDir, 'generated'))
41
+ await mkdir(join(rootDir, 'src'))
42
+ await writeFile(
43
+ join(rootDir, 'tsconfig.json'),
44
+ JSON.stringify({
45
+ compilerOptions: {
46
+ module: 'NodeNext',
47
+ moduleResolution: 'NodeNext',
48
+ paths: { '#gen/*': ['./generated/*'] },
49
+ },
50
+ })
51
+ )
52
+ await writeFile(
53
+ join(rootDir, 'package.json'),
54
+ JSON.stringify({ type: 'module' })
55
+ )
56
+ await writeFile(join(rootDir, 'generated', 'factory.ts'), FACTORY)
57
+ const funcFile = join(rootDir, 'src', 'apple.func.ts')
58
+ await writeFile(funcFile, funcSource(specifier))
59
+ return { rootDir, funcFile, tsconfig: join(rootDir, 'tsconfig.json') }
60
+ }
61
+
62
+ describe('tsconfig path mappings', () => {
63
+ test('a relative import gives the function its input type', async () => {
64
+ const { rootDir, funcFile } = await makeProject('../generated/factory.js')
65
+ try {
66
+ const state = await inspect(logger, [funcFile], { rootDir })
67
+ const meta = state.functions.meta['buysAnApple']
68
+ assert.ok(meta, 'buysAnApple meta should exist')
69
+ assert.deepStrictEqual(meta!.inputs, ['BuysAnAppleInput'])
70
+ } finally {
71
+ await rm(rootDir, { recursive: true, force: true })
72
+ }
73
+ })
74
+
75
+ test('an import that only resolves through tsconfig paths keeps its input type', async () => {
76
+ const { rootDir, funcFile, tsconfig } = await makeProject('#gen/factory.js')
77
+ try {
78
+ const state = await inspect(logger, [funcFile], { rootDir, tsconfig })
79
+ const meta = state.functions.meta['buysAnApple']
80
+ assert.ok(meta, 'buysAnApple meta should exist')
81
+ assert.deepStrictEqual(
82
+ meta!.inputs,
83
+ ['BuysAnAppleInput'],
84
+ 'without the project path mappings the factory resolves to `any`, and the input is silently dropped'
85
+ )
86
+ } finally {
87
+ await rm(rootDir, { recursive: true, force: true })
88
+ }
89
+ })
90
+ })
package/src/types.ts CHANGED
@@ -273,6 +273,12 @@ export type InspectorOptions = Partial<{
273
273
  rootDir: string
274
274
  isAddon: boolean
275
275
  sourceFile: ts.SourceFile
276
+ /**
277
+ * The project's tsconfig, read for its path mappings only. The inspector's
278
+ * program needs them to resolve imports the project reaches through `paths`;
279
+ * unlike `schemaConfig.tsconfig` this is also needed on a setup-only run.
280
+ */
281
+ tsconfig: string
276
282
  types: Partial<{
277
283
  configFileType: string
278
284
  userSessionType: string
@@ -423,6 +429,36 @@ export interface AuthDefinition {
423
429
  services: FunctionServicesMeta
424
430
  }
425
431
 
432
+ /** One statically resolved entry of a feature's `scenarios` array. */
433
+ export interface InspectorFeatureEntry {
434
+ /** The scenario's declared export name, with any import alias followed back. */
435
+ scenario: string
436
+ /** A `{ scenario, data }` entry's literal data — gherkin's `Examples:`. */
437
+ data?: unknown
438
+ }
439
+
440
+ /** A `pikkuFeature` export, as far as it can be read from the source. */
441
+ export interface InspectorFeature {
442
+ path: string
443
+ exportedName: string
444
+ name?: string
445
+ description?: string
446
+ tags?: string[]
447
+ entries: InspectorFeatureEntry[]
448
+ /**
449
+ * Entries that are not literal — a spread, a `.map()`, a call. Counted rather
450
+ * than dropped so the console can say the listing is partial instead of
451
+ * quietly showing a short feature.
452
+ */
453
+ unresolvedEntries: number
454
+ /**
455
+ * Feature hooks run once around the whole group, never per scenario, and are
456
+ * runtime-only — so only their presence is recorded.
457
+ */
458
+ hasBefore: boolean
459
+ hasAfter: boolean
460
+ }
461
+
426
462
  export interface InspectorState {
427
463
  rootDir: string // Root directory inferred from source files
428
464
  singletonServicesTypeImportMap: PathToNameAndType
@@ -466,6 +502,15 @@ export interface InspectorState {
466
502
  files: Map<string, { path: string; exportedName: string }>
467
503
  graphMeta: SerializedWorkflowGraphs
468
504
  graphFiles: Map<string, { path: string; exportedName: string }>
505
+ /**
506
+ * `pikkuFeature` exports, keyed by export identifier.
507
+ *
508
+ * Everything the config states literally is extracted here, because a
509
+ * feature is the document structure the console renders. Entries built by
510
+ * an ordinary loop cannot be enumerated statically — those are counted in
511
+ * `unresolvedEntries` and resolved at runtime by object identity instead.
512
+ */
513
+ featureFiles: Map<string, InspectorFeature>
469
514
  invokedWorkflows: Set<string>
470
515
  }
471
516
  rpc: {
@@ -45,6 +45,40 @@ export function funcIdToTypeName(id: string): string {
45
45
  .join('')
46
46
  }
47
47
 
48
+ /**
49
+ * `pikkuScenarioStep({ name })` is the one wrapper whose `name` is its identity:
50
+ * scenarios reference a step by that string, so it must become the pikkuFuncId
51
+ * (and hence the generated step map's key), not just a cosmetic label the way
52
+ * `pikkuMiddleware`/`pikkuAgent` use it.
53
+ */
54
+ function extractScenarioStepName(
55
+ callExpr: ts.Node,
56
+ result: ExtractedFunctionName
57
+ ): void {
58
+ if (
59
+ !ts.isCallExpression(callExpr) ||
60
+ !ts.isIdentifier(callExpr.expression) ||
61
+ callExpr.expression.text !== 'pikkuScenarioStep'
62
+ ) {
63
+ return
64
+ }
65
+ const firstArg = callExpr.arguments[0]
66
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
67
+ return
68
+ }
69
+ for (const prop of firstArg.properties) {
70
+ if (
71
+ ts.isPropertyAssignment(prop) &&
72
+ ts.isIdentifier(prop.name) &&
73
+ prop.name.text === 'name' &&
74
+ ts.isStringLiteral(prop.initializer)
75
+ ) {
76
+ result.explicitName = prop.initializer.text
77
+ return
78
+ }
79
+ }
80
+ }
81
+
48
82
  export function extractFunctionName(
49
83
  callExpr: ts.Node,
50
84
  checker: ts.TypeChecker,
@@ -63,6 +97,8 @@ export function extractFunctionName(
63
97
  version: null,
64
98
  }
65
99
 
100
+ extractScenarioStepName(callExpr, result)
101
+
66
102
  const workflowHelpers = new Set([
67
103
  'workflow',
68
104
  'workflowStart',
@@ -63,6 +63,7 @@ function makeState(
63
63
  functionsMeta?: Record<string, any>
64
64
  addonFunctions?: Record<string, Record<string, any>>
65
65
  addonRequiredParentServices?: string[]
66
+ authServices?: string[]
66
67
  } = {}
67
68
  ): Omit<InspectorState, 'typesLookup'> {
68
69
  return {
@@ -99,6 +100,13 @@ function makeState(
99
100
  rpc: { internalMeta: {}, exposedMeta: {} },
100
101
  addonFunctions: overrides.addonFunctions ?? {},
101
102
  addonRequiredParentServices: overrides.addonRequiredParentServices ?? [],
103
+ auth: overrides.authServices
104
+ ? {
105
+ definition: {
106
+ services: { optimized: true, services: overrides.authServices },
107
+ },
108
+ }
109
+ : {},
102
110
  } as unknown as Omit<InspectorState, 'typesLookup'>
103
111
  }
104
112
 
@@ -306,6 +314,26 @@ describe('aggregateRequiredServices — per-function addon services', () => {
306
314
  })
307
315
  })
308
316
 
317
+ describe('aggregateRequiredServices — the auth factory’s own services', () => {
318
+ test('an auth definition contributes its services before its handler exists', () => {
319
+ // The state a clean checkout produces: the project declares
320
+ // `pikkuBetterAuth`, so the definition is inspected, but the handler the CLI
321
+ // generates from it has not been written yet — so nothing in functions.meta
322
+ // mentions the services `authorize` reads.
323
+ const state = makeState({ authServices: ['kysely', 'jwt'] })
324
+ aggregateRequiredServices(state)
325
+ const required = state.serviceAggregation.requiredServices
326
+ assert.ok(required.has('kysely'))
327
+ assert.ok(required.has('jwt'))
328
+ })
329
+
330
+ test('a project without auth is unaffected', () => {
331
+ const state = makeState()
332
+ aggregateRequiredServices(state)
333
+ assert.equal(state.serviceAggregation.requiredServices.size, 0)
334
+ })
335
+ })
336
+
309
337
  describe('override validation resolves the override target (value), not the logical key', () => {
310
338
  test('validateSecretOverrides accepts an override whose value is a declared secret', () => {
311
339
  const { logger, criticals } = makeCriticalLogger()
@@ -422,7 +450,10 @@ describe('validateRemoteAddonDependencies (wireRemoteAddon must be a devDependen
422
450
  })
423
451
  )
424
452
  assert.equal(criticals.length, 1)
425
- assert.equal(criticals[0]!.code, ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY)
453
+ assert.equal(
454
+ criticals[0]!.code,
455
+ ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY
456
+ )
426
457
  assert.match(criticals[0]!.message, /devDependencies/)
427
458
  } finally {
428
459
  rmSync(dir, { recursive: true, force: true })
@@ -441,7 +472,10 @@ describe('validateRemoteAddonDependencies (wireRemoteAddon must be a devDependen
441
472
  })
442
473
  )
443
474
  assert.equal(criticals.length, 1)
444
- assert.equal(criticals[0]!.code, ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY)
475
+ assert.equal(
476
+ criticals[0]!.code,
477
+ ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY
478
+ )
445
479
  } finally {
446
480
  rmSync(dir, { recursive: true, force: true })
447
481
  }