@pikku/inspector 0.12.70 → 0.12.73

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,59 @@
1
+ ## 0.12.73
2
+
3
+ ### Patch Changes
4
+
5
+ - 6519c66: Report what each inspector pass did, not only how long it took. `inspect()` now records per-pass work counters on the state (`stats`: files in the program, files reused from the previous program, type/instantiation/symbol counts, CPU and wall time, heap in use), and `pikku all` prints one `[INSPECT]` row per pass under `PIKKU_TIMING`. The codegen benchmark gates on those counts and on live heap per pass instead of on step timings, which swing with the runner, and its fixture now includes an agent so the post-agent re-inspection is actually exercised.
6
+ - 5447dba: Keep parsed source files across inspector passes. A re-inspection in `pikku all` used to re-parse and re-bind every file, because the previous `ts.Program` — the thing TypeScript reuses parses from — had to be dropped to free its type checker. The inspector now builds each program through a compiler host with a content-hashed `SourceFile` cache, so a re-inspection parses only what codegen just wrote while holding no checker: on the 500-function benchmark the re-inspection reuses all 908 files, its CPU time falls by a third, and peak heap drops.
7
+
8
+ ## 0.12.72
9
+
10
+ ### Patch Changes
11
+
12
+ - 202cb72: Refuse a scenario step whose prose opens with its own actor (PKU681). The
13
+ reporter already renders the actor as the sentence's subject, so a step
14
+ authored as `'sam' creates the client` and run as `{ actor: actors.sam }`
15
+ reads "Given sam 'sam' creates the client" — and the hardcoded name desyncs
16
+ the moment the call site changes actor. Only the subject position is refused:
17
+ naming someone else mid-sentence, or an actor keyed after a role noun used as
18
+ a noun ("creates the admin client"), is left alone.
19
+
20
+ ## 0.12.71
21
+
22
+ ### Patch Changes
23
+
24
+ - 9015400: Derive an addon's required parent services from what its functions actually use.
25
+
26
+ An addon's functions are its published surface — a consumer calls them, nothing
27
+ wires them locally — so `usedFunctions` was empty for an addon's own build and no
28
+ function services were ever aggregated. The generated services map came out
29
+ claiming the addon needed nothing beyond the framework defaults, and
30
+ `requiredParentServices` was derived only from the second parameter of
31
+ `pikkuAddonServices`, which names what the factory reads rather than what its
32
+ functions destructure.
33
+
34
+ The visible cost was an addon that consumed `kysely` in all fifteen of its
35
+ functions and declared it nowhere: the consuming project was never told to supply
36
+ it, the addon lost it at runtime, and every query failed on the first call. The
37
+ only way out was to list the missing names under `forceRequiredServices`, which
38
+ is a detection escape hatch and was never meant to carry a contract.
39
+
40
+ An addon build now aggregates services from every declared function, and treats
41
+ as parent-provided whatever it uses but does not build itself. What the factory
42
+ builds is read from the object literals it returns, minus anything it took off
43
+ the parent bag first — a forwarded service is not a created one, which is how
44
+ `@pikku/addon-admin` returns `scopeService` and friends without claiming to own
45
+ them. Names destructured from the second parameter in the function body, rather
46
+ than in the parameter list, are picked up too.
47
+
48
+ - 8852a75: Register an agent invoked from a function body in the calling deployment unit.
49
+
50
+ `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`.
51
+
52
+ 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`.
53
+
54
+ - Updated dependencies [f4e2e89]
55
+ - @pikku/core@0.12.102
56
+
1
57
  ## 0.12.70
2
58
 
3
59
  ### 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
@@ -5,6 +5,7 @@ import { visitSetup, visitFunctions, visitRoutes } from './visit.js';
5
5
  import { TypesMap } from './types-map.js';
6
6
  import { getFilesAndMethods } from './utils/get-files-and-methods.js';
7
7
  import { findCommonAncestor } from './utils/find-root-dir.js';
8
+ import { createSourceFileCacheHost } from './source-file-cache.js';
8
9
  import { createNestedProjectFilter } from './utils/nested-project-filter.js';
9
10
  import { aggregateRequiredServices, stampAuthHandlerServices, validateAgentModels, validateAgentToolReferences, validateAgentScorerReferences, validateSecretOverrides, validateVariableOverrides, validateCredentialOverrides, validateRemoteAddonDependencies, validateRemoteAddonAuth, validateScopeReferences, validateSystemRoleScopes, validatePersonaRoles, computeResolvedIOTypes, computeMiddlewareGroupsMeta, computePermissionsGroupsMeta, computeRequiredSchemas, validateNoSecretAliasServices, validateSecretUsage, computeDiagnostics, validateSchemaReferences, validateSchemaWiringSeparation, validateScenarioServices, validateScenarioSteps, validateWorkflowGraphAddons, } from './utils/post-process.js';
10
11
  import { validateExposedFunctionsGated } from './utils/validate-exposed-functions-gated.js';
@@ -36,6 +37,8 @@ export function getInitialInspectorState(rootDir) {
36
37
  wireServicesFactories: new Map(),
37
38
  wireServicesMeta: new Map(),
38
39
  addonRequiredParentServices: [],
40
+ addonCreatedServices: [],
41
+ addonServicesFactorySeen: false,
39
42
  addonServerlessIncompatible: new Map(),
40
43
  configFactories: new Map(),
41
44
  serverLifecycleFactories: new Map(),
@@ -116,6 +119,7 @@ export function getInitialInspectorState(rootDir) {
116
119
  agents: {
117
120
  agentsMeta: {},
118
121
  files: new Map(),
122
+ invokedAgentsByFile: new Map(),
119
123
  },
120
124
  scorers: {
121
125
  scorersMeta: {},
@@ -270,10 +274,13 @@ export const inspect = async (logger, routeFiles, options = {}) => {
270
274
  allowJs: false,
271
275
  checkJs: false,
272
276
  };
277
+ const cpuStart = process.cpuUsage();
273
278
  const startProgram = performance.now();
274
- const program = ts.createProgram(normalizedRouteFiles, compilerOptions, undefined, // host
275
- options.oldProgram);
276
- logger.debug(`Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files${options.oldProgram ? ', incremental' : ''})`);
279
+ const host = createSourceFileCacheHost(compilerOptions);
280
+ const program = ts.createProgram(normalizedRouteFiles, compilerOptions, host, options.oldProgram);
281
+ host.prune(program);
282
+ const filesReused = host.reused();
283
+ logger.debug(`Created program in ${(performance.now() - startProgram).toFixed(0)}ms (${normalizedRouteFiles.length} files, ${filesReused} source files reused)`);
277
284
  const startChecker = performance.now();
278
285
  const checker = program.getTypeChecker();
279
286
  logger.debug(`Got type checker in ${(performance.now() - startChecker).toFixed(2)}ms`);
@@ -414,6 +421,18 @@ export const inspect = async (logger, routeFiles, options = {}) => {
414
421
  validatePersonaRoles(logger, state);
415
422
  logger.endValidationPass?.();
416
423
  }
424
+ const cpu = process.cpuUsage(cpuStart);
425
+ state.stats = {
426
+ files: program.getSourceFiles().length,
427
+ projectFiles: sourceFiles.length,
428
+ filesReused,
429
+ types: program.getTypeCount(),
430
+ instantiations: program.getInstantiationCount(),
431
+ symbols: program.getSymbolCount(),
432
+ cpuMs: Math.round((cpu.user + cpu.system) / 1000),
433
+ wallMs: Math.round(performance.now() - startProgram),
434
+ heapUsedMb: Math.round(process.memoryUsage().heapUsed / 1048576),
435
+ };
417
436
  state.program = program;
418
437
  return state;
419
438
  };
@@ -0,0 +1,11 @@
1
+ import * as ts from 'typescript';
2
+ export interface SourceFileCacheHost extends ts.CompilerHost {
3
+ /** Files served from the cache by the program just built. */
4
+ readonly reused: () => number;
5
+ /**
6
+ * Drop entries the program did not use, so a file that left the project
7
+ * (or watch mode's churn) does not pin its AST forever.
8
+ */
9
+ readonly prune: (program: ts.Program) => void;
10
+ }
11
+ export declare const createSourceFileCacheHost: (options: ts.CompilerOptions) => SourceFileCacheHost;
@@ -0,0 +1,54 @@
1
+ import * as ts from 'typescript';
2
+ import { createHash } from 'crypto';
3
+ /**
4
+ * Parsed source files, kept across programs so a re-inspection only re-parses
5
+ * what changed. Keyed by path; an entry is valid while the file's content
6
+ * hash matches.
7
+ *
8
+ * This is the incremental reuse `oldProgram` used to give, without its cost.
9
+ * Passing the previous ts.Program keeps that program reachable, and a program
10
+ * memoises its type checker — so every re-inspection held a second, fully
11
+ * exercised checker for the sake of skipping a re-parse. A bare SourceFile
12
+ * carries its AST and binder symbols and nothing else; sharing it between
13
+ * programs is what the language service's document registry does.
14
+ *
15
+ * Module-level on purpose: `inspect()` is called several times per `pikku all`
16
+ * (and indefinitely in watch mode) from one process, and the cache is only
17
+ * worth having if it outlives the call.
18
+ */
19
+ const cache = new Map();
20
+ export const createSourceFileCacheHost = (options) => {
21
+ const host = ts.createCompilerHost(options, true);
22
+ let reused = 0;
23
+ host.getSourceFile = (fileName, languageVersionOrOptions, onError) => {
24
+ let text;
25
+ try {
26
+ text = host.readFile(fileName);
27
+ }
28
+ catch (e) {
29
+ onError?.(e instanceof Error ? e.message : String(e));
30
+ return undefined;
31
+ }
32
+ if (text === undefined)
33
+ return undefined;
34
+ const hash = createHash('sha1').update(text).digest('hex');
35
+ const hit = cache.get(fileName);
36
+ if (hit && hit.hash === hash) {
37
+ reused++;
38
+ return hit.file;
39
+ }
40
+ const file = ts.createSourceFile(fileName, text, languageVersionOrOptions, true);
41
+ cache.set(fileName, { hash, file });
42
+ return file;
43
+ };
44
+ return Object.assign(host, {
45
+ reused: () => reused,
46
+ prune: (program) => {
47
+ const live = new Set(program.getSourceFiles().map((sf) => sf.fileName));
48
+ for (const fileName of cache.keys()) {
49
+ if (!live.has(fileName))
50
+ cache.delete(fileName);
51
+ }
52
+ },
53
+ });
54
+ };
package/dist/types.d.ts CHANGED
@@ -223,6 +223,26 @@ export type AddonConfig = {
223
223
  secretOverrides?: Record<string, string>;
224
224
  forceInclude?: boolean;
225
225
  };
226
+ export interface InspectorPassStats {
227
+ /** Source files in the ts.Program, node_modules and lib included. */
228
+ files: number;
229
+ /** Files under rootDir the inspector actually swept. */
230
+ projectFiles: number;
231
+ /**
232
+ * Files served from the source-file cache without re-parsing. Zero on a
233
+ * first pass; on a re-inspection it should be nearly all of `files` — that
234
+ * reuse is what keeps a re-inspection cheaper than a cold one.
235
+ */
236
+ filesReused: number;
237
+ /** Checker work, as `tsc --extendedDiagnostics` counts it. */
238
+ types: number;
239
+ instantiations: number;
240
+ symbols: number;
241
+ cpuMs: number;
242
+ wallMs: number;
243
+ /** V8 heap in use when the pass finished, before any GC. */
244
+ heapUsedMb: number;
245
+ }
226
246
  export type InspectorOptions = Partial<{
227
247
  setupOnly: boolean;
228
248
  rootDir: string;
@@ -448,12 +468,20 @@ export interface InspectorState {
448
468
  wireServicesFactories: PathToNameAndType;
449
469
  wireServicesMeta: Map<string, string[]>;
450
470
  addonRequiredParentServices: string[];
471
+ addonCreatedServices: string[];
472
+ addonServicesFactorySeen: boolean;
451
473
  addonServerlessIncompatible: Map<string, string[]>;
452
474
  configFactories: PathToNameAndType;
453
475
  serverLifecycleFactories: PathToNameAndType;
454
476
  filesAndMethods: InspectorFilesAndMethods;
455
477
  filesAndMethodsErrors: Map<string, PathToNameAndType>;
456
478
  typesLookup: Map<string, ts.Type[]>;
479
+ /**
480
+ * What this pass cost, in counts rather than time where it can be counted:
481
+ * counts don't move with the machine, so they are what a perf gate can
482
+ * compare across runners (see benchmarks/bench-codegen-perf.ts).
483
+ */
484
+ stats?: InspectorPassStats;
457
485
  schemaLookup: Map<string, SchemaRef>;
458
486
  schemas: Record<string, JSONValue>;
459
487
  http: InspectorHTTPState;
@@ -571,6 +599,16 @@ export interface InspectorState {
571
599
  path: string;
572
600
  exportedName: string;
573
601
  }>;
602
+ /**
603
+ * Agent registry keys named by a `runAgent`/`rpc.agent.run` call in a
604
+ * function body, keyed by the file the call was written in.
605
+ *
606
+ * The lookup those calls perform is in-process, so the agent has to be
607
+ * registered in the same deployment unit as its caller. Without this the
608
+ * deploy analyzer only ever saw an agent as its own unit, and a caller in
609
+ * another unit failed at run time with "AI agent not found".
610
+ */
611
+ invokedAgentsByFile: Map<string, Set<string>>;
574
612
  };
575
613
  scorers: {
576
614
  scorersMeta: ScorerMeta;
@@ -385,6 +385,13 @@ export function filterInspectorState(state, filters, logger) {
385
385
  ...state.agents,
386
386
  agentsMeta: JSON.parse(JSON.stringify(state.agents?.agentsMeta ?? {})),
387
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
+ ])),
388
395
  },
389
396
  // Scorers carry through every filter. They are named by the agents that
390
397
  // survive it, and a deployment that quietly stopped grading would look
@@ -870,6 +877,16 @@ export function filterInspectorState(state, filters, logger) {
870
877
  if (filteredState.serviceAggregation.requiredServices.has('workflowService')) {
871
878
  filteredState.serviceAggregation.requiredServices.add('queueService');
872
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
+ }
873
890
  if ((filteredState.rpc.wireAddonDeclarations?.size ?? 0) > 0) {
874
891
  const referencedIds = new Set(filteredState.serviceAggregation.usedFunctions);
875
892
  const keptFiles = new Set();
@@ -897,8 +914,21 @@ export function filterInspectorState(state, filters, logger) {
897
914
  for (const tool of agentMeta.tools ?? [])
898
915
  referencedIds.add(tool);
899
916
  }
917
+ // A unit carrying the generic `/rpc/:rpcName` catch-all (the rpcCaller
918
+ // unit, and agent units) dispatches arbitrary RPCs at runtime via
919
+ // `rpc.exposed(name)`, including namespaced addon RPCs (e.g.
920
+ // `admin:createUser`). Those never appear in referencedIds because no
921
+ // app-local function statically references them, so keep every wired
922
+ // addon's declaration for such units — otherwise the per-unit bootstrap
923
+ // omits the addon package bootstrap and `rpc.exposed('admin:createUser')`
924
+ // throws RPCNotFoundError on the deployed stage.
925
+ const hasRpcCatchAll = (filters.names ?? []).includes('/rpc/:rpcName');
900
926
  const keptNamespaces = new Set();
901
927
  for (const namespace of filteredState.rpc.wireAddonDeclarations.keys()) {
928
+ if (hasRpcCatchAll) {
929
+ keptNamespaces.add(namespace);
930
+ continue;
931
+ }
902
932
  const prefix = `${namespace}:`;
903
933
  for (const id of referencedIds) {
904
934
  if (id.startsWith(prefix)) {
@@ -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;