@pikku/inspector 0.12.65 → 0.12.66

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,39 @@
1
+ ## 0.12.66
2
+
3
+ ### Patch Changes
4
+
5
+ - 2f94a0a: Stop `pikku all` failing on a diagnostic its own codegen went on to fix.
6
+
7
+ The run inspects several times, because generating the scaffold, the leaf indexes and the type files each changes the source graph the next inspection reads. Every full inspection re-runs every validator, so the newest pass is the complete one — but the logger accumulated diagnostics across all of them, and the build gate failed the run if any pass had ever recorded a critical.
8
+
9
+ A project whose system roles grant a scaffold-declared scope (`virtualUser:*`) therefore could not build from clean: the pass taken before the scaffold existed raised PKU124, the pass taken after was clean, and the run failed anyway. Same for PKU951 on a scaffold-declared secret.
10
+
11
+ Validation diagnostics are now scoped to the pass that recorded them, so a later pass supersedes an earlier one. Diagnostics reported outside a validation pass — by a generator or a command — are untouched, and a fault every pass reports still fails the run.
12
+
13
+ - 7eeff81: Record `rpc!.invoke(...)` as an invocation
14
+
15
+ `rpc` is optional on the wire, so a caller that knows it is there writes
16
+ `rpc!.invoke('someFunction', …)`. The inspector matched the receiver as a bare
17
+ identifier, but a non-null assertion is a node of its own wrapping that
18
+ identifier, so those call sites were never recorded.
19
+
20
+ That is silent rather than loud. An invocation is what puts a function that is
21
+ not `expose: true` into the runtime registry, so the target was left
22
+ unregistered and the dispatch failed with `Function not found` — at run time,
23
+ on a call site that type-checked.
24
+
25
+ The generated virtual-user scaffold dispatches exactly this way, which meant
26
+ `executeVirtualUserRun` was never registered: every virtual-user run sat at
27
+ `running` forever with nothing logged, because the scaffold's own
28
+ `.catch(() => {})` swallowed the rejection.
29
+
30
+ The receiver is now unwrapped through non-null assertions and parentheses
31
+ before it is matched.
32
+
33
+ - Updated dependencies [88629af]
34
+ - Updated dependencies [f1ccfe3]
35
+ - @pikku/core@0.12.96
36
+
1
37
  ## 0.12.65
2
38
 
3
39
  ### Patch Changes
@@ -13,6 +13,25 @@ function outerParent(node) {
13
13
  function findCastArg(args) {
14
14
  return args.find(hasTypeCast);
15
15
  }
16
+ /**
17
+ * The `rpc` in `rpc.invoke(...)`, seen through whatever the author wrote to
18
+ * satisfy the type checker.
19
+ *
20
+ * `rpc` is optional on the wire, so a caller that knows it is there reaches for
21
+ * `rpc!.invoke(...)` — and a non-null assertion is a node of its own, not the
22
+ * identifier underneath it. Matching only the bare identifier meant those calls
23
+ * were never recorded as invocations, which is silent rather than loud: the
24
+ * target is not `expose: true`, so nothing else puts it in the registry, and
25
+ * the dispatch fails with "Function not found" at run time on a call site that
26
+ * type-checked. Parentheses are unwrapped for the same reason.
27
+ */
28
+ function invocationReceiver(node) {
29
+ let inner = node;
30
+ while (ts.isNonNullExpression(inner) || ts.isParenthesizedExpression(inner)) {
31
+ inner = inner.expression;
32
+ }
33
+ return inner;
34
+ }
16
35
  /**
17
36
  * Helper to extract namespace from a namespaced function reference like 'ext:hello'
18
37
  */
@@ -62,10 +81,14 @@ export function addRPCInvocations(node, state, logger) {
62
81
  }
63
82
  }
64
83
  // Check for rpc.invoke('...') calls
