@pikku/inspector 0.12.70 → 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 +49 -0
- package/dist/add/add-file-with-factory.js +96 -14
- package/dist/add/add-rpc-invocations.js +48 -0
- package/dist/error-codes.d.ts +1 -0
- package/dist/error-codes.js +1 -0
- package/dist/inspector.js +3 -0
- package/dist/types.d.ts +12 -0
- package/dist/utils/filter-inspector-state.js +17 -0
- package/dist/utils/post-process.d.ts +10 -0
- package/dist/utils/post-process.js +87 -0
- package/dist/utils/serialize-inspector-state.d.ts +3 -0
- package/dist/utils/serialize-inspector-state.js +6 -0
- package/package.json +3 -3
- package/src/add/add-file-with-factory.test.ts +134 -0
- package/src/add/add-file-with-factory.ts +109 -17
- package/src/add/add-rpc-invocations.test.ts +124 -0
- package/src/add/add-rpc-invocations.ts +52 -0
- package/src/add/add-scenario-step.test.ts +61 -0
- package/src/error-codes.ts +1 -0
- package/src/inspector.ts +3 -0
- package/src/types.ts +12 -0
- package/src/utils/filter-inspector-state.test.ts +79 -0
- package/src/utils/filter-inspector-state.ts +19 -0
- package/src/utils/post-process.test.ts +42 -0
- package/src/utils/post-process.ts +110 -0
- package/src/utils/serialize-inspector-state.ts +17 -0
- package/tsconfig.tsbuildinfo +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
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
|
+
|
|
1
50
|
## 0.12.70
|
|
2
51
|
|
|
3
52
|
### 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
|
-
|
|
62
|
-
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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)
|
package/dist/error-codes.d.ts
CHANGED
|
@@ -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",
|
package/dist/error-codes.js
CHANGED
|
@@ -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;
|
|
@@ -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();
|
|
@@ -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.
|
|
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.
|
|
48
|
+
"@pikku/core": "^0.12.102"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
|
-
"@pikku/core": "^0.12.
|
|
51
|
+
"@pikku/core": "^0.12.102",
|
|
52
52
|
"@types/node": "^24.13.3"
|
|
53
53
|
},
|
|
54
54
|
"engines": {
|
|
@@ -0,0 +1,134 @@
|
|
|
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 { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
import {
|
|
10
|
+
serializeInspectorState,
|
|
11
|
+
deserializeInspectorState,
|
|
12
|
+
} from '../utils/serialize-inspector-state.js'
|
|
13
|
+
|
|
14
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
15
|
+
({
|
|
16
|
+
debug: () => {},
|
|
17
|
+
info: () => {},
|
|
18
|
+
warn: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
diagnostic: ({ severity, code, message }) => {
|
|
21
|
+
if (severity !== 'warn') {
|
|
22
|
+
criticals.push({ code, message })
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
critical: (code: ErrorCode, message: string) => {
|
|
26
|
+
criticals.push({ code, message })
|
|
27
|
+
},
|
|
28
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
29
|
+
}) satisfies InspectorLogger
|
|
30
|
+
|
|
31
|
+
const inspectSource = async (prefix: string, source: string) => {
|
|
32
|
+
const rootDir = await mkdtemp(join(tmpdir(), prefix))
|
|
33
|
+
const file = join(rootDir, 'services.ts')
|
|
34
|
+
await writeFile(file, source)
|
|
35
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
36
|
+
try {
|
|
37
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
38
|
+
return { state, criticals }
|
|
39
|
+
} finally {
|
|
40
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('pikkuAddonServices — what an addon takes from its parent', () => {
|
|
45
|
+
test('a name destructured in the body, not the parameter list, is still forwarded', async () => {
|
|
46
|
+
const { state } = await inspectSource(
|
|
47
|
+
'pikku-addon-body-destructure-',
|
|
48
|
+
[
|
|
49
|
+
"import { pikkuAddonServices } from '#pikku/addon/setup'",
|
|
50
|
+
'export const createSingletonServices = pikkuAddonServices(',
|
|
51
|
+
' async (_config, existingServices) => {',
|
|
52
|
+
' const { kysely, secrets } = existingServices',
|
|
53
|
+
' return { ...existingServices, stripeApi: makeStripe(secrets) }',
|
|
54
|
+
' }',
|
|
55
|
+
')',
|
|
56
|
+
].join('\n')
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
assert.ok(
|
|
60
|
+
state.addonRequiredParentServices.includes('kysely'),
|
|
61
|
+
'kysely is read off the parent bag and must be declared as required'
|
|
62
|
+
)
|
|
63
|
+
assert.deepEqual(state.addonCreatedServices, ['stripeApi'])
|
|
64
|
+
assert.equal(state.addonServicesFactorySeen, true)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
test('an object rest is a binding name, not a service', async () => {
|
|
68
|
+
const { state } = await inspectSource(
|
|
69
|
+
'pikku-addon-object-rest-',
|
|
70
|
+
[
|
|
71
|
+
"import { pikkuAddonServices } from '#pikku/addon/setup'",
|
|
72
|
+
'export const createSingletonServices = pikkuAddonServices(',
|
|
73
|
+
' async (_config, { kysely, ...parentServices }) => ({',
|
|
74
|
+
' ...parentServices,',
|
|
75
|
+
' kysely,',
|
|
76
|
+
' stripeApi: makeStripe(),',
|
|
77
|
+
' })',
|
|
78
|
+
')',
|
|
79
|
+
].join('\n')
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
assert.deepEqual(state.addonRequiredParentServices, ['kysely'])
|
|
83
|
+
assert.ok(
|
|
84
|
+
!state.addonRequiredParentServices.includes('parentServices'),
|
|
85
|
+
'a rest binding names the leftover bag, not a service the parent provides'
|
|
86
|
+
)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
test('a parenthesised return still yields its created services', async () => {
|
|
90
|
+
const { state } = await inspectSource(
|
|
91
|
+
'pikku-addon-parenthesised-return-',
|
|
92
|
+
[
|
|
93
|
+
"import { pikkuAddonServices } from '#pikku/addon/setup'",
|
|
94
|
+
'export const createSingletonServices = pikkuAddonServices(',
|
|
95
|
+
' async (_config, existingServices) => {',
|
|
96
|
+
' return ({ ...existingServices, stripeApi: makeStripe() })',
|
|
97
|
+
' }',
|
|
98
|
+
')',
|
|
99
|
+
].join('\n')
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
assert.deepEqual(state.addonCreatedServices, ['stripeApi'])
|
|
103
|
+
assert.ok(
|
|
104
|
+
!state.addonRequiredParentServices.includes('stripeApi'),
|
|
105
|
+
'a service the factory builds is not one the parent has to supply'
|
|
106
|
+
)
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
test('the addon contract survives a serialize/deserialize round trip', async () => {
|
|
110
|
+
const { state } = await inspectSource(
|
|
111
|
+
'pikku-addon-round-trip-',
|
|
112
|
+
[
|
|
113
|
+
"import { pikkuAddonServices } from '#pikku/addon/setup'",
|
|
114
|
+
'export const createSingletonServices = pikkuAddonServices(',
|
|
115
|
+
' async (_config, existingServices) => {',
|
|
116
|
+
' const { kysely } = existingServices',
|
|
117
|
+
' return { ...existingServices, stripeApi: makeStripe(kysely) }',
|
|
118
|
+
' }',
|
|
119
|
+
')',
|
|
120
|
+
].join('\n')
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
const restored = deserializeInspectorState(
|
|
124
|
+
JSON.parse(JSON.stringify(serializeInspectorState(state)))
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
assert.deepEqual(
|
|
128
|
+
restored.addonRequiredParentServices,
|
|
129
|
+
state.addonRequiredParentServices
|
|
130
|
+
)
|
|
131
|
+
assert.deepEqual(restored.addonCreatedServices, ['stripeApi'])
|
|
132
|
+
assert.equal(restored.addonServicesFactorySeen, true)
|
|
133
|
+
})
|
|
134
|
+
})
|