@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
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
import { createHash } from 'crypto'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parsed source files, kept across programs so a re-inspection only re-parses
|
|
6
|
+
* what changed. Keyed by path; an entry is valid while the file's content
|
|
7
|
+
* hash matches.
|
|
8
|
+
*
|
|
9
|
+
* This is the incremental reuse `oldProgram` used to give, without its cost.
|
|
10
|
+
* Passing the previous ts.Program keeps that program reachable, and a program
|
|
11
|
+
* memoises its type checker — so every re-inspection held a second, fully
|
|
12
|
+
* exercised checker for the sake of skipping a re-parse. A bare SourceFile
|
|
13
|
+
* carries its AST and binder symbols and nothing else; sharing it between
|
|
14
|
+
* programs is what the language service's document registry does.
|
|
15
|
+
*
|
|
16
|
+
* Module-level on purpose: `inspect()` is called several times per `pikku all`
|
|
17
|
+
* (and indefinitely in watch mode) from one process, and the cache is only
|
|
18
|
+
* worth having if it outlives the call.
|
|
19
|
+
*/
|
|
20
|
+
const cache = new Map<string, { hash: string; file: ts.SourceFile }>()
|
|
21
|
+
|
|
22
|
+
export interface SourceFileCacheHost extends ts.CompilerHost {
|
|
23
|
+
/** Files served from the cache by the program just built. */
|
|
24
|
+
readonly reused: () => number
|
|
25
|
+
/**
|
|
26
|
+
* Drop entries the program did not use, so a file that left the project
|
|
27
|
+
* (or watch mode's churn) does not pin its AST forever.
|
|
28
|
+
*/
|
|
29
|
+
readonly prune: (program: ts.Program) => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const createSourceFileCacheHost = (
|
|
33
|
+
options: ts.CompilerOptions
|
|
34
|
+
): SourceFileCacheHost => {
|
|
35
|
+
const host = ts.createCompilerHost(options, true)
|
|
36
|
+
let reused = 0
|
|
37
|
+
|
|
38
|
+
host.getSourceFile = (fileName, languageVersionOrOptions, onError) => {
|
|
39
|
+
let text: string | undefined
|
|
40
|
+
try {
|
|
41
|
+
text = host.readFile(fileName)
|
|
42
|
+
} catch (e) {
|
|
43
|
+
onError?.(e instanceof Error ? e.message : String(e))
|
|
44
|
+
return undefined
|
|
45
|
+
}
|
|
46
|
+
if (text === undefined) return undefined
|
|
47
|
+
|
|
48
|
+
const hash = createHash('sha1').update(text).digest('hex')
|
|
49
|
+
const hit = cache.get(fileName)
|
|
50
|
+
if (hit && hit.hash === hash) {
|
|
51
|
+
reused++
|
|
52
|
+
return hit.file
|
|
53
|
+
}
|
|
54
|
+
const file = ts.createSourceFile(
|
|
55
|
+
fileName,
|
|
56
|
+
text,
|
|
57
|
+
languageVersionOrOptions,
|
|
58
|
+
true
|
|
59
|
+
)
|
|
60
|
+
cache.set(fileName, { hash, file })
|
|
61
|
+
return file
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return Object.assign(host, {
|
|
65
|
+
reused: () => reused,
|
|
66
|
+
prune: (program: ts.Program) => {
|
|
67
|
+
const live = new Set(program.getSourceFiles().map((sf) => sf.fileName))
|
|
68
|
+
for (const fileName of cache.keys()) {
|
|
69
|
+
if (!live.has(fileName)) cache.delete(fileName)
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -290,6 +290,27 @@ export type AddonConfig = {
|
|
|
290
290
|
forceInclude?: boolean
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
export interface InspectorPassStats {
|
|
294
|
+
/** Source files in the ts.Program, node_modules and lib included. */
|
|
295
|
+
files: number
|
|
296
|
+
/** Files under rootDir the inspector actually swept. */
|
|
297
|
+
projectFiles: number
|
|
298
|
+
/**
|
|
299
|
+
* Files served from the source-file cache without re-parsing. Zero on a
|
|
300
|
+
* first pass; on a re-inspection it should be nearly all of `files` — that
|
|
301
|
+
* reuse is what keeps a re-inspection cheaper than a cold one.
|
|
302
|
+
*/
|
|
303
|
+
filesReused: number
|
|
304
|
+
/** Checker work, as `tsc --extendedDiagnostics` counts it. */
|
|
305
|
+
types: number
|
|
306
|
+
instantiations: number
|
|
307
|
+
symbols: number
|
|
308
|
+
cpuMs: number
|
|
309
|
+
wallMs: number
|
|
310
|
+
/** V8 heap in use when the pass finished, before any GC. */
|
|
311
|
+
heapUsedMb: number
|
|
312
|
+
}
|
|
313
|
+
|
|
293
314
|
export type InspectorOptions = Partial<{
|
|
294
315
|
setupOnly: boolean
|
|
295
316
|
rootDir: string
|
|
@@ -526,12 +547,20 @@ export interface InspectorState {
|
|
|
526
547
|
wireServicesFactories: PathToNameAndType
|
|
527
548
|
wireServicesMeta: Map<string, string[]> // variable name -> singleton services consumed
|
|
528
549
|
addonRequiredParentServices: string[] // services an addon needs from the parent (extracted from pikkuAddonServices 2nd param)
|
|
550
|
+
addonCreatedServices: string[] // services an addon's own pikkuAddonServices factory builds itself
|
|
551
|
+
addonServicesFactorySeen: boolean // this project declares a pikkuAddonServices factory, i.e. it is an addon
|
|
529
552
|
addonServerlessIncompatible: Map<string, string[]> // namespace → service names that are serverless-incompatible (scoped per addon)
|
|
530
553
|
configFactories: PathToNameAndType
|
|
531
554
|
serverLifecycleFactories: PathToNameAndType
|
|
532
555
|
filesAndMethods: InspectorFilesAndMethods
|
|
533
556
|
filesAndMethodsErrors: Map<string, PathToNameAndType>
|
|
534
557
|
typesLookup: Map<string, ts.Type[]> // Lookup for types by name (e.g., function input types, Config type)
|
|
558
|
+
/**
|
|
559
|
+
* What this pass cost, in counts rather than time where it can be counted:
|
|
560
|
+
* counts don't move with the machine, so they are what a perf gate can
|
|
561
|
+
* compare across runners (see benchmarks/bench-codegen-perf.ts).
|
|
562
|
+
*/
|
|
563
|
+
stats?: InspectorPassStats
|
|
535
564
|
schemaLookup: Map<string, SchemaRef> // Lookup for schemas by name for deferred JSON Schema conversion (supports Standard Schema vendors)
|
|
536
565
|
schemas: Record<string, JSONValue>
|
|
537
566
|
http: InspectorHTTPState
|
|
@@ -634,6 +663,16 @@ export interface InspectorState {
|
|
|
634
663
|
agents: {
|
|
635
664
|
agentsMeta: AgentsMeta
|
|
636
665
|
files: Map<string, { path: string; exportedName: string }>
|
|
666
|
+
/**
|
|
667
|
+
* Agent registry keys named by a `runAgent`/`rpc.agent.run` call in a
|
|
668
|
+
* function body, keyed by the file the call was written in.
|
|
669
|
+
*
|
|
670
|
+
* The lookup those calls perform is in-process, so the agent has to be
|
|
671
|
+
* registered in the same deployment unit as its caller. Without this the
|
|
672
|
+
* deploy analyzer only ever saw an agent as its own unit, and a caller in
|
|
673
|
+
* another unit failed at run time with "AI agent not found".
|
|
674
|
+
*/
|
|
675
|
+
invokedAgentsByFile: Map<string, Set<string>>
|
|
637
676
|
}
|
|
638
677
|
scorers: {
|
|
639
678
|
scorersMeta: ScorerMeta
|
|
@@ -2063,3 +2063,145 @@ describe('Workflow file pruning', () => {
|
|
|
2063
2063
|
assert.equal(Object.keys(state.workflows.graphMeta).length, 2)
|
|
2064
2064
|
})
|
|
2065
2065
|
})
|
|
2066
|
+
|
|
2067
|
+
describe('invokedAgentsByFile', () => {
|
|
2068
|
+
test('round-trips through serialize/deserialize', () => {
|
|
2069
|
+
const state = getInitialInspectorState('/test/project')
|
|
2070
|
+
state.agents.invokedAgentsByFile = new Map([
|
|
2071
|
+
['/test/project/src/api/users.ts', new Set(['houseAssistant'])],
|
|
2072
|
+
[
|
|
2073
|
+
'/test/project/src/api/tasks.ts',
|
|
2074
|
+
new Set(['houseAssistant', 'gardenAssistant']),
|
|
2075
|
+
],
|
|
2076
|
+
])
|
|
2077
|
+
const restored = deserializeInspectorState(
|
|
2078
|
+
JSON.parse(JSON.stringify(serializeInspectorState(state as any)))
|
|
2079
|
+
)
|
|
2080
|
+
assert.ok(restored.agents.invokedAgentsByFile instanceof Map)
|
|
2081
|
+
assert.strictEqual(restored.agents.invokedAgentsByFile.size, 2)
|
|
2082
|
+
assert.deepStrictEqual(
|
|
2083
|
+
[
|
|
2084
|
+
...restored.agents.invokedAgentsByFile.get(
|
|
2085
|
+
'/test/project/src/api/tasks.ts'
|
|
2086
|
+
)!,
|
|
2087
|
+
].sort(),
|
|
2088
|
+
['gardenAssistant', 'houseAssistant']
|
|
2089
|
+
)
|
|
2090
|
+
})
|
|
2091
|
+
|
|
2092
|
+
test('deserializing legacy state without the field yields an empty Map', () => {
|
|
2093
|
+
const serialized = JSON.parse(
|
|
2094
|
+
JSON.stringify(
|
|
2095
|
+
serializeInspectorState(
|
|
2096
|
+
getInitialInspectorState('/test/project') as any
|
|
2097
|
+
)
|
|
2098
|
+
)
|
|
2099
|
+
)
|
|
2100
|
+
delete serialized.agents.invokedAgentsByFile
|
|
2101
|
+
const restored = deserializeInspectorState(serialized)
|
|
2102
|
+
assert.ok(restored.agents.invokedAgentsByFile instanceof Map)
|
|
2103
|
+
assert.strictEqual(restored.agents.invokedAgentsByFile.size, 0)
|
|
2104
|
+
})
|
|
2105
|
+
|
|
2106
|
+
test('the filtered map is a clone, not the original', () => {
|
|
2107
|
+
const state = createMockInspectorState()
|
|
2108
|
+
state.agents = {
|
|
2109
|
+
agentsMeta: {},
|
|
2110
|
+
files: new Map(),
|
|
2111
|
+
invokedAgentsByFile: new Map([
|
|
2112
|
+
['/test/project/src/api/users.ts', new Set(['houseAssistant'])],
|
|
2113
|
+
]),
|
|
2114
|
+
}
|
|
2115
|
+
const filtered = filterInspectorState(
|
|
2116
|
+
state,
|
|
2117
|
+
{ names: ['getUsers'] },
|
|
2118
|
+
mockLogger
|
|
2119
|
+
)
|
|
2120
|
+
filtered.agents.invokedAgentsByFile.clear()
|
|
2121
|
+
assert.strictEqual(state.agents.invokedAgentsByFile.size, 1)
|
|
2122
|
+
})
|
|
2123
|
+
|
|
2124
|
+
test('keeps the entry of a kept function file and prunes the rest', () => {
|
|
2125
|
+
const state = createMockInspectorState()
|
|
2126
|
+
state.agents = {
|
|
2127
|
+
agentsMeta: {},
|
|
2128
|
+
files: new Map(),
|
|
2129
|
+
invokedAgentsByFile: new Map([
|
|
2130
|
+
['/test/project/src/api/users.ts', new Set(['houseAssistant'])],
|
|
2131
|
+
['/test/project/src/admin/settings.ts', new Set(['adminAssistant'])],
|
|
2132
|
+
]),
|
|
2133
|
+
}
|
|
2134
|
+
const filtered = filterInspectorState(
|
|
2135
|
+
state,
|
|
2136
|
+
{ names: ['getUsers'] },
|
|
2137
|
+
mockLogger
|
|
2138
|
+
)
|
|
2139
|
+
assert.deepStrictEqual(
|
|
2140
|
+
[...filtered.agents.invokedAgentsByFile.keys()],
|
|
2141
|
+
['/test/project/src/api/users.ts']
|
|
2142
|
+
)
|
|
2143
|
+
})
|
|
2144
|
+
|
|
2145
|
+
describe('Wired addon retention', () => {
|
|
2146
|
+
// A unit that owns the generic `/rpc/:rpcName` catch-all (the rpcCaller
|
|
2147
|
+
// unit, and agent units) dispatches arbitrary RPCs at runtime via
|
|
2148
|
+
// `rpc.exposed(name)`, including namespaced addon RPCs like
|
|
2149
|
+
// `admin:createUser`. No app-local function statically references those
|
|
2150
|
+
// ids, so the catch-all must be enough to keep every wired addon's
|
|
2151
|
+
// declaration — otherwise the per-unit bootstrap omits the addon package
|
|
2152
|
+
// bootstrap and `rpc.exposed('admin:createUser')` throws RPCNotFoundError
|
|
2153
|
+
// on a deployed stage.
|
|
2154
|
+
const withAddons = () => {
|
|
2155
|
+
const state = createMockInspectorState()
|
|
2156
|
+
state.rpc.wireAddonDeclarations = new Map([
|
|
2157
|
+
['admin', { package: '@pikku/addon-admin' } as any],
|
|
2158
|
+
['console', { package: '@pikku/addon-console' } as any],
|
|
2159
|
+
])
|
|
2160
|
+
state.rpc.usedAddons = new Set(['admin', 'console'])
|
|
2161
|
+
return state
|
|
2162
|
+
}
|
|
2163
|
+
|
|
2164
|
+
test('keeps all wired addons for a unit with the /rpc/:rpcName catch-all', () => {
|
|
2165
|
+
const result = filterInspectorState(
|
|
2166
|
+
withAddons(),
|
|
2167
|
+
{ names: ['/rpc/:rpcName', 'http:post:/rpc/:rpcName', 'rpcCaller'] },
|
|
2168
|
+
mockLogger
|
|
2169
|
+
)
|
|
2170
|
+
assert.deepStrictEqual(
|
|
2171
|
+
[...result.rpc.wireAddonDeclarations.keys()].sort(),
|
|
2172
|
+
['admin', 'console']
|
|
2173
|
+
)
|
|
2174
|
+
assert.deepStrictEqual([...result.rpc.usedAddons].sort(), [
|
|
2175
|
+
'admin',
|
|
2176
|
+
'console',
|
|
2177
|
+
])
|
|
2178
|
+
})
|
|
2179
|
+
|
|
2180
|
+
test('drops wired addons for a unit without the catch-all that references no namespace', () => {
|
|
2181
|
+
const result = filterInspectorState(
|
|
2182
|
+
withAddons(),
|
|
2183
|
+
{ names: ['getUsers'] },
|
|
2184
|
+
mockLogger
|
|
2185
|
+
)
|
|
2186
|
+
assert.deepStrictEqual([...result.rpc.wireAddonDeclarations.keys()], [])
|
|
2187
|
+
assert.deepStrictEqual([...result.rpc.usedAddons], [])
|
|
2188
|
+
})
|
|
2189
|
+
|
|
2190
|
+
test('still keeps an addon a surviving function statically references, without the catch-all', () => {
|
|
2191
|
+
const state = withAddons()
|
|
2192
|
+
state.serviceAggregation.usedFunctions.add('getUsers')
|
|
2193
|
+
state.rpc.invokedFunctionsByFile = new Map([
|
|
2194
|
+
['/test/project/src/api/users.ts', new Set(['admin:createUser'])],
|
|
2195
|
+
])
|
|
2196
|
+
const result = filterInspectorState(
|
|
2197
|
+
state,
|
|
2198
|
+
{ names: ['getUsers'] },
|
|
2199
|
+
mockLogger
|
|
2200
|
+
)
|
|
2201
|
+
assert.deepStrictEqual(
|
|
2202
|
+
[...result.rpc.wireAddonDeclarations.keys()],
|
|
2203
|
+
['admin']
|
|
2204
|
+
)
|
|
2205
|
+
})
|
|
2206
|
+
})
|
|
2207
|
+
})
|
|
@@ -507,6 +507,15 @@ export function filterInspectorState(
|
|
|
507
507
|
...state.agents,
|
|
508
508
|
agentsMeta: JSON.parse(JSON.stringify(state.agents?.agentsMeta ?? {})),
|
|
509
509
|
files: new Map(),
|
|
510
|
+
invokedAgentsByFile: new Map(
|
|
511
|
+
[
|
|
512
|
+
...(state.agents?.invokedAgentsByFile ??
|
|
513
|
+
new Map<string, Set<string>>()),
|
|
514
|
+
].map(([file, agents]): [string, Set<string>] => [
|
|
515
|
+
file,
|
|
516
|
+
new Set(agents),
|
|
517
|
+
])
|
|
518
|
+
),
|
|
510
519
|
},
|
|
511
520
|
// Scorers carry through every filter. They are named by the agents that
|
|
512
521
|
// survive it, and a deployment that quietly stopped grading would look
|
|
@@ -1210,6 +1219,16 @@ export function filterInspectorState(
|
|
|
1210
1219
|
filteredState.serviceAggregation.requiredServices.add('queueService')
|
|
1211
1220
|
}
|
|
1212
1221
|
|
|
1222
|
+
const keptFunctionFiles = new Set<string>()
|
|
1223
|
+
for (const file of filteredState.functions.files.values()) {
|
|
1224
|
+
if (file?.path) keptFunctionFiles.add(file.path)
|
|
1225
|
+
}
|
|
1226
|
+
for (const file of filteredState.agents.invokedAgentsByFile.keys()) {
|
|
1227
|
+
if (!keptFunctionFiles.has(file)) {
|
|
1228
|
+
filteredState.agents.invokedAgentsByFile.delete(file)
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1213
1232
|
if ((filteredState.rpc.wireAddonDeclarations?.size ?? 0) > 0) {
|
|
1214
1233
|
const referencedIds = new Set<string>(
|
|
1215
1234
|
filteredState.serviceAggregation.usedFunctions
|
|
@@ -1239,8 +1258,21 @@ export function filterInspectorState(
|
|
|
1239
1258
|
) as Array<{ tools?: string[] }>) {
|
|
1240
1259
|
for (const tool of agentMeta.tools ?? []) referencedIds.add(tool)
|
|
1241
1260
|
}
|
|
1261
|
+
// A unit carrying the generic `/rpc/:rpcName` catch-all (the rpcCaller
|
|
1262
|
+
// unit, and agent units) dispatches arbitrary RPCs at runtime via
|
|
1263
|
+
// `rpc.exposed(name)`, including namespaced addon RPCs (e.g.
|
|
1264
|
+
// `admin:createUser`). Those never appear in referencedIds because no
|
|
1265
|
+
// app-local function statically references them, so keep every wired
|
|
1266
|
+
// addon's declaration for such units — otherwise the per-unit bootstrap
|
|
1267
|
+
// omits the addon package bootstrap and `rpc.exposed('admin:createUser')`
|
|
1268
|
+
// throws RPCNotFoundError on the deployed stage.
|
|
1269
|
+
const hasRpcCatchAll = (filters.names ?? []).includes('/rpc/:rpcName')
|
|
1242
1270
|
const keptNamespaces = new Set<string>()
|
|
1243
1271
|
for (const namespace of filteredState.rpc.wireAddonDeclarations.keys()) {
|
|
1272
|
+
if (hasRpcCatchAll) {
|
|
1273
|
+
keptNamespaces.add(namespace)
|
|
1274
|
+
continue
|
|
1275
|
+
}
|
|
1244
1276
|
const prefix = `${namespace}:`
|
|
1245
1277
|
for (const id of referencedIds) {
|
|
1246
1278
|
if (id.startsWith(prefix)) {
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
validateAgentToolReferences,
|
|
14
14
|
validateAgentModels,
|
|
15
15
|
validateSchemaReferences,
|
|
16
|
+
proseOpensWithActor,
|
|
16
17
|
} from './post-process.js'
|
|
17
18
|
import { ErrorCode } from '../error-codes.js'
|
|
18
19
|
import type { InspectorState, InspectorLogger } from '../types.js'
|
|
@@ -1050,3 +1051,44 @@ describe('validateSchemaReferences', () => {
|
|
|
1050
1051
|
assert.match(diagnostics[0]!.message, /2 function contracts name a schema/)
|
|
1051
1052
|
})
|
|
1052
1053
|
})
|
|
1054
|
+
|
|
1055
|
+
describe('proseOpensWithActor', () => {
|
|
1056
|
+
test('a quoted actor opening the prose doubles the subject', () => {
|
|
1057
|
+
assert.equal(
|
|
1058
|
+
proseOpensWithActor(`'sam' creates the browser-journey client`, 'sam'),
|
|
1059
|
+
true
|
|
1060
|
+
)
|
|
1061
|
+
})
|
|
1062
|
+
|
|
1063
|
+
test('quotes and case are decoration, not the point', () => {
|
|
1064
|
+
assert.equal(proseOpensWithActor('Sam creates the client', 'sam'), true)
|
|
1065
|
+
assert.equal(proseOpensWithActor('sam creates the client', 'sam'), true)
|
|
1066
|
+
assert.equal(proseOpensWithActor(`"sam" creates the client`, 'sam'), true)
|
|
1067
|
+
})
|
|
1068
|
+
|
|
1069
|
+
test("an actor's own possessive is still the doubled subject", () => {
|
|
1070
|
+
assert.equal(proseOpensWithActor("sam's client is created", 'sam'), true)
|
|
1071
|
+
})
|
|
1072
|
+
|
|
1073
|
+
test('naming someone mid-sentence is ordinary prose', () => {
|
|
1074
|
+
assert.equal(proseOpensWithActor('sends sam an invite', 'sam'), false)
|
|
1075
|
+
})
|
|
1076
|
+
|
|
1077
|
+
// A persona keyed after a role noun must keep the word as a word: refusing
|
|
1078
|
+
// "creates the admin client" for an actor called `admin` would make the
|
|
1079
|
+
// check the reason to rename the persona.
|
|
1080
|
+
test('an actor key that is also a common noun is only refused as the subject', () => {
|
|
1081
|
+
assert.equal(
|
|
1082
|
+
proseOpensWithActor('creates the admin client', 'admin'),
|
|
1083
|
+
false
|
|
1084
|
+
)
|
|
1085
|
+
assert.equal(proseOpensWithActor('admin creates the client', 'admin'), true)
|
|
1086
|
+
})
|
|
1087
|
+
|
|
1088
|
+
test('a longer name starting with the actor key is a different person', () => {
|
|
1089
|
+
assert.equal(
|
|
1090
|
+
proseOpensWithActor('samantha creates the client', 'sam'),
|
|
1091
|
+
false
|
|
1092
|
+
)
|
|
1093
|
+
})
|
|
1094
|
+
})
|
|
@@ -22,6 +22,7 @@ import { relative } from 'node:path'
|
|
|
22
22
|
import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
|
|
23
23
|
import { flattenScopeDefinitions } from '@pikku/core/scope'
|
|
24
24
|
import type { WorkflowStepMeta } from '@pikku/core/workflow'
|
|
25
|
+
import type { ScenarioStepMeta } from '@pikku/core/scenario'
|
|
25
26
|
import { DYNAMIC_SCENARIO_STEP_TARGET } from './workflow/dsl/patterns.js'
|
|
26
27
|
|
|
27
28
|
/**
|
|
@@ -381,6 +382,35 @@ export function aggregateRequiredServices(
|
|
|
381
382
|
requiredServices.add(service)
|
|
382
383
|
}
|
|
383
384
|
}
|
|
385
|
+
|
|
386
|
+
// 8. When this project IS an addon, what it needs from its parent is whatever
|
|
387
|
+
// its functions use minus whatever its own factory builds. Reading only the
|
|
388
|
+
// factory's second parameter missed everything the functions destructure and
|
|
389
|
+
// the factory never names — `kysely` above all — which left the contract empty
|
|
390
|
+
// and let a consumer omit it and fail at the first query instead of at build.
|
|
391
|
+
if (state.addonServicesFactorySeen) {
|
|
392
|
+
// Every function an addon declares is part of its published surface — a
|
|
393
|
+
// consumer calls it — so none of them are reachable from a local wiring and
|
|
394
|
+
// `usedFunctions` is empty. Aggregating only from wirings left the services
|
|
395
|
+
// map claiming an addon needs nothing, which is what drove addons to list
|
|
396
|
+
// their own services under `forceRequiredServices` by hand.
|
|
397
|
+
for (const funcMeta of Object.values(state.functions.meta)) {
|
|
398
|
+
addServices(funcMeta?.services)
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const createdSet = new Set(state.addonCreatedServices ?? [])
|
|
402
|
+
const parentServices = new Set(parentDeclared)
|
|
403
|
+
for (const service of requiredServices) {
|
|
404
|
+
if (
|
|
405
|
+
!createdSet.has(service) &&
|
|
406
|
+
!internalServices.has(service) &&
|
|
407
|
+
!defaultServices.has(service)
|
|
408
|
+
) {
|
|
409
|
+
parentServices.add(service)
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
state.addonRequiredParentServices = [...parentServices]
|
|
413
|
+
}
|
|
384
414
|
}
|
|
385
415
|
|
|
386
416
|
export function validateSecretOverrides(
|
|
@@ -911,6 +941,85 @@ export function validateScenarioServices(
|
|
|
911
941
|
}
|
|
912
942
|
}
|
|
913
943
|
|
|
944
|
+
/**
|
|
945
|
+
* Does this step's prose open by naming the actor who is already its subject?
|
|
946
|
+
*
|
|
947
|
+
* `composeStepProse` renders the actor as the sentence's subject, so a step
|
|
948
|
+
* authored as `'sam' creates the client` reads "Given sam 'sam' creates the
|
|
949
|
+
* client". Only the subject position counts: a persona keyed after a role noun
|
|
950
|
+
* ("admin") must still be able to say "creates the admin client", and naming
|
|
951
|
+
* someone mid-sentence — "sends sam an invite" — is ordinary prose.
|
|
952
|
+
*/
|
|
953
|
+
export const proseOpensWithActor = (prose: string, actor: string): boolean => {
|
|
954
|
+
if (!actor) {
|
|
955
|
+
return false
|
|
956
|
+
}
|
|
957
|
+
const escaped = actor.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
958
|
+
return new RegExp(`^["'“‘\\s]*${escaped}(?:['’]s)?\\b`, 'i').test(prose)
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* The prose that will actually render for one step, in the runtime's own
|
|
963
|
+
* precedence: a call-site description overrides the step's template, which
|
|
964
|
+
* overrides its declared description, which falls back to the durable name.
|
|
965
|
+
*
|
|
966
|
+
* Every candidate is returned rather than only the winner, because a template
|
|
967
|
+
* is authored where the actor is not yet known — so it is wrong at every call
|
|
968
|
+
* site that runs it as the persona it names, not just the winning one.
|
|
969
|
+
*/
|
|
970
|
+
const renderableProse = (
|
|
971
|
+
step: ScenarioStepMeta,
|
|
972
|
+
stepMeta: { description?: string; scenarioStepTemplate?: string } | undefined
|
|
973
|
+
): string[] => {
|
|
974
|
+
const description = step.options?.description
|
|
975
|
+
if (description) {
|
|
976
|
+
return [step.stepName, description]
|
|
977
|
+
}
|
|
978
|
+
return [
|
|
979
|
+
step.stepName,
|
|
980
|
+
stepMeta?.scenarioStepTemplate,
|
|
981
|
+
stepMeta?.description,
|
|
982
|
+
].filter((prose): prose is string => typeof prose === 'string')
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
/**
|
|
986
|
+
* A step's prose must not open by naming the actor it already runs as (PKU681).
|
|
987
|
+
*
|
|
988
|
+
* The reporter renders the actor as the sentence's subject, so `'sam' creates
|
|
989
|
+
* the client` run as `actors.sam` reads "Given sam 'sam' creates the client" —
|
|
990
|
+
* and the hardcoded copy silently desyncs the moment the call site changes
|
|
991
|
+
* actor. Only the subject position is refused; naming someone mid-sentence is
|
|
992
|
+
* ordinary prose, and a persona keyed after a role noun can still say "creates
|
|
993
|
+
* the admin client".
|
|
994
|
+
*/
|
|
995
|
+
const validateStepProse = (
|
|
996
|
+
logger: InspectorLogger,
|
|
997
|
+
workflowName: string,
|
|
998
|
+
step: ScenarioStepMeta,
|
|
999
|
+
stepMeta: { description?: string; scenarioStepTemplate?: string } | undefined
|
|
1000
|
+
): void => {
|
|
1001
|
+
const actor = step.actor
|
|
1002
|
+
if (!actor) {
|
|
1003
|
+
return
|
|
1004
|
+
}
|
|
1005
|
+
const offender = renderableProse(step, stepMeta).find((prose) =>
|
|
1006
|
+
proseOpensWithActor(prose, actor)
|
|
1007
|
+
)
|
|
1008
|
+
if (offender === undefined) {
|
|
1009
|
+
return
|
|
1010
|
+
}
|
|
1011
|
+
logger.critical(
|
|
1012
|
+
ErrorCode.SCENARIO_STEP_PROSE_NAMES_ACTOR,
|
|
1013
|
+
`Scenario '${workflowName}' names its actor in the step prose: '${offender}' runs as '${actor}'. ` +
|
|
1014
|
+
`The reporter already renders the actor as the subject, so this reads ` +
|
|
1015
|
+
`"${capitaliseFirst(step.phase)} ${actor} ${offender}". Drop the name and let ` +
|
|
1016
|
+
`{ actor: actors.${actor} } supply it — the step reads as a bare third-person predicate.`
|
|
1017
|
+
)
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
const capitaliseFirst = (value: string): string =>
|
|
1021
|
+
value.charAt(0).toUpperCase() + value.slice(1)
|
|
1022
|
+
|
|
914
1023
|
/**
|
|
915
1024
|
* Walk every scenario step, wherever it is nested, and validate the two things
|
|
916
1025
|
* that can only be known once both the workflow meta and the function meta
|
|
@@ -949,6 +1058,7 @@ export function validateScenarioSteps(
|
|
|
949
1058
|
}
|
|
950
1059
|
state.serviceAggregation.usedFunctions.add(step.stepFunc)
|
|
951
1060
|
const stepMeta = state.functions.meta[step.stepFunc]
|
|
1061
|
+
validateStepProse(logger, workflowName, step, stepMeta)
|
|
952
1062
|
if (stepMeta?.scenarioStepRequiresActor && !step.actor) {
|
|
953
1063
|
const why = stepMeta.scenarioStepSurfaces?.includes('browser')
|
|
954
1064
|
? 'declares a browser binding'
|
|
@@ -51,6 +51,8 @@ export interface SerializableInspectorState {
|
|
|
51
51
|
>
|
|
52
52
|
wireServicesMeta: Array<[string, string[]]>
|
|
53
53
|
addonRequiredParentServices: string[]
|
|
54
|
+
addonCreatedServices: string[]
|
|
55
|
+
addonServicesFactorySeen: boolean
|
|
54
56
|
addonServerlessIncompatible: Array<[string, string[]]>
|
|
55
57
|
configFactories: Array<
|
|
56
58
|
[
|
|
@@ -186,6 +188,7 @@ export interface SerializableInspectorState {
|
|
|
186
188
|
files: string[]
|
|
187
189
|
}
|
|
188
190
|
agents: {
|
|
191
|
+
invokedAgentsByFile?: Array<[string, string[]]>
|
|
189
192
|
agentsMeta: InspectorState['agents']['agentsMeta']
|
|
190
193
|
files: [string, { path: string; exportedName: string }][]
|
|
191
194
|
}
|
|
@@ -335,6 +338,8 @@ export function serializeInspectorState(
|
|
|
335
338
|
wireServicesFactories: Array.from(state.wireServicesFactories.entries()),
|
|
336
339
|
wireServicesMeta: Array.from(state.wireServicesMeta.entries()),
|
|
337
340
|
addonRequiredParentServices: state.addonRequiredParentServices,
|
|
341
|
+
addonCreatedServices: state.addonCreatedServices,
|
|
342
|
+
addonServicesFactorySeen: state.addonServicesFactorySeen,
|
|
338
343
|
addonServerlessIncompatible: Array.from(
|
|
339
344
|
state.addonServerlessIncompatible.entries()
|
|
340
345
|
),
|
|
@@ -417,6 +422,11 @@ export function serializeInspectorState(
|
|
|
417
422
|
agents: {
|
|
418
423
|
agentsMeta: state.agents?.agentsMeta ?? {},
|
|
419
424
|
files: Array.from(state.agents?.files?.entries() ?? []),
|
|
425
|
+
invokedAgentsByFile: Array.from(
|
|
426
|
+
(
|
|
427
|
+
state.agents?.invokedAgentsByFile ?? new Map<string, Set<string>>()
|
|
428
|
+
).entries()
|
|
429
|
+
).map(([file, agents]): [string, string[]] => [file, Array.from(agents)]),
|
|
420
430
|
},
|
|
421
431
|
scorers: {
|
|
422
432
|
scorersMeta: state.scorers?.scorersMeta ?? {},
|
|
@@ -544,6 +554,8 @@ export function deserializeInspectorState(
|
|
|
544
554
|
wireServicesFactories: new Map(data.wireServicesFactories),
|
|
545
555
|
wireServicesMeta: new Map(data.wireServicesMeta),
|
|
546
556
|
addonRequiredParentServices: data.addonRequiredParentServices || [],
|
|
557
|
+
addonCreatedServices: data.addonCreatedServices || [],
|
|
558
|
+
addonServicesFactorySeen: data.addonServicesFactorySeen ?? false,
|
|
547
559
|
addonServerlessIncompatible: new Map(
|
|
548
560
|
data.addonServerlessIncompatible || []
|
|
549
561
|
),
|
|
@@ -626,6 +638,11 @@ export function deserializeInspectorState(
|
|
|
626
638
|
agents: {
|
|
627
639
|
agentsMeta: data.agents?.agentsMeta || {},
|
|
628
640
|
files: new Map(data.agents?.files || []),
|
|
641
|
+
invokedAgentsByFile: new Map(
|
|
642
|
+
(data.agents?.invokedAgentsByFile || []).map(
|
|
643
|
+
([file, agents]): [string, Set<string>] => [file, new Set(agents)]
|
|
644
|
+
)
|
|
645
|
+
),
|
|
629
646
|
},
|
|
630
647
|
scorers: {
|
|
631
648
|
scorersMeta: data.scorers?.scorersMeta || {},
|