84
+ const invokeReceiver = ts.isPropertyAccessExpression(expression)
85
+ ? invocationReceiver(expression.expression)
86
+ : undefined;
65
87
  if (ts.isPropertyAccessExpression(expression) &&
66
88
  expression.name.text === 'invoke' &&
67
- ts.isIdentifier(expression.expression) &&
68
- expression.expression.text === 'rpc') {
89
+ invokeReceiver &&
90
+ ts.isIdentifier(invokeReceiver) &&
91
+ invokeReceiver.text === 'rpc') {
69
92
  // Skip PKU940 for generated files — they may contain intentional casts
70
93
  // (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
71
94
  const sourceFileName = node.getSourceFile().fileName;
package/dist/inspector.js CHANGED
@@ -371,6 +371,10 @@ export const inspect = async (logger, routeFiles, options = {}) => {
371
371
  state.filesAndMethodsErrors = errors;
372
372
  logger.debug(`Get files and methods completed in ${(performance.now() - startFilesAndMethods).toFixed(2)}ms`);
373
373
  if (!options.setupOnly) {
374
+ // Everything below re-derives from the state this pass just built, so it
375
+ // supersedes whatever an earlier pass concluded about a source graph the
376
+ // run has since finished generating into.
377
+ logger.beginValidationPass?.();
374
378
  const startAggregate = performance.now();
375
379
  // Apply the inspected auth handler service set before aggregation so it
376
380
  // flows into requiredServices (the generated handler's own func is opaque).
@@ -408,6 +412,7 @@ export const inspect = async (logger, routeFiles, options = {}) => {
408
412
  validateScopeReferences(logger, state);
409
413
  validateSystemRoleScopes(logger, state);
410
414
  validatePersonaRoles(logger, state);
415
+ logger.endValidationPass?.();
411
416
  }
412
417
  state.program = program;
413
418
  return state;
package/dist/types.d.ts CHANGED
@@ -300,6 +300,24 @@ export interface InspectorLogger {
300
300
  /** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
301
301
  critical: (code: ErrorCode, message: string) => void;
302
302
  hasCriticalErrors: () => boolean;
303
+ /**
304
+ * Opens a validation pass, discarding whatever a previous one recorded.
305
+ *
306
+ * A single `pikku all` inspects several times, because generating the
307
+ * scaffold, the leaf indexes and the type files each changes the source graph
308
+ * the next inspection reads. The validators are pure functions of that graph
309
+ * and every full inspection re-runs all of them, so the newest pass is always
310
+ * the complete one and the ones before it are strictly superseded.
311
+ *
312
+ * Without this the diagnostics accumulated instead, and a run failed on a
313
+ * complaint about its own half-built input: a project whose roles reference
314
+ * scaffold-declared scopes was reported as failing even though the pass that
315
+ * ran once the scaffold existed was clean. Optional so a host supplying its
316
+ * own logger keeps working — one that omits it simply never discards.
317
+ */
318
+ beginValidationPass?: () => void;
319
+ /** Closes the pass opened by `beginValidationPass`. */
320
+ endValidationPass?: () => void;
303
321
  }
304
322
  export type AddWiring = (logger: InspectorLogger, node: ts.Node, checker: ts.TypeChecker, state: InspectorState, options: InspectorOptions) => void;
305
323
  export interface InspectorFilesAndMethods {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.65",
3
+ "version": "0.12.66",
4
4
  "description": "TypeScript inspector for Pikku — discovers functions and wirings to drive code generation",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "BUSL-1.1",
@@ -45,10 +45,10 @@
45
45
  "zod-to-ts": "^2.1.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@pikku/core": "^0.12.95"
48
+ "@pikku/core": "^0.12.96"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.95",
51
+ "@pikku/core": "^0.12.96",
52
52
  "@types/node": "^24.13.3"
53
53
  },
54
54
  "engines": {
@@ -110,4 +110,54 @@ export const routes = { stream: { func: ref('console:streamWorkflowRun') } }
110
110
  await rm(dir, { recursive: true, force: true })
111
111
  }
112
112
  })
113
+
114
+ test('a non-null assertion on the receiver is still an invocation', async () => {
115
+ const { state, dir } = await inspectFiles({
116
+ 'caller.ts': `
117
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> } | undefined
118
+ export async function doWork() {
119
+ return rpc!.invoke('executeVirtualUserRun')
120
+ }
121
+ `,
122
+ })
123
+ try {
124
+ assert.ok(state.rpc.invokedFunctions.has('executeVirtualUserRun'))
125
+ const invoked = [...state.rpc.invokedFunctionsByFile.values()][0]!
126
+ assert.deepStrictEqual([...invoked], ['executeVirtualUserRun'])
127
+ } finally {
128
+ await rm(dir, { recursive: true, force: true })
129
+ }
130
+ })
131
+
132
+ test('a parenthesised receiver is still an invocation', async () => {
133
+ const { state, dir } = await inspectFiles({
134
+ 'caller.ts': `
135
+ declare const rpc: { invoke: (name: string, data?: unknown) => Promise<unknown> } | undefined
136
+ export async function doWork() {
137
+ return (rpc!).invoke('listTasks')
138
+ }
139
+ `,
140
+ })
141
+ try {
142
+ assert.ok(state.rpc.invokedFunctions.has('listTasks'))
143
+ } finally {
144
+ await rm(dir, { recursive: true, force: true })
145
+ }
146
+ })
147
+
148
+ test('invoke on some other receiver is left alone', async () => {
149
+ const { state, dir } = await inspectFiles({
150
+ 'caller.ts': `
151
+ declare const client: { invoke: (name: string, data?: unknown) => Promise<unknown> } | undefined
152
+ export async function doWork() {
153
+ return client!.invoke('notAnRpc')
154
+ }
155
+ `,
156
+ })
157
+ try {
158
+ assert.ok(!state.rpc.invokedFunctions.has('notAnRpc'))
159
+ } finally {
160
+ await rm(dir, { recursive: true, force: true })
161
+ }
162
+ })
113
163
  })
