@pikku/inspector 0.12.41 → 0.12.43

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 (108) hide show
  1. package/CHANGELOG.md +104 -0
  2. package/dist/add/add-addon-bans.js +1 -0
  3. package/dist/add/add-ai-agent.d.ts +3 -1
  4. package/dist/add/add-ai-agent.js +82 -0
  5. package/dist/add/add-credential.js +3 -1
  6. package/dist/add/add-functions.js +10 -9
  7. package/dist/add/add-gateway.js +3 -0
  8. package/dist/add/add-http-route.js +2 -2
  9. package/dist/add/add-mcp-prompt.js +0 -1
  10. package/dist/add/add-mcp-resource.js +0 -1
  11. package/dist/add/add-permission.d.ts +1 -1
  12. package/dist/add/add-permission.js +3 -177
  13. package/dist/add/add-queue-worker.js +3 -0
  14. package/dist/add/add-schedule.js +3 -0
  15. package/dist/add/add-scope.d.ts +2 -0
  16. package/dist/add/add-scope.js +146 -0
  17. package/dist/add/add-trigger.js +3 -0
  18. package/dist/add/add-wire-remote-addon.d.ts +10 -0
  19. package/dist/add/add-wire-remote-addon.js +66 -0
  20. package/dist/add/add-workflow-graph.js +32 -3
  21. package/dist/error-codes.d.ts +3 -0
  22. package/dist/error-codes.js +4 -0
  23. package/dist/inspector.js +19 -7
  24. package/dist/types.d.ts +19 -13
  25. package/dist/utils/custom-types-generator.js +2 -1
  26. package/dist/utils/ensure-function-metadata.d.ts +15 -0
  27. package/dist/utils/ensure-function-metadata.js +24 -0
  28. package/dist/utils/filter-inspector-state.js +9 -0
  29. package/dist/utils/get-property-value.d.ts +7 -2
  30. package/dist/utils/get-property-value.js +48 -1
  31. package/dist/utils/load-addon-functions-meta.js +81 -6
  32. package/dist/utils/nested-project-filter.d.ts +1 -0
  33. package/dist/utils/nested-project-filter.js +29 -0
  34. package/dist/utils/permissions.d.ts +1 -21
  35. package/dist/utils/permissions.js +17 -108
  36. package/dist/utils/post-process.d.ts +30 -1
  37. package/dist/utils/post-process.js +150 -72
  38. package/dist/utils/serialize-inspector-state.d.ts +13 -8
  39. package/dist/utils/serialize-inspector-state.js +12 -6
  40. package/dist/utils/serialize-permissions-groups-meta.d.ts +0 -2
  41. package/dist/utils/serialize-permissions-groups-meta.js +0 -26
  42. package/dist/utils/workflow/derive-workflow-plan.js +9 -3
  43. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +56 -1
  44. package/dist/utils/workflow/dsl/patterns.d.ts +10 -0
  45. package/dist/utils/workflow/dsl/patterns.js +24 -8
  46. package/dist/utils/workflow/dsl/validation.js +3 -4
  47. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +14 -0
  48. package/dist/utils/workflow/graph/finalize-workflows.d.ts +7 -0
  49. package/dist/utils/workflow/graph/finalize-workflows.js +11 -2
  50. package/dist/utils/workflow/graph/serialize-workflow-graph.d.ts +2 -0
  51. package/dist/utils/workflow/graph/serialize-workflow-graph.js +4 -0
  52. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +6 -2
  53. package/dist/visit.js +4 -0
  54. package/package.json +3 -3
  55. package/src/add/add-addon-bans.ts +1 -0
  56. package/src/add/add-ai-agent.test.ts +87 -0
  57. package/src/add/add-ai-agent.ts +122 -0
  58. package/src/add/add-credential.ts +6 -0
  59. package/src/add/add-functions.ts +10 -12
  60. package/src/add/add-gateway.ts +11 -0
  61. package/src/add/add-http-route.ts +2 -8
  62. package/src/add/add-mcp-prompt.ts +0 -1
  63. package/src/add/add-mcp-resource.ts +0 -1
  64. package/src/add/add-permission.ts +4 -242
  65. package/src/add/add-queue-worker.ts +11 -0
  66. package/src/add/add-schedule.ts +11 -0
  67. package/src/add/add-scope.test.ts +346 -0
  68. package/src/add/add-scope.ts +225 -0
  69. package/src/add/add-trigger.ts +11 -0
  70. package/src/add/add-wire-remote-addon.ts +77 -0
  71. package/src/add/add-workflow-graph-input.test.ts +94 -0
  72. package/src/add/add-workflow-graph-notes.test.ts +74 -0
  73. package/src/add/add-workflow-graph.ts +35 -3
  74. package/src/add/inline-wiring-function.test.ts +104 -0
  75. package/src/add/validate-workflow-graph-addons.test.ts +102 -0
  76. package/src/error-codes.ts +5 -0
  77. package/src/inspector.ts +22 -6
  78. package/src/types.ts +16 -17
  79. package/src/utils/custom-types-generator.test.ts +26 -1
  80. package/src/utils/custom-types-generator.ts +2 -1
  81. package/src/utils/ensure-function-metadata.ts +42 -0
  82. package/src/utils/filter-inspector-state.test.ts +0 -2
  83. package/src/utils/filter-inspector-state.ts +9 -0
  84. package/src/utils/get-property-value.test.ts +141 -0
  85. package/src/utils/get-property-value.ts +62 -1
  86. package/src/utils/load-addon-functions-meta.test.ts +157 -0
  87. package/src/utils/load-addon-functions-meta.ts +94 -6
  88. package/src/utils/load-addon-scopes.test.ts +138 -0
  89. package/src/utils/nested-project-filter.test.ts +53 -0
  90. package/src/utils/nested-project-filter.ts +28 -0
  91. package/src/utils/permissions.test.ts +7 -232
  92. package/src/utils/permissions.ts +20 -160
  93. package/src/utils/post-process.test.ts +269 -4
  94. package/src/utils/post-process.ts +216 -95
  95. package/src/utils/serialize-inspector-state.ts +22 -25
  96. package/src/utils/serialize-permissions-groups-meta.ts +0 -28
  97. package/src/utils/workflow/derive-workflow-plan.test.ts +41 -3
  98. package/src/utils/workflow/derive-workflow-plan.ts +11 -4
  99. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +66 -0
  100. package/src/utils/workflow/dsl/patterns.ts +34 -8
  101. package/src/utils/workflow/dsl/validation.ts +3 -4
  102. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +15 -0
  103. package/src/utils/workflow/graph/finalize-workflows.test.ts +50 -0
  104. package/src/utils/workflow/graph/finalize-workflows.ts +13 -2
  105. package/src/utils/workflow/graph/serialize-workflow-graph.ts +6 -0
  106. package/src/utils/workflow/graph/workflow-graph.types.ts +8 -0
  107. package/src/visit.ts +4 -0
  108. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,225 @@
1
+ import * as ts from 'typescript'
2
+ import { getPropertyValue } from '../utils/get-property-value.js'
3
+ import type { AddWiring, InspectorLogger } from '../types.js'
4
+ import { ErrorCode } from '../error-codes.js'
5
+ import type { ScopeNodeMeta } from '@pikku/core/scope'
6
+
7
+ const SEPARATOR = ':'
8
+ const WILDCARD = '*'
9
+
10
+ /**
11
+ * Validates a single scope segment, reporting rather than throwing so the
12
+ * inspector can surface every problem in one pass.
13
+ *
14
+ * @returns true when the segment is usable.
15
+ */
16
+ const isValidSegment = (
17
+ segment: string,
18
+ scopeName: string,
19
+ logger: InspectorLogger
20
+ ): boolean => {
21
+ if (segment.length === 0) {
22
+ logger.critical(
23
+ ErrorCode.INVALID_VALUE,
24
+ `Scope '${scopeName}' contains an empty segment.`
25
+ )
26
+ return false
27
+ }
28
+ if (segment.includes(SEPARATOR)) {
29
+ logger.critical(
30
+ ErrorCode.INVALID_VALUE,
31
+ `Scope segment '${segment}' in '${scopeName}' contains the '${SEPARATOR}' separator. ` +
32
+ `Nest scopes with the 'scopes' property instead of embedding '${SEPARATOR}' in a name.`
33
+ )
34
+ return false
35
+ }
36
+ if (segment === WILDCARD) {
37
+ logger.critical(
38
+ ErrorCode.INVALID_VALUE,
39
+ `Scope segment '${segment}' in '${scopeName}' is the wildcard. ` +
40
+ `'${WILDCARD}' is reserved for granting a scope and its descendants, and cannot be declared.`
41
+ )
42
+ return false
43
+ }
44
+ return true
45
+ }
46
+
47
+ /**
48
+ * Recursively extracts a `scopes: { ... }` object literal into nested metadata.
49
+ */
50
+ const extractScopeNodes = (
51
+ obj: ts.ObjectLiteralExpression,
52
+ scopeName: string,
53
+ logger: InspectorLogger
54
+ ): Record<string, ScopeNodeMeta> | undefined => {
55
+ const nodes: Record<string, ScopeNodeMeta> = {}
56
+
57
+ for (const prop of obj.properties) {
58
+ if (!ts.isPropertyAssignment(prop)) {
59
+ continue
60
+ }
61
+
62
+ let segment: string | undefined
63
+ if (ts.isIdentifier(prop.name)) {
64
+ segment = prop.name.text
65
+ } else if (ts.isStringLiteral(prop.name)) {
66
+ segment = prop.name.text
67
+ }
68
+
69
+ if (segment === undefined) {
70
+ logger.critical(
71
+ ErrorCode.NON_LITERAL_WIRE_NAME,
72
+ `Scope '${scopeName}' has a nested scope whose key is not a literal.`
73
+ )
74
+ continue
75
+ }
76
+
77
+ if (!isValidSegment(segment, scopeName, logger)) {
78
+ continue
79
+ }
80
+
81
+ if (!ts.isObjectLiteralExpression(prop.initializer)) {
82
+ logger.critical(
83
+ ErrorCode.INVALID_VALUE,
84
+ `Nested scope '${segment}' in '${scopeName}' must be an object literal.`
85
+ )
86
+ continue
87
+ }
88
+
89
+ const node: ScopeNodeMeta = {}
90
+ const displayName = getPropertyValue(prop.initializer, 'displayName') as
91
+ | string
92
+ | null
93
+ if (displayName) {
94
+ node.displayName = displayName
95
+ }
96
+ const description = getPropertyValue(prop.initializer, 'description') as
97
+ | string
98
+ | null
99
+ if (description) {
100
+ node.description = description
101
+ }
102
+
103
+ const nestedProp = prop.initializer.properties.find(
104
+ (p) =>
105
+ ts.isPropertyAssignment(p) &&
106
+ ts.isIdentifier(p.name) &&
107
+ p.name.text === 'scopes'
108
+ )
109
+ if (
110
+ nestedProp &&
111
+ ts.isPropertyAssignment(nestedProp) &&
112
+ ts.isObjectLiteralExpression(nestedProp.initializer)
113
+ ) {
114
+ const nested = extractScopeNodes(
115
+ nestedProp.initializer,
116
+ scopeName,
117
+ logger
118
+ )
119
+ if (nested) {
120
+ node.scopes = nested
121
+ }
122
+ }
123
+
124
+ nodes[segment] = node
125
+ }
126
+
127
+ return Object.keys(nodes).length > 0 ? nodes : undefined
128
+ }
129
+
130
+ /**
131
+ * Unwraps `x as const` / `x as any` so a cast declaration is still extracted
132
+ * rather than silently skipped.
133
+ */
134
+ const unwrapAs = (node: ts.Expression): ts.Expression =>
135
+ ts.isAsExpression(node) || ts.isSatisfiesExpression(node)
136
+ ? unwrapAs(node.expression)
137
+ : node
138
+
139
+ export const addScope: AddWiring = (logger, node, checker, state, _options) => {
140
+ if (!ts.isCallExpression(node)) {
141
+ return
142
+ }
143
+
144
+ const expression = node.expression
145
+ if (!ts.isIdentifier(expression) || expression.text !== 'wireScope') {
146
+ return
147
+ }
148
+
149
+ const firstArg = node.arguments[0]
150
+ if (!firstArg) {
151
+ return
152
+ }
153
+
154
+ const unwrapped = unwrapAs(firstArg)
155
+ if (!ts.isObjectLiteralExpression(unwrapped)) {
156
+ return
157
+ }
158
+
159
+ const sourceFile = node.getSourceFile().fileName
160
+
161
+ // Roots are keyed exactly like the nodes beneath them, so each property of
162
+ // the call's single argument is one tree.
163
+ for (const prop of unwrapped.properties) {
164
+ if (!ts.isPropertyAssignment(prop)) {
165
+ continue
166
+ }
167
+
168
+ let name: string | undefined
169
+ if (ts.isIdentifier(prop.name) || ts.isStringLiteral(prop.name)) {
170
+ name = prop.name.text
171
+ }
172
+
173
+ if (name === undefined) {
174
+ logger.critical(
175
+ ErrorCode.NON_LITERAL_WIRE_NAME,
176
+ 'A scope is declared with a key that is not a literal.'
177
+ )
178
+ continue
179
+ }
180
+
181
+ if (!isValidSegment(name, name, logger)) {
182
+ continue
183
+ }
184
+
185
+ if (!ts.isObjectLiteralExpression(prop.initializer)) {
186
+ logger.critical(
187
+ ErrorCode.INVALID_VALUE,
188
+ `Scope '${name}' must be an object literal.`
189
+ )
190
+ continue
191
+ }
192
+
193
+ const root = prop.initializer
194
+ const displayNameValue = getPropertyValue(root, 'displayName') as
195
+ | string
196
+ | null
197
+ const descriptionValue = getPropertyValue(root, 'description') as
198
+ | string
199
+ | null
200
+
201
+ let scopes: Record<string, ScopeNodeMeta> | undefined
202
+ const scopesProp = root.properties.find(
203
+ (p) =>
204
+ ts.isPropertyAssignment(p) &&
205
+ ts.isIdentifier(p.name) &&
206
+ p.name.text === 'scopes'
207
+ )
208
+ if (
209
+ scopesProp &&
210
+ ts.isPropertyAssignment(scopesProp) &&
211
+ ts.isObjectLiteralExpression(scopesProp.initializer)
212
+ ) {
213
+ scopes = extractScopeNodes(scopesProp.initializer, name, logger)
214
+ }
215
+
216
+ state.scopes.files.add(sourceFile)
217
+ state.scopes.definitions.push({
218
+ name,
219
+ displayName: displayNameValue || undefined,
220
+ description: descriptionValue || undefined,
221
+ scopes,
222
+ sourceFile,
223
+ })
224
+ }
225
+ }
@@ -9,6 +9,7 @@ import {
9
9
  makeContextBasedId,
10
10
  } from '../utils/extract-function-name.js'
11
11
  import { getPropertyAssignmentInitializer } from '../utils/type-utils.js'
12
+ import { ensureInlineWiringFunction } from '../utils/ensure-function-metadata.js'
12
13
  import { resolveMiddleware } from '../utils/middleware.js'
13
14
  import { extractWireNames } from '../utils/post-process.js'
14
15
  import { resolveAddonName } from '../utils/resolve-addon-package.js'
@@ -83,6 +84,16 @@ const addWireTrigger: (
83
84
  return
84
85
  }
85
86
 
87
+ // Register metadata for a func inlined into the wiring (see helper).
88
+ ensureInlineWiringFunction(
89
+ state,
90
+ pikkuFuncId,
91
+ nameValue,
92
+ funcInitializer,
93
+ checker,
94
+ extracted.isHelper
95
+ )
96
+
86
97
  // --- resolve middleware ---
87
98
  const middleware = resolveMiddleware(state, obj, tags, checker)
88
99
 
@@ -0,0 +1,77 @@
1
+ import * as ts from 'typescript'
2
+ import type { InspectorState, InspectorLogger } from '../types.js'
3
+
4
+ /**
5
+ * Detect wireRemoteAddon({ name: '...', package: '...' }) call expressions and
6
+ * record the namespace as a **remote** addon declaration. Only name + package
7
+ * are needed for codegen (serverUrl/auth are runtime closures resolved by the
8
+ * RPC runner); the `remote` marker tells the consumer's RPC-map generator to
9
+ * import the addon's `.remote.gen` map instead of `.internal.gen`.
10
+ */
11
+ export function addWireRemoteAddon(
12
+ node: ts.Node,
13
+ state: InspectorState,
14
+ logger: InspectorLogger
15
+ ) {
16
+ if (!ts.isCallExpression(node)) return
17
+
18
+ const { expression, arguments: args } = node
19
+ if (!ts.isIdentifier(expression) || expression.text !== 'wireRemoteAddon')
20
+ return
21
+
22
+ const [firstArg] = args
23
+ if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) return
24
+
25
+ let name: string | undefined
26
+ let pkg: string | undefined
27
+ // The auth binding is a runtime closure; we only extract the statically
28
+ // knowable slot names so `pikku verify` can check they exist in the
29
+ // consumer's own wirings. `hasAuth` distinguishes "public" (omitted) from
30
+ // "bound via a custom resolve()" (present but no static id).
31
+ let hasAuth = false
32
+ let authCredentialId: string | undefined
33
+ let authSecretId: string | undefined
34
+
35
+ for (const prop of firstArg.properties) {
36
+ if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
37
+ const key = prop.name.text
38
+ if (key === 'name' && ts.isStringLiteral(prop.initializer)) {
39
+ name = prop.initializer.text
40
+ } else if (key === 'package' && ts.isStringLiteral(prop.initializer)) {
41
+ pkg = prop.initializer.text
42
+ } else if (
43
+ key === 'auth' &&
44
+ ts.isObjectLiteralExpression(prop.initializer)
45
+ ) {
46
+ hasAuth = true
47
+ for (const authProp of prop.initializer.properties) {
48
+ if (!ts.isPropertyAssignment(authProp) || !ts.isIdentifier(authProp.name))
49
+ continue
50
+ if (
51
+ authProp.name.text === 'credentialId' &&
52
+ ts.isStringLiteral(authProp.initializer)
53
+ ) {
54
+ authCredentialId = authProp.initializer.text
55
+ } else if (
56
+ authProp.name.text === 'secretId' &&
57
+ ts.isStringLiteral(authProp.initializer)
58
+ ) {
59
+ authSecretId = authProp.initializer.text
60
+ }
61
+ }
62
+ }
63
+ }
64
+
65
+ if (!name || !pkg) return
66
+
67
+ logger.debug(`• Found wireRemoteAddon: ${name} → ${pkg} (remote)`)
68
+ state.rpc.wireAddonDeclarations.set(name, {
69
+ package: pkg,
70
+ remote: true,
71
+ hasAuth,
72
+ authCredentialId,
73
+ authSecretId,
74
+ })
75
+ state.rpc.usedAddons.add(name)
76
+ state.rpc.wireAddonFiles.add(node.getSourceFile().fileName)
77
+ }
@@ -0,0 +1,94 @@
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(): InspectorLogger {
10
+ return {
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ diagnostic: () => {},
16
+ critical: () => {},
17
+ hasCriticalErrors: () => false,
18
+ }
19
+ }
20
+
21
+ describe('addWorkflowGraph — node input extraction', () => {
22
+ test('`as const` and imported template() values survive into the serialized input', async () => {
23
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-graph-input-'))
24
+ const stepFile = join(rootDir, 'my.steps.ts')
25
+ const graphFile = join(rootDir, 'my.graph.ts')
26
+
27
+ await writeFile(
28
+ stepFile,
29
+ [
30
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
31
+ 'export const edit = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
32
+ ].join('\n')
33
+ )
34
+
35
+ await writeFile(
36
+ graphFile,
37
+ [
38
+ "import { pikkuWorkflowGraph } from '@pikku/core/workflow'",
39
+ "import { template } from '@pikku/core/workflow'",
40
+ 'export const myGraph = pikkuWorkflowGraph({',
41
+ " name: 'input-graph',",
42
+ " nodes: { edit: 'edit' },",
43
+ ' config: {',
44
+ ' edit: {',
45
+ ' input: (ref) => ({',
46
+ " method: 'GET' as const,",
47
+ ' operations: [',
48
+ " { field: 'product', operation: 'set' as const, value: 'widget' },",
49
+ " { field: 'email', operation: 'set' as const, value: ref('trigger', 'body.email') },",
50
+ " { field: 'greeting', operation: 'set' as const, value: template('Hi $0', [ref('trigger', 'body.name')]) },",
51
+ ' ],',
52
+ ' }),',
53
+ ' },',
54
+ ' },',
55
+ '})',
56
+ ].join('\n')
57
+ )
58
+
59
+ try {
60
+ const state = await inspect(makeLogger(), [stepFile, graphFile], {
61
+ rootDir,
62
+ })
63
+ const graph = state.workflows.graphMeta['input-graph']
64
+ assert.ok(graph, 'graph meta should be registered')
65
+ const input = (graph.nodes['edit'] as { input?: any }).input
66
+ assert.ok(input, 'node input should be extracted')
67
+
68
+ // Bug 1 — top-level `X as const` must not be dropped.
69
+ assert.equal(input.method, 'GET', 'top-level `as const` value preserved')
70
+
71
+ // Bug 1 — nested `as const` inside an array element must not be dropped.
72
+ assert.equal(input.operations[0].operation, 'set')
73
+ assert.equal(input.operations[0].value, 'widget')
74
+ assert.equal(input.operations[1].operation, 'set')
75
+
76
+ // ref() still works alongside the fix.
77
+ assert.deepEqual(input.operations[1].value, {
78
+ $ref: 'trigger',
79
+ path: 'body.email',
80
+ })
81
+
82
+ // Bug 2 — imported template() (arrow has only `ref`, no 2nd param) preserved.
83
+ assert.equal(input.operations[2].operation, 'set')
84
+ assert.deepEqual(input.operations[2].value, {
85
+ $template: {
86
+ parts: ['Hi ', ''],
87
+ expressions: [{ $ref: 'trigger', path: 'body.name' }],
88
+ },
89
+ })
90
+ } finally {
91
+ await rm(rootDir, { recursive: true, force: true })
92
+ }
93
+ })
94
+ })
@@ -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
+ function makeLogger(): InspectorLogger {
10
+ return {
11
+ debug: () => {},
12
+ info: () => {},
13
+ warn: () => {},
14
+ error: () => {},
15
+ diagnostic: () => {},
16
+ critical: () => {},
17
+ hasCriticalErrors: () => false,
18
+ }
19
+ }
20
+
21
+ describe('addWorkflowGraph — notes extraction', () => {
22
+ test('node-level and graph-level notes are carried into the serialized graph', async () => {
23
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-graph-notes-'))
24
+ const stepFile = join(rootDir, 'my.steps.ts')
25
+ const graphFile = join(rootDir, 'my.graph.ts')
26
+
27
+ await writeFile(
28
+ stepFile,
29
+ [
30
+ "import { pikkuSessionlessFunc } from '@pikku/core'",
31
+ 'export const assess = pikkuSessionlessFunc({ func: async () => ({ ok: true }) })',
32
+ 'export const notify = pikkuSessionlessFunc({ func: async () => ({ sent: true }) })',
33
+ ].join('\n')
34
+ )
35
+
36
+ await writeFile(
37
+ graphFile,
38
+ [
39
+ "import { pikkuWorkflowGraph } from '@pikku/core/workflow'",
40
+ 'export const myGraph = pikkuWorkflowGraph({',
41
+ " name: 'my-graph',",
42
+ " notes: ['imported sticky note'],",
43
+ ' nodes: {',
44
+ " assess: 'assess',",
45
+ " notify: 'notify',",
46
+ ' },',
47
+ ' config: {',
48
+ " assess: { next: 'notify', notes: 'STUB — generated from node \"Assess\"' },",
49
+ ' notify: {},',
50
+ ' },',
51
+ '})',
52
+ ].join('\n')
53
+ )
54
+
55
+ try {
56
+ const state = await inspect(makeLogger(), [stepFile, graphFile], {
57
+ rootDir,
58
+ })
59
+ const graph = state.workflows.graphMeta['my-graph']
60
+ assert.ok(graph, 'graph meta should be registered')
61
+ assert.deepEqual(graph.notes, ['imported sticky note'])
62
+ assert.equal(
63
+ (graph.nodes['assess'] as { notes?: string }).notes,
64
+ 'STUB — generated from node "Assess"'
65
+ )
66
+ assert.equal(
67
+ (graph.nodes['notify'] as { notes?: string }).notes,
68
+ undefined
69
+ )
70
+ } finally {
71
+ await rm(rootDir, { recursive: true, force: true })
72
+ }
73
+ })
74
+ })
@@ -12,6 +12,11 @@ function extractAstValue(
12
12
  refParamName: string,
13
13
  templateParamName: string | undefined
14
14
  ): unknown {
15
+ // `'GET' as const` / parenthesised values wrap the real initializer — see
16
+ // through them or the whole property is silently dropped from the meta.
17
+ while (ts.isAsExpression(expr) || ts.isParenthesizedExpression(expr)) {
18
+ expr = expr.expression
19
+ }
15
20
  if (ts.isStringLiteral(expr)) {
16
21
  return expr.text
17
22
  }
@@ -146,10 +151,14 @@ function extractInputMapping(
146
151
  ? node.parameters[0].name.text
147
152
  : 'ref'
148
153
 
154
+ // The canonical form imports `template` from '@pikku/core/workflow' and calls
155
+ // it inside a single-param `(ref) => (...)` arrow; the older form passes it as
156
+ // a second arrow parameter. Support both — default to the imported name so a
157
+ // `template(...)` value is never dropped just because it isn't a 2nd param.
149
158
  const templateParamName =
150
159
  node.parameters.length > 1 && ts.isIdentifier(node.parameters[1].name)
151
160
  ? node.parameters[1].name.text
152
- : undefined
161
+ : 'template'
153
162
 
154
163
  const input: Record<string, unknown | DataRef> = {}
155
164
 
@@ -184,6 +193,13 @@ function extractNextConfig(
184
193
  node: ts.Node,
185
194
  _checker: ts.TypeChecker
186
195
  ): string | string[] | Record<string, string | string[]> | undefined {
196
+ // Key-based branch `next` (`{ key: [targets] }`) is authored with an `as any`
197
+ // cast because the graph's NextConfig can't narrow record targets to node-id
198
+ // literals; see through that (and parens) so the routing survives into the meta.
199
+ while (ts.isAsExpression(node) || ts.isParenthesizedExpression(node)) {
200
+ node = node.expression
201
+ }
202
+
187
203
  if (ts.isStringLiteral(node)) {
188
204
  return node.text
189
205
  }
@@ -254,6 +270,7 @@ interface PikkuWorkflowGraphExtract {
254
270
  name?: string
255
271
  description?: string
256
272
  tags?: string[]
273
+ notes?: string[]
257
274
  disabled?: true
258
275
  nodesNode?: ts.ObjectLiteralExpression
259
276
  configNode?: ts.ObjectLiteralExpression
@@ -270,6 +287,7 @@ function extractWorkflowGraphConfig(
270
287
  let name: string | undefined
271
288
  let description: string | undefined
272
289
  let tags: string[] | undefined
290
+ let notes: string[] | undefined
273
291
  let disabled: true | undefined
274
292
  let nodesNode: ts.ObjectLiteralExpression | undefined
275
293
  let configNode: ts.ObjectLiteralExpression | undefined
@@ -293,6 +311,13 @@ function extractWorkflowGraphConfig(
293
311
  tags = prop.initializer.elements
294
312
  .filter(ts.isStringLiteral)
295
313
  .map((el) => (el as ts.StringLiteral).text)
314
+ } else if (
315
+ propName === 'notes' &&
316
+ ts.isArrayLiteralExpression(prop.initializer)
317
+ ) {
318
+ notes = prop.initializer.elements
319
+ .filter(ts.isStringLiteral)
320
+ .map((el) => (el as ts.StringLiteral).text)
296
321
  } else if (
297
322
  propName === 'nodes' &&
298
323
  ts.isObjectLiteralExpression(prop.initializer)
@@ -306,7 +331,7 @@ function extractWorkflowGraphConfig(
306
331
  }
307
332
  }
308
333
 
309
- return { name, description, tags, disabled, nodesNode, configNode }
334
+ return { name, description, tags, notes, disabled, nodesNode, configNode }
310
335
  }
311
336
 
312
337
  /**
@@ -359,6 +384,7 @@ function extractGraphFromNewFormat(
359
384
  onError: undefined,
360
385
  retries: undefined,
361
386
  retryDelay: undefined,
387
+ notes: undefined,
362
388
  }
363
389
  }
364
390
 
@@ -386,6 +412,7 @@ function extractGraphFromNewFormat(
386
412
  nodes[nodeId].input = nodeConfig.input
387
413
  nodes[nodeId].retries = nodeConfig.retries
388
414
  nodes[nodeId].retryDelay = nodeConfig.retryDelay
415
+ nodes[nodeId].notes = nodeConfig.notes
389
416
  }
390
417
  }
391
418
  }
@@ -406,12 +433,14 @@ function extractNodeConfigFromObject(
406
433
  input: Record<string, any>
407
434
  retries: number | undefined
408
435
  retryDelay: string | number | undefined
436
+ notes: string | undefined
409
437
  } {
410
438
  let next: any = undefined
411
439
  let onError: any = undefined
412
440
  let input: Record<string, any> = {}
413
441
  let retries: number | undefined = undefined
414
442
  let retryDelay: string | number | undefined = undefined
443
+ let notes: string | undefined = undefined
415
444
 
416
445
  for (const prop of obj.properties) {
417
446
  if (!ts.isPropertyAssignment(prop) || !ts.isIdentifier(prop.name)) continue
@@ -434,10 +463,12 @@ function extractNodeConfigFromObject(
434
463
  } else if (ts.isStringLiteral(prop.initializer)) {
435
464
  retryDelay = prop.initializer.text
436
465
  }
466
+ } else if (propName === 'notes') {
467
+ notes = extractStringLiteral(prop.initializer, checker)
437
468
  }
438
469
  }
439
470
 
440
- return { next, onError, input, retries, retryDelay }
471
+ return { next, onError, input, retries, retryDelay, notes }
441
472
  }
442
473
 
443
474
  /**
@@ -526,6 +557,7 @@ export const addWorkflowGraph: AddWiring = (logger, node, checker, state) => {
526
557
  source: 'graph',
527
558
  description: graphConfig.description,
528
559
  tags: graphConfig.tags,
560
+ notes: graphConfig.notes,
529
561
  nodes: graphNodes,
530
562
  entryNodeIds,
531
563
  }