@pikku/inspector 0.12.69 → 0.12.72

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2010,3 +2010,135 @@ describe('invokedFunctionsByFile serialization', () => {
2010
2010
  assert.strictEqual(restored.rpc.invokedFunctionsByFile.size, 0)
2011
2011
  })
2012
2012
  })
2013
+
2014
+ describe('Workflow file pruning', () => {
2015
+ const workflowState = () => {
2016
+ const state = createMockInspectorState()
2017
+ state.workflows.meta = {
2018
+ moveOut: { pikkuFuncId: 'getUsers' } as any,
2019
+ theMoveOutScenario: { pikkuFuncId: 'createUser' } as any,
2020
+ }
2021
+ state.workflows.graphMeta = {
2022
+ moveOut: { pikkuFuncId: 'getUsers' } as any,
2023
+ theMoveOutScenario: {
2024
+ pikkuFuncId: 'createUser',
2025
+ source: 'scenario',
2026
+ } as any,
2027
+ }
2028
+ state.workflows.files = new Map([
2029
+ [
2030
+ 'moveOut',
2031
+ {
2032
+ path: '/test/project/src/wirings/move-out.workflow.ts',
2033
+ exportedName: 'moveOut',
2034
+ },
2035
+ ],
2036
+ [
2037
+ 'theMoveOutScenario',
2038
+ {
2039
+ path: '/test/project/test/scenarios/the-move-out.scenario.ts',
2040
+ exportedName: 'theMoveOutScenario',
2041
+ },
2042
+ ],
2043
+ ])
2044
+ return state
2045
+ }
2046
+
2047
+ test('drops the file entry of a pruned workflow', () => {
2048
+ const result = filterInspectorState(
2049
+ workflowState(),
2050
+ { names: ['getUsers'] },
2051
+ mockLogger
2052
+ )
2053
+
2054
+ assert.deepEqual(Object.keys(result.workflows.graphMeta), ['moveOut'])
2055
+ assert.deepEqual([...result.workflows.files.keys()], ['moveOut'])
2056
+ })
2057
+
2058
+ test('leaves the original state untouched', () => {
2059
+ const state = workflowState()
2060
+ filterInspectorState(state, { names: ['getUsers'] }, mockLogger)
2061
+
2062
+ assert.equal(state.workflows.files.size, 2)
2063
+ assert.equal(Object.keys(state.workflows.graphMeta).length, 2)
2064
+ })
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
+ })
@@ -21,6 +21,28 @@ type DeployFilter = {
21
21
  has(pikkuFuncId: string): boolean
22
22
  }
23
23
 
24
+ /**
25
+ * Drop a workflow from every map that describes it. `files` and `graphFiles`
26
+ * are what the wiring generator iterates, and `graphMeta` is what tells a
27
+ * scenario apart from an app workflow — leaving a pruned workflow's file entry
28
+ * behind registers it anyway, and a scenario whose graphMeta has gone reads as
29
+ * an app workflow, which is how scenario steps reached deployed bundles.
30
+ */
31
+ const pruneWorkflow = (
32
+ workflows: InspectorState['workflows'],
33
+ name: string,
34
+ pikkuFuncId: string | undefined
35
+ ) => {
36
+ delete workflows.graphMeta[name]
37
+ delete workflows.meta[name]
38
+ workflows.files.delete(name)
39
+ workflows.graphFiles.delete(name)
40
+ if (pikkuFuncId) {
41
+ workflows.files.delete(pikkuFuncId)
42
+ workflows.graphFiles.delete(pikkuFuncId)
43
+ }
44
+ }
45
+
24
46
  // Module-level Set to track warned groups across multiple filter calls
25
47
  const globalWarnedGroups = new Set<string>()
26
48
 
@@ -449,6 +471,8 @@ export function filterInspectorState(
449
471
  ...state.workflows,
450
472
  graphMeta: JSON.parse(JSON.stringify(state.workflows?.graphMeta ?? {})),
451
473
  meta: JSON.parse(JSON.stringify(state.workflows?.meta ?? {})),
474
+ files: new Map(state.workflows?.files ?? []),
475
+ graphFiles: new Map(state.workflows?.graphFiles ?? []),
452
476
  },
453
477
  channels: {
454
478
  ...state.channels,
@@ -483,6 +507,15 @@ export function filterInspectorState(
483
507
  ...state.agents,
484
508
  agentsMeta: JSON.parse(JSON.stringify(state.agents?.agentsMeta ?? {})),
485
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
+ ),
486
519
  },
487
520
  // Scorers carry through every filter. They are named by the agents that
488
521
  // survive it, and a deployment that quietly stopped grading would look
@@ -1098,12 +1131,10 @@ export function filterInspectorState(
1098
1131
  pikkuFuncId &&
1099
1132
  !filteredState.serviceAggregation.usedFunctions.has(pikkuFuncId)
1100
1133
  ) {
1101
- delete filteredState.workflows.graphMeta[name]
1102
- delete filteredState.workflows.meta[name]
1134
+ pruneWorkflow(filteredState.workflows, name, pikkuFuncId)
1103
1135
  } else if (!pikkuFuncId) {
1104
1136
  // No function ID found — prune it
1105
- delete filteredState.workflows.graphMeta[name]
1106
- delete filteredState.workflows.meta[name]
1137
+ pruneWorkflow(filteredState.workflows, name, pikkuFuncId)
1107
1138
  }
1108
1139
  }
1109
1140
 
@@ -1188,6 +1219,16 @@ export function filterInspectorState(
1188
1219
  filteredState.serviceAggregation.requiredServices.add('queueService')
1189
1220
  }
1190
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
+
1191
1232
  if ((filteredState.rpc.wireAddonDeclarations?.size ?? 0) > 0) {
1192
1233
  const referencedIds = new Set<string>(
1193
1234
  filteredState.serviceAggregation.usedFunctions
@@ -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 || {},