@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 +56 -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 +22 -3
- package/dist/source-file-cache.d.ts +11 -0
- package/dist/source-file-cache.js +54 -0
- package/dist/types.d.ts +38 -0
- package/dist/utils/filter-inspector-state.js +30 -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 +22 -2
- package/src/source-file-cache.ts +73 -0
- package/src/types.ts +39 -0
- package/src/utils/filter-inspector-state.test.ts +142 -0
- package/src/utils/filter-inspector-state.ts +32 -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/dist/utils/check-secret-sinks.d.ts +0 -31
- package/dist/utils/check-secret-sinks.js +0 -118
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
|
-
|
|
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
|
@@ -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
|
|
275
|
-
options.oldProgram);
|
|
276
|
-
|
|
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;
|