@@ -20,6 +20,26 @@ function findCastArg(
20
20
  return args.find(hasTypeCast)
21
21
  }
22
22
 
23
+ /**
24
+ * The `rpc` in `rpc.invoke(...)`, seen through whatever the author wrote to
25
+ * satisfy the type checker.
26
+ *
27
+ * `rpc` is optional on the wire, so a caller that knows it is there reaches for
28
+ * `rpc!.invoke(...)` — and a non-null assertion is a node of its own, not the
29
+ * identifier underneath it. Matching only the bare identifier meant those calls
30
+ * were never recorded as invocations, which is silent rather than loud: the
31
+ * target is not `expose: true`, so nothing else puts it in the registry, and
32
+ * the dispatch fails with "Function not found" at run time on a call site that
33
+ * type-checked. Parentheses are unwrapped for the same reason.
34
+ */
35
+ function invocationReceiver(node: ts.Expression): ts.Expression {
36
+ let inner = node
37
+ while (ts.isNonNullExpression(inner) || ts.isParenthesizedExpression(inner)) {
38
+ inner = inner.expression
39
+ }
40
+ return inner
41
+ }
42
+
23
43
  /**
24
44
  * Helper to extract namespace from a namespaced function reference like 'ext:hello'
25
45
  */
@@ -80,11 +100,15 @@ export function addRPCInvocations(
80
100
  }
81
101
 
82
102
  // Check for rpc.invoke('...') calls
103
+ const invokeReceiver = ts.isPropertyAccessExpression(expression)
104
+ ? invocationReceiver(expression.expression)
105
+ : undefined
83
106
  if (
84
107
  ts.isPropertyAccessExpression(expression) &&
85
108
  expression.name.text === 'invoke' &&
86
- ts.isIdentifier(expression.expression) &&
87
- expression.expression.text === 'rpc'
109
+ invokeReceiver &&
110
+ ts.isIdentifier(invokeReceiver) &&
111
+ invokeReceiver.text === 'rpc'
88
112
  ) {
89
113
  // Skip PKU940 for generated files — they may contain intentional casts
90
114
  // (e.g. the paginated useInfiniteQuery hook in pikku-react-query.gen.ts).
package/src/inspector.ts CHANGED
@@ -493,6 +493,10 @@ export const inspect = async (
493
493
  )
494
494
 
495
495
  if (!options.setupOnly) {
496
+ // Everything below re-derives from the state this pass just built, so it
497
+ // supersedes whatever an earlier pass concluded about a source graph the
498
+ // run has since finished generating into.
499
+ logger.beginValidationPass?.()
496
500
  const startAggregate = performance.now()
497
501
  // Apply the inspected auth handler service set before aggregation so it
498
502
  // flows into requiredServices (the generated handler's own func is opaque).
@@ -542,6 +546,7 @@ export const inspect = async (
542
546
  validateScopeReferences(logger, state)
543
547
  validateSystemRoleScopes(logger, state)
544
548
  validatePersonaRoles(logger, state)
549
+ logger.endValidationPass?.()
545
550
  }
546
551
 
547
552
  state.program = program
package/src/types.ts CHANGED
@@ -366,6 +366,24 @@ export interface InspectorLogger {
366
366
  /** Sugar for `diagnostic({ severity: 'critical', code, message })`. */
367
367
  critical: (code: ErrorCode, message: string) => void
368
368
  hasCriticalErrors: () => boolean
369
+ /**
370
+ * Opens a validation pass, discarding whatever a previous one recorded.
371
+ *
372
+ * A single `pikku all` inspects several times, because generating the
373
+ * scaffold, the leaf indexes and the type files each changes the source graph
374
+ * the next inspection reads. The validators are pure functions of that graph
375
+ * and every full inspection re-runs all of them, so the newest pass is always
376
+ * the complete one and the ones before it are strictly superseded.
377
+ *
378
+ * Without this the diagnostics accumulated instead, and a run failed on a
379
+ * complaint about its own half-built input: a project whose roles reference
380
+ * scaffold-declared scopes was reported as failing even though the pass that
381
+ * ran once the scaffold existed was clean. Optional so a host supplying its
382
+ * own logger keeps working — one that omits it simply never discards.
383
+ */
384
+ beginValidationPass?: () => void
385
+ /** Closes the pass opened by `beginValidationPass`. */
386
+ endValidationPass?: () => void
369
387
  }
370
388
 
371
389
  export type AddWiring = (