@pikku/inspector 0.12.69 → 0.12.72

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,60 @@
1
+ ## 0.12.72
2
+
3
+ ### Patch Changes
4
+
5
+ - 202cb72: Refuse a scenario step whose prose opens with its own actor (PKU681). The
6
+ reporter already renders the actor as the sentence's subject, so a step
7
+ authored as `'sam' creates the client` and run as `{ actor: actors.sam }`
8
+ reads "Given sam 'sam' creates the client" — and the hardcoded name desyncs
9
+ the moment the call site changes actor. Only the subject position is refused:
10
+ naming someone else mid-sentence, or an actor keyed after a role noun used as
11
+ a noun ("creates the admin client"), is left alone.
12
+
13
+ ## 0.12.71
14
+
15
+ ### Patch Changes
16
+
17
+ - 9015400: Derive an addon's required parent services from what its functions actually use.
18
+
19
+ An addon's functions are its published surface — a consumer calls them, nothing
20
+ wires them locally — so `usedFunctions` was empty for an addon's own build and no
21
+ function services were ever aggregated. The generated services map came out
22
+ claiming the addon needed nothing beyond the framework defaults, and
23
+ `requiredParentServices` was derived only from the second parameter of
24
+ `pikkuAddonServices`, which names what the factory reads rather than what its
25
+ functions destructure.
26
+
27
+ The visible cost was an addon that consumed `kysely` in all fifteen of its
28
+ functions and declared it nowhere: the consuming project was never told to supply
29
+ it, the addon lost it at runtime, and every query failed on the first call. The
30
+ only way out was to list the missing names under `forceRequiredServices`, which
31
+ is a detection escape hatch and was never meant to carry a contract.
32
+
33
+ An addon build now aggregates services from every declared function, and treats
34
+ as parent-provided whatever it uses but does not build itself. What the factory
35
+ builds is read from the object literals it returns, minus anything it took off
36
+ the parent bag first — a forwarded service is not a created one, which is how
37
+ `@pikku/addon-admin` returns `scopeService` and friends without claiming to own
38
+ them. Names destructured from the second parameter in the function body, rather
39
+ than in the parameter list, are picked up too.
40
+
41
+ - 8852a75: Register an agent invoked from a function body in the calling deployment unit.
42
+
43
+ `runAgent('houseAssistant', ...)` and `rpc.agent.run('houseAssistant', ...)` resolve against the in-process agent registry, but the deploy analyzer only ever put an agent's registration in its own `agent-*` unit. A function calling one landed in a separate unit whose bootstrap never registered it, so the deployed worker threw `AI agent not found: houseAssistant`.
44
+
45
+ The inspector now records a string-literal agent name passed to `runAgent` / `streamAgent` / `rpc.agent.run` / `rpc.agent.stream` in a function body under `agents.invokedAgentsByFile`, mirroring what it already does for `rpc.invoke` targets. The analyzer carries those names on the calling unit as `invokedAgents` and adds the `ai-model` / `ai-storage` service requirements, and per-unit codegen puts the agent — and its tools — into that unit's filter names so its wiring is generated there too. A dynamic (template-literal) agent name is warned about, as it is for `rpc.invoke`.
46
+
47
+ - Updated dependencies [f4e2e89]
48
+ - @pikku/core@0.12.102
49
+
50
+ ## 0.12.70
51
+
52
+ ### Patch Changes
53
+
54
+ - d64db40: Prune a filtered-out workflow's file entry, not just its meta.
55
+
56
+ `filterInspectorState` deleted a pruned workflow from `workflows.graphMeta` and `workflows.meta` but left it in `workflows.files`, which is what the wiring generator iterates. Per-unit deploy codegen therefore registered every workflow in the project into every unit, and — because a scenario is told apart from an app workflow by `graphMeta[name].source === 'scenario'`, which the same prune had just removed — each scenario was reclassified as an app workflow. That pulled scenario bodies, their steps, and whatever those import into deployed bundles; on Cloudflare a step reading a fixture off disk failed the whole publish with `No such module "node:fs"`.
57
+
1
58
  ## 0.12.69
2
59
 
3
60
  ### Patch Changes
@@ -10,6 +10,93 @@ const wrapperFunctionMap = {
10
10
  pikkuAddonWireServices: 'CreateWireServices',
11
11
  pikkuServerLifecycle: 'ServerLifecycle',
12
12
  };
13
+ /**
14
+ * What an addon's `pikkuAddonServices` factory takes from the parent: the names
15
+ * destructured off its second parameter, either in the parameter list or from a
16
+ * `const { … } = existingServices` in the body.
17
+ */
18
+ const extractForwardedServices = (functionNode) => {
19
+ const forwarded = [];
20
+ const secondParam = functionNode.parameters[1];
21
+ if (!secondParam)
22
+ return forwarded;
23
+ const collectBinding = (pattern) => {
24
+ for (const elem of pattern.elements) {
25
+ if (elem.dotDotDotToken)
26
+ continue;
27
+ const name = elem.propertyName && ts.isIdentifier(elem.propertyName)
28
+ ? elem.propertyName.text
29
+ : ts.isIdentifier(elem.name)
30
+ ? elem.name.text
31
+ : undefined;
32
+ if (name)
33
+ forwarded.push(name);
34
+ }
35
+ };
36
+ if (ts.isObjectBindingPattern(secondParam.name)) {
37
+ collectBinding(secondParam.name);
38
+ return forwarded;
39
+ }
40
+ if (!ts.isIdentifier(secondParam.name))
41
+ return forwarded;
42
+ const paramName = secondParam.name.text;
43
+ const body = functionNode.body;
44
+ if (!ts.isBlock(body))
45
+ return forwarded;
46
+ const visit = (node) => {
47
+ if (ts.isVariableDeclaration(node) &&
48
+ ts.isObjectBindingPattern(node.name) &&
49
+ node.initializer &&
50
+ ts.isIdentifier(node.initializer) &&
51
+ node.initializer.text === paramName) {
52
+ collectBinding(node.name);
53
+ }
54
+ ts.forEachChild(node, visit);
55
+ };
56
+ ts.forEachChild(body, visit);
57
+ return forwarded;
58
+ };
59
+ /**
60
+ * The service names the factory's returned object literals name. A spread is
61
+ * skipped — it carries the parent's bag through rather than naming anything —
62
+ * and a name the factory took off the parent is a forward, not a creation.
63
+ */
64
+ const extractReturnedServices = (functionNode) => {
65
+ const returned = [];
66
+ const collect = (expression) => {
67
+ while (ts.isParenthesizedExpression(expression)) {
68
+ expression = expression.expression;
69
+ }
70
+ if (!ts.isObjectLiteralExpression(expression))
71
+ return;
72
+ for (const property of expression.properties) {
73
+ if (ts.isSpreadAssignment(property))
74
+ continue;
75
+ const name = property.name;
76
+ if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name))) {
77
+ returned.push(name.text);
78
+ }
79
+ }
80
+ };
81
+ const body = functionNode.body;
82
+ if (!ts.isBlock(body)) {
83
+ collect(body);
84
+ return returned;
85
+ }
86
+ const visit = (node) => {
87
+ if (ts.isFunctionDeclaration(node) ||
88
+ ts.isFunctionExpression(node) ||
89
+ ts.isArrowFunction(node)) {
90
+ return;
91
+ }
92
+ if (ts.isReturnStatement(node) && node.expression) {
93
+ collect(node.expression);
94
+ }
95
+ ts.forEachChild(node, visit);
96
+ };
97
+ ts.forEachChild(body, visit);
98
+ return returned;
99
+ };
13
100
  export const addFileWithFactory = (node, checker, methods = new Map(), expectedTypeName, state) => {
14
101
  if (ts.isVariableDeclaration(node)) {
15
102
  const fileName = node.getSourceFile().fileName;
@@ -57,20 +144,15 @@ export const addFileWithFactory = (node, checker, methods = new Map(), expectedT
57
144
  }
58
145
  // Extract existing services an addon needs from the parent
59
146
  // (second parameter of pikkuAddonServices callback)
60
- if (wrapperFunctionName === 'pikkuAddonServices' &&
61
- functionNode &&
62
- functionNode.parameters.length >= 2) {
63
- const secondParam = functionNode.parameters[1];
64
- if (secondParam && ts.isObjectBindingPattern(secondParam.name)) {
65
- for (const elem of secondParam.name.elements) {
66
- const name = elem.propertyName && ts.isIdentifier(elem.propertyName)
67
- ? elem.propertyName.text
68
- : ts.isIdentifier(elem.name)
69
- ? elem.name.text
70
- : undefined;
71
- if (name) {
72
- state.addonRequiredParentServices.push(name);
73
- }
147
+ if (wrapperFunctionName === 'pikkuAddonServices' && functionNode) {
148
+ state.addonServicesFactorySeen = true;
149
+ const forwarded = new Set(extractForwardedServices(functionNode));
150
+ for (const name of forwarded) {
151
+ state.addonRequiredParentServices.push(name);
152
+ }
153
+ for (const name of extractReturnedServices(functionNode)) {
154
+ if (!forwarded.has(name)) {
155
+ state.addonCreatedServices.push(name);
74
156
  }
75
157
  }
76
158
  }
@@ -42,6 +42,33 @@ function extractNamespace(functionRef) {
42
42
  }
43
43
  return null;
44
44
  }
45
+ function agentInvocationName(expression) {
46
+ if (ts.isIdentifier(expression)) {
47
+ if (expression.text === 'runAgent')
48
+ return 'runAgent';
49
+ if (expression.text === 'streamAgent')
50
+ return 'streamAgent';
51
+ return null;
52
+ }
53
+ if (!ts.isPropertyAccessExpression(expression))
54
+ return null;
55
+ const method = expression.name.text;
56
+ if (method !== 'run' && method !== 'stream')
57
+ return null;
58
+ const agentReceiver = invocationReceiver(expression.expression);
59
+ if (!ts.isPropertyAccessExpression(agentReceiver) ||
60
+ agentReceiver.name.text !== 'agent') {
61
+ return null;
62
+ }
63
+ const rpcReceiver = invocationReceiver(agentReceiver.expression);
64
+ const isRpc = ts.isIdentifier(rpcReceiver)
65
+ ? rpcReceiver.text === 'rpc'
66
+ : ts.isPropertyAccessExpression(rpcReceiver) &&
67
+ rpcReceiver.name.text === 'rpc';
68
+ if (!isRpc)
69
+ return null;
70
+ return method === 'run' ? 'rpc.agent.run' : 'rpc.agent.stream';
71
+ }
45
72
  /**
46
73
  * Scan for rpc.invoke() calls to track which functions are actually being invoked
47
74
  * Also detects addon usage via:
@@ -80,6 +107,27 @@ export function addRPCInvocations(node, state, logger) {
80
107
  state.workflows.invokedWorkflows.add(workflowName);
81
108
  }
82
109
  }
110
+ const agentCall = agentInvocationName(expression);
111
+ if (agentCall) {
112
+ const [firstArg] = args;
113
+ if (firstArg && ts.isStringLiteral(firstArg)) {
114
+ const agentName = firstArg.text;
115
+ logger.debug(`• Found ${agentCall}() call: ${agentName}`);
116
+ const sourceFileName = node.getSourceFile().fileName;
117
+ let byFile = state.agents.invokedAgentsByFile.get(sourceFileName);
118
+ if (!byFile) {
119
+ byFile = new Set();
120
+ state.agents.invokedAgentsByFile.set(sourceFileName, byFile);
121
+ }
122
+ byFile.add(agentName);
123
+ }
124
+ else if (firstArg &&
125
+ (ts.isTemplateExpression(firstArg) ||
126
+ ts.isNoSubstitutionTemplateLiteral(firstArg))) {
127
+ logger.warn(`• Found dynamic agent invocation: ${firstArg.getText()}`);
128
+ logger.warn(`\tYou can only use string literals for agent names, with ' or " and not \``);
129
+ }
130
+ }
83
131
  // Check for rpc.invoke('...') calls
84
132
  const invokeReceiver = ts.isPropertyAccessExpression(expression)
85
133
  ? invocationReceiver(expression.expression)
@@ -24,6 +24,7 @@ export declare enum ErrorCode {
24
24
  SCENARIO_STEP_TARGET_NOT_STATIC = "PKU678",
25
25
  SCENARIO_NOT_EXTRACTABLE = "PKU679",
26
26
  SCENARIO_HAS_NO_ASSERTION = "PKU680",
27
+ SCENARIO_STEP_PROSE_NAMES_ACTOR = "PKU681",
27
28
  WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = "PKU600",
28
29
  INVALID_DSL_WORKFLOW = "PKU641",
29
30
  WORKFLOW_GRAPH_ADDON_NOT_WIRED = "PKU642",
@@ -26,6 +26,7 @@ export var ErrorCode;
26
26
  ErrorCode["SCENARIO_STEP_TARGET_NOT_STATIC"] = "PKU678";
27
27
  ErrorCode["SCENARIO_NOT_EXTRACTABLE"] = "PKU679";
28
28
  ErrorCode["SCENARIO_HAS_NO_ASSERTION"] = "PKU680";
29
+ ErrorCode["SCENARIO_STEP_PROSE_NAMES_ACTOR"] = "PKU681";
29
30
  ErrorCode["WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED"] = "PKU600";
30
31
  ErrorCode["INVALID_DSL_WORKFLOW"] = "PKU641";
31
32
  ErrorCode["WORKFLOW_GRAPH_ADDON_NOT_WIRED"] = "PKU642";
package/dist/inspector.js CHANGED
@@ -36,6 +36,8 @@ export function getInitialInspectorState(rootDir) {
36
36
  wireServicesFactories: new Map(),
37
37
  wireServicesMeta: new Map(),
38
38
  addonRequiredParentServices: [],
39
+ addonCreatedServices: [],
40
+ addonServicesFactorySeen: false,
39
41
  addonServerlessIncompatible: new Map(),
40
42
  configFactories: new Map(),
41
43
  serverLifecycleFactories: new Map(),
@@ -116,6 +118,7 @@ export function getInitialInspectorState(rootDir) {
116
118
  agents: {
117
119
  agentsMeta: {},
118
120
  files: new Map(),
121
+ invokedAgentsByFile: new Map(),
119
122
  },
120
123
  scorers: {
121
124
  scorersMeta: {},
package/dist/types.d.ts CHANGED
@@ -448,6 +448,8 @@ export interface InspectorState {
448
448
  wireServicesFactories: PathToNameAndType;
449
449
  wireServicesMeta: Map<string, string[]>;
450
450
  addonRequiredParentServices: string[];
451
+ addonCreatedServices: string[];
452
+ addonServicesFactorySeen: boolean;
451
453
  addonServerlessIncompatible: Map<string, string[]>;
452
454
  configFactories: PathToNameAndType;
453
455
  serverLifecycleFactories: PathToNameAndType;
@@ -571,6 +573,16 @@ export interface InspectorState {
571
573
  path: string;
572
574
  exportedName: string;
573
575
  }>;
576
+ /**
577
+ * Agent registry keys named by a `runAgent`/`rpc.agent.run` call in a
578
+ * function body, keyed by the file the call was written in.
579
+ *
580
+ * The lookup those calls perform is in-process, so the agent has to be
581
+ * registered in the same deployment unit as its caller. Without this the
582
+ * deploy analyzer only ever saw an agent as its own unit, and a caller in
583
+ * another unit failed at run time with "AI agent not found".
584
+ */
585
+ invokedAgentsByFile: Map<string, Set<string>>;
574
586
  };
575
587
  scorers: {
576
588
  scorersMeta: ScorerMeta;
@@ -2,6 +2,23 @@ import { parseVersionedId } from '@pikku/core/utils';
2
2
  import { makeContextBasedId } from './extract-function-name.js';
3
3
  import { aggregateRequiredServices } from './post-process.js';
4
4
  import { IncompatibleDeployTargetError, resolveDeployTarget, } from './resolve-deploy-target.js';
5
+ /**
6
+ * Drop a workflow from every map that describes it. `files` and `graphFiles`
7
+ * are what the wiring generator iterates, and `graphMeta` is what tells a
8
+ * scenario apart from an app workflow — leaving a pruned workflow's file entry
9
+ * behind registers it anyway, and a scenario whose graphMeta has gone reads as
10
+ * an app workflow, which is how scenario steps reached deployed bundles.
11
+ */
12
+ const pruneWorkflow = (workflows, name, pikkuFuncId) => {
13
+ delete workflows.graphMeta[name];
14
+ delete workflows.meta[name];
15
+ workflows.files.delete(name);
16
+ workflows.graphFiles.delete(name);
17
+ if (pikkuFuncId) {
18
+ workflows.files.delete(pikkuFuncId);
19
+ workflows.graphFiles.delete(pikkuFuncId);
20
+ }
21
+ };
5
22
  // Module-level Set to track warned groups across multiple filter calls
6
23
  const globalWarnedGroups = new Set();
7
24
  /**
@@ -334,6 +351,8 @@ export function filterInspectorState(state, filters, logger) {
334
351
  ...state.workflows,
335
352
  graphMeta: JSON.parse(JSON.stringify(state.workflows?.graphMeta ?? {})),
336
353
  meta: JSON.parse(JSON.stringify(state.workflows?.meta ?? {})),
354
+ files: new Map(state.workflows?.files ?? []),
355
+ graphFiles: new Map(state.workflows?.graphFiles ?? []),
337
356
  },
338
357
  channels: {
339
358
  ...state.channels,
@@ -366,6 +385,13 @@ export function filterInspectorState(state, filters, logger) {
366
385
  ...state.agents,
367
386
  agentsMeta: JSON.parse(JSON.stringify(state.agents?.agentsMeta ?? {})),
368
387
  files: new Map(),
388
+ invokedAgentsByFile: new Map([
389
+ ...(state.agents?.invokedAgentsByFile ??
390
+ new Map()),
391
+ ].map(([file, agents]) => [
392
+ file,
393
+ new Set(agents),
394
+ ])),
369
395
  },
370
396
  // Scorers carry through every filter. They are named by the agents that
371
397
  // survive it, and a deployment that quietly stopped grading would look
@@ -768,13 +794,11 @@ export function filterInspectorState(state, filters, logger) {
768
794
  const pikkuFuncId = graphMeta?.pikkuFuncId ?? workflowMeta?.pikkuFuncId;
769
795
  if (pikkuFuncId &&
770
796
  !filteredState.serviceAggregation.usedFunctions.has(pikkuFuncId)) {
771
- delete filteredState.workflows.graphMeta[name];
772
- delete filteredState.workflows.meta[name];
797
+ pruneWorkflow(filteredState.workflows, name, pikkuFuncId);
773
798
  }
774
799
  else if (!pikkuFuncId) {
775
800
  // No function ID found — prune it
776
- delete filteredState.workflows.graphMeta[name];
777
- delete filteredState.workflows.meta[name];
801
+ pruneWorkflow(filteredState.workflows, name, pikkuFuncId);
778
802
  }
779
803
  }
780
804
  // Prune RPC meta to only include entries whose target function survived
@@ -853,6 +877,16 @@ export function filterInspectorState(state, filters, logger) {
853
877
  if (filteredState.serviceAggregation.requiredServices.has('workflowService')) {
854
878
  filteredState.serviceAggregation.requiredServices.add('queueService');
855
879
  }
880
+ const keptFunctionFiles = new Set();
881
+ for (const file of filteredState.functions.files.values()) {
882
+ if (file?.path)
883
+ keptFunctionFiles.add(file.path);
884
+ }
885
+ for (const file of filteredState.agents.invokedAgentsByFile.keys()) {
886
+ if (!keptFunctionFiles.has(file)) {
887
+ filteredState.agents.invokedAgentsByFile.delete(file);
888
+ }
889
+ }
856
890
  if ((filteredState.rpc.wireAddonDeclarations?.size ?? 0) > 0) {
857
891
  const referencedIds = new Set(filteredState.serviceAggregation.usedFunctions);
858
892
  const keptFiles = new Set();
@@ -101,6 +101,16 @@ export declare function validateAgentModels(logger: InspectorLogger, state: Insp
101
101
  * the check read `undefined` there and never fired.
102
102
  */
103
103
  export declare function validateScenarioServices(logger: InspectorLogger, state: InspectorState | Omit<InspectorState, 'typesLookup'>): void;
104
+ /**
105
+ * Does this step's prose open by naming the actor who is already its subject?
106
+ *
107
+ * `composeStepProse` renders the actor as the sentence's subject, so a step
108
+ * authored as `'sam' creates the client` reads "Given sam 'sam' creates the
109
+ * client". Only the subject position counts: a persona keyed after a role noun
110
+ * ("admin") must still be able to say "creates the admin client", and naming
111
+ * someone mid-sentence — "sends sam an invite" — is ordinary prose.
112
+ */
113
+ export declare const proseOpensWithActor: (prose: string, actor: string) => boolean;
104
114
  /**
105
115
  * Walk every scenario step, wherever it is nested, and validate the two things
106
116
  * that can only be known once both the workflow meta and the function meta
@@ -313,6 +313,31 @@ export function aggregateRequiredServices(state) {
313
313
  requiredServices.add(service);
314
314
  }
315
315
  }
316
+ // 8. When this project IS an addon, what it needs from its parent is whatever
317
+ // its functions use minus whatever its own factory builds. Reading only the
318
+ // factory's second parameter missed everything the functions destructure and
319
+ // the factory never names — `kysely` above all — which left the contract empty
320
+ // and let a consumer omit it and fail at the first query instead of at build.
321
+ if (state.addonServicesFactorySeen) {
322
+ // Every function an addon declares is part of its published surface — a
323
+ // consumer calls it — so none of them are reachable from a local wiring and
324
+ // `usedFunctions` is empty. Aggregating only from wirings left the services
325
+ // map claiming an addon needs nothing, which is what drove addons to list
326
+ // their own services under `forceRequiredServices` by hand.
327
+ for (const funcMeta of Object.values(state.functions.meta)) {
328
+ addServices(funcMeta?.services);
329
+ }
330
+ const createdSet = new Set(state.addonCreatedServices ?? []);
331
+ const parentServices = new Set(parentDeclared);
332
+ for (const service of requiredServices) {
333
+ if (!createdSet.has(service) &&
334
+ !internalServices.has(service) &&
335
+ !defaultServices.has(service)) {
336
+ parentServices.add(service);
337
+ }
338
+ }
339
+ state.addonRequiredParentServices = [...parentServices];
340
+ }
316
341
  }
317
342
  export function validateSecretOverrides(logger, state) {
318
343
  const { wireAddonDeclarations } = state.rpc;
@@ -734,6 +759,67 @@ export function validateScenarioServices(logger, state) {
734
759
  }
735
760
  }
736
761
  }
762
+ /**
763
+ * Does this step's prose open by naming the actor who is already its subject?
764
+ *
765
+ * `composeStepProse` renders the actor as the sentence's subject, so a step
766
+ * authored as `'sam' creates the client` reads "Given sam 'sam' creates the
767
+ * client". Only the subject position counts: a persona keyed after a role noun
768
+ * ("admin") must still be able to say "creates the admin client", and naming
769
+ * someone mid-sentence — "sends sam an invite" — is ordinary prose.
770
+ */
771
+ export const proseOpensWithActor = (prose, actor) => {
772
+ if (!actor) {
773
+ return false;
774
+ }
775
+ const escaped = actor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
776
+ return new RegExp(`^["'“‘\\s]*${escaped}(?:['’]s)?\\b`, 'i').test(prose);
777
+ };
778
+ /**
779
+ * The prose that will actually render for one step, in the runtime's own
780
+ * precedence: a call-site description overrides the step's template, which
781
+ * overrides its declared description, which falls back to the durable name.
782
+ *
783
+ * Every candidate is returned rather than only the winner, because a template
784
+ * is authored where the actor is not yet known — so it is wrong at every call
785
+ * site that runs it as the persona it names, not just the winning one.
786
+ */
787
+ const renderableProse = (step, stepMeta) => {
788
+ const description = step.options?.description;
789
+ if (description) {
790
+ return [step.stepName, description];
791
+ }
792
+ return [
793
+ step.stepName,
794
+ stepMeta?.scenarioStepTemplate,
795
+ stepMeta?.description,
796
+ ].filter((prose) => typeof prose === 'string');
797
+ };
798
+ /**
799
+ * A step's prose must not open by naming the actor it already runs as (PKU681).
800
+ *
801
+ * The reporter renders the actor as the sentence's subject, so `'sam' creates
802
+ * the client` run as `actors.sam` reads "Given sam 'sam' creates the client" —
803
+ * and the hardcoded copy silently desyncs the moment the call site changes
804
+ * actor. Only the subject position is refused; naming someone mid-sentence is
805
+ * ordinary prose, and a persona keyed after a role noun can still say "creates
806
+ * the admin client".
807
+ */
808
+ const validateStepProse = (logger, workflowName, step, stepMeta) => {
809
+ const actor = step.actor;
810
+ if (!actor) {
811
+ return;
812
+ }
813
+ const offender = renderableProse(step, stepMeta).find((prose) => proseOpensWithActor(prose, actor));
814
+ if (offender === undefined) {
815
+ return;
816
+ }
817
+ logger.critical(ErrorCode.SCENARIO_STEP_PROSE_NAMES_ACTOR, `Scenario '${workflowName}' names its actor in the step prose: '${offender}' runs as '${actor}'. ` +
818
+ `The reporter already renders the actor as the subject, so this reads ` +
819
+ `"${capitaliseFirst(step.phase)} ${actor} ${offender}". Drop the name and let ` +
820
+ `{ actor: actors.${actor} } supply it — the step reads as a bare third-person predicate.`);
821
+ };
822
+ const capitaliseFirst = (value) => value.charAt(0).toUpperCase() + value.slice(1);
737
823
  /**
738
824
  * Walk every scenario step, wherever it is nested, and validate the two things
739
825
  * that can only be known once both the workflow meta and the function meta
@@ -765,6 +851,7 @@ export function validateScenarioSteps(logger, state) {
765
851
  }
766
852
  state.serviceAggregation.usedFunctions.add(step.stepFunc);
767
853
  const stepMeta = state.functions.meta[step.stepFunc];
854
+ validateStepProse(logger, workflowName, step, stepMeta);
768
855
  if (stepMeta?.scenarioStepRequiresActor && !step.actor) {
769
856
  const why = stepMeta.scenarioStepSurfaces?.includes('browser')
770
857
  ? 'declares a browser binding'
@@ -57,6 +57,8 @@ export interface SerializableInspectorState {
57
57
  ]>;
58
58
  wireServicesMeta: Array<[string, string[]]>;
59
59
  addonRequiredParentServices: string[];
60
+ addonCreatedServices: string[];
61
+ addonServicesFactorySeen: boolean;
60
62
  addonServerlessIncompatible: Array<[string, string[]]>;
61
63
  configFactories: Array<[
62
64
  string,
@@ -210,6 +212,7 @@ export interface SerializableInspectorState {
210
212
  files: string[];
211
213
  };
212
214
  agents: {
215
+ invokedAgentsByFile?: Array<[string, string[]]>;
213
216
  agentsMeta: InspectorState['agents']['agentsMeta'];
214
217
  files: [string, {
215
218
  path: string;
@@ -24,6 +24,8 @@ export function serializeInspectorState(state) {
24
24
  wireServicesFactories: Array.from(state.wireServicesFactories.entries()),
25
25
  wireServicesMeta: Array.from(state.wireServicesMeta.entries()),
26
26
  addonRequiredParentServices: state.addonRequiredParentServices,
27
+ addonCreatedServices: state.addonCreatedServices,
28
+ addonServicesFactorySeen: state.addonServicesFactorySeen,
27
29
  addonServerlessIncompatible: Array.from(state.addonServerlessIncompatible.entries()),
28
30
  configFactories: Array.from(state.configFactories.entries()),
29
31
  serverLifecycleFactories: Array.from(state.serverLifecycleFactories.entries()),
@@ -94,6 +96,7 @@ export function serializeInspectorState(state) {
94
96
  agents: {
95
97
  agentsMeta: state.agents?.agentsMeta ?? {},
96
98
  files: Array.from(state.agents?.files?.entries() ?? []),
99
+ invokedAgentsByFile: Array.from((state.agents?.invokedAgentsByFile ?? new Map()).entries()).map(([file, agents]) => [file, Array.from(agents)]),
97
100
  },
98
101
  scorers: {
99
102
  scorersMeta: state.scorers?.scorersMeta ?? {},
@@ -198,6 +201,8 @@ export function deserializeInspectorState(data) {
198
201
  wireServicesFactories: new Map(data.wireServicesFactories),
199
202
  wireServicesMeta: new Map(data.wireServicesMeta),
200
203
  addonRequiredParentServices: data.addonRequiredParentServices || [],
204
+ addonCreatedServices: data.addonCreatedServices || [],
205
+ addonServicesFactorySeen: data.addonServicesFactorySeen ?? false,
201
206
  addonServerlessIncompatible: new Map(data.addonServerlessIncompatible || []),
202
207
  configFactories: new Map(data.configFactories),
203
208
  serverLifecycleFactories: new Map(data.serverLifecycleFactories),
@@ -272,6 +277,7 @@ export function deserializeInspectorState(data) {
272
277
  agents: {
273
278
  agentsMeta: data.agents?.agentsMeta || {},
274
279
  files: new Map(data.agents?.files || []),
280
+ invokedAgentsByFile: new Map((data.agents?.invokedAgentsByFile || []).map(([file, agents]) => [file, new Set(agents)])),
275
281
  },
276
282
  scorers: {
277
283
  scorersMeta: data.scorers?.scorersMeta || {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/inspector",
3
- "version": "0.12.69",
3
+ "version": "0.12.72",
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.100"
48
+ "@pikku/core": "^0.12.102"
49
49
  },
50
50
  "devDependencies": {
51
- "@pikku/core": "^0.12.100",
51
+ "@pikku/core": "^0.12.102",
52
52
  "@types/node": "^24.13.3"
53
53
  },
54
54
  "engines": {