@pikku/inspector 0.12.63 → 0.12.65

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.
@@ -12,9 +12,11 @@ import {
12
12
  validateRemoteAddonAuth,
13
13
  validateAgentToolReferences,
14
14
  validateAgentModels,
15
+ validateSchemaReferences,
15
16
  } from './post-process.js'
16
17
  import { ErrorCode } from '../error-codes.js'
17
18
  import type { InspectorState, InspectorLogger } from '../types.js'
19
+ import { TypesMap } from '../types-map.js'
18
20
 
19
21
  const makeCriticalLogger = () => {
20
22
  const criticals: { code: string; message: string }[] = []
@@ -928,3 +930,123 @@ describe('validateAgentModels (aliases resolve against pikku.config.json)', () =
928
930
  })
929
931
  }
930
932
  })
933
+
934
+ const makeDiagnosticLogger = () => {
935
+ const diagnostics: { code: string; severity: string; message: string }[] = []
936
+ const logger = {
937
+ debug: () => {},
938
+ info: () => {},
939
+ warn: () => {},
940
+ error: () => {},
941
+ critical: () => {},
942
+ diagnostic: (d: any) =>
943
+ diagnostics.push({
944
+ code: String(d.code),
945
+ severity: d.severity,
946
+ message: d.message,
947
+ }),
948
+ } as unknown as InspectorLogger
949
+ return { logger, diagnostics }
950
+ }
951
+
952
+ const makeSchemaRefState = (
953
+ meta: Record<string, { inputs?: string[]; outputs?: string[] }>,
954
+ schemas: string[],
955
+ types: Record<string, string> = {}
956
+ ): InspectorState => {
957
+ const typesMap = new TypesMap()
958
+ for (const [name, path] of Object.entries(types)) {
959
+ typesMap.addType(name, path)
960
+ }
961
+ return {
962
+ functions: { meta, typesMap },
963
+ schemas: Object.fromEntries(schemas.map((name) => [name, {}])),
964
+ } as unknown as InspectorState
965
+ }
966
+
967
+ describe('validateSchemaReferences', () => {
968
+ test('an output type that was never exported is reported, not silent', () => {
969
+ const { logger, diagnostics } = makeDiagnosticLogger()
970
+ validateSchemaReferences(
971
+ logger,
972
+ makeSchemaRefState(
973
+ { getStructure: { outputs: ['StructureSummary'] } },
974
+ []
975
+ )
976
+ )
977
+ assert.equal(diagnostics.length, 1)
978
+ assert.equal(diagnostics[0]!.code, ErrorCode.SCHEMA_REFERENCE_UNRESOLVED)
979
+ assert.equal(diagnostics[0]!.severity, 'error')
980
+ assert.match(
981
+ diagnostics[0]!.message,
982
+ /getStructure\.output → 'StructureSummary'/
983
+ )
984
+ assert.match(diagnostics[0]!.message, /declared but not exported/)
985
+ })
986
+
987
+ test('an unresolved input is reported the same way', () => {
988
+ const { logger, diagnostics } = makeDiagnosticLogger()
989
+ validateSchemaReferences(
990
+ logger,
991
+ makeSchemaRefState({ getStructure: { inputs: ['StructureQuery'] } }, [])
992
+ )
993
+ assert.equal(diagnostics.length, 1)
994
+ assert.match(
995
+ diagnostics[0]!.message,
996
+ /getStructure\.input → 'StructureQuery'/
997
+ )
998
+ })
999
+
1000
+ test('a generated schema resolves and says nothing', () => {
1001
+ const { logger, diagnostics } = makeDiagnosticLogger()
1002
+ validateSchemaReferences(
1003
+ logger,
1004
+ makeSchemaRefState({ getStructure: { outputs: ['StructureSummary'] } }, [
1005
+ 'StructureSummary',
1006
+ ])
1007
+ )
1008
+ assert.deepEqual(diagnostics, [])
1009
+ })
1010
+
1011
+ test('primitive contracts are never schema references', () => {
1012
+ const { logger, diagnostics } = makeDiagnosticLogger()
1013
+ validateSchemaReferences(
1014
+ logger,
1015
+ makeSchemaRefState(
1016
+ { getStructure: { inputs: ['string'], outputs: ['void'] } },
1017
+ []
1018
+ )
1019
+ )
1020
+ assert.deepEqual(diagnostics, [])
1021
+ })
1022
+
1023
+ test('a name shared by two files is reported under the disambiguated name', () => {
1024
+ const { logger, diagnostics } = makeDiagnosticLogger()
1025
+ const typesMap = new TypesMap()
1026
+ const mine = typesMap.addUniqueType('StructureSummary', '/structure.ts')
1027
+ typesMap.addUniqueType('StructureSummary', '/other-structure.ts')
1028
+ const state = {
1029
+ functions: { meta: { getStructure: { outputs: [mine] } }, typesMap },
1030
+ schemas: {},
1031
+ } as unknown as InspectorState
1032
+ validateSchemaReferences(logger, state)
1033
+ assert.equal(diagnostics.length, 1)
1034
+ assert.match(diagnostics[0]!.message, new RegExp(`→ '${mine}'`))
1035
+ })
1036
+
1037
+ test('every unresolved contract is named in one diagnostic, not one each', () => {
1038
+ const { logger, diagnostics } = makeDiagnosticLogger()
1039
+ validateSchemaReferences(
1040
+ logger,
1041
+ makeSchemaRefState(
1042
+ {
1043
+ getStructure: { outputs: ['StructureSummary'] },
1044
+ listStructures: { outputs: ['StructureSummary'] },
1045
+ },
1046
+ []
1047
+ )
1048
+ )
1049
+ assert.equal(diagnostics.length, 1)
1050
+ assert.match(diagnostics[0]!.message, /2 function contracts name a schema/)
1051
+ })
1052
+ })
@@ -17,6 +17,7 @@ import { join } from 'node:path'
17
17
  import { extractTypeKeys } from './type-utils.js'
18
18
  import { ErrorCode } from '../error-codes.js'
19
19
  import { findSecretAliasServices } from './secret-alias-services.js'
20
+ import { resolveCoreType } from './resolve-core-type.js'
20
21
  import { relative } from 'node:path'
21
22
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
22
23
  import { flattenScopeDefinitions } from '@pikku/core/scope'
@@ -106,19 +107,25 @@ function extractAllServices(
106
107
  }
107
108
 
108
109
  // Extract all singleton services from the SingletonServices type
109
- const singletonServicesTypes = state.typesLookup.get('SingletonServices')
110
- if (singletonServicesTypes && singletonServicesTypes.length > 0) {
111
- const singletonServiceNames = extractTypeKeys(singletonServicesTypes[0])
112
- state.serviceAggregation.allSingletonServices = singletonServiceNames.sort()
110
+ const singletonServicesType = resolveCoreType(
111
+ state.typesLookup,
112
+ state.singletonServicesTypeImportMap
113
+ )
114
+ if (singletonServicesType) {
115
+ state.serviceAggregation.allSingletonServices = extractTypeKeys(
116
+ singletonServicesType
117
+ ).sort()
113
118
  }
114
119
 
115
120
  // Extract all services from the Services type
116
- const servicesTypes = state.typesLookup.get('Services')
117
- if (servicesTypes && servicesTypes.length > 0) {
118
- const allServiceNames = extractTypeKeys(servicesTypes[0])
121
+ const servicesType = resolveCoreType(
122
+ state.typesLookup,
123
+ state.wireServicesTypeImportMap
124
+ )
125
+ if (servicesType) {
119
126
  // Wire services are those in Services but not in SingletonServices
120
127
  const singletonSet = new Set(state.serviceAggregation.allSingletonServices)
121
- state.serviceAggregation.allWireServices = allServiceNames
128
+ state.serviceAggregation.allWireServices = extractTypeKeys(servicesType)
122
129
  .filter((name) => !singletonSet.has(name))
123
130
  .sort()
124
131
  }
@@ -1101,11 +1108,14 @@ export function validateNoSecretAliasServices(
1101
1108
  if (!('typesLookup' in state)) {
1102
1109
  return
1103
1110
  }
1104
- const singletonServicesTypes = state.typesLookup.get('SingletonServices')
1105
- if (!singletonServicesTypes?.length) {
1111
+ const singletonServicesType = resolveCoreType(
1112
+ state.typesLookup,
1113
+ state.singletonServicesTypeImportMap
1114
+ )
1115
+ if (!singletonServicesType) {
1106
1116
  return
1107
1117
  }
1108
- const aliases = findSecretAliasServices(singletonServicesTypes[0]!, checker)
1118
+ const aliases = findSecretAliasServices(singletonServicesType, checker)
1109
1119
  if (aliases.length === 0) {
1110
1120
  return
1111
1121
  }
@@ -0,0 +1,142 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import ts from 'typescript'
4
+ import { aggregateRequiredServices } from './post-process.js'
5
+ import type { InspectorState, PathToNameAndType } from '../types.js'
6
+
7
+ const SOURCE_FILE = '/application-types.ts'
8
+
9
+ /**
10
+ * Builds the slice of inspector state that service extraction reads: the
11
+ * `typesLookup` entry the AST walk records under whatever the project named its
12
+ * interface, and the import map that points at that name.
13
+ */
14
+ const stateWithCoreTypes = (
15
+ source: string,
16
+ singletonName: string,
17
+ wireName: string
18
+ ): InspectorState => {
19
+ const host: ts.CompilerHost = {
20
+ ...ts.createCompilerHost({}),
21
+ getSourceFile: (name, languageVersion) =>
22
+ name === SOURCE_FILE
23
+ ? ts.createSourceFile(name, source, languageVersion, true)
24
+ : undefined,
25
+ fileExists: (name) => name === SOURCE_FILE,
26
+ readFile: (name) => (name === SOURCE_FILE ? source : undefined),
27
+ }
28
+ const program = ts.createProgram([SOURCE_FILE], { noLib: true }, host)
29
+ const checker = program.getTypeChecker()
30
+ const sourceFile = program.getSourceFile(SOURCE_FILE)!
31
+
32
+ const typesLookup = new Map<string, ts.Type[]>()
33
+ const importMapFor = (name: string): PathToNameAndType => {
34
+ const declaration = sourceFile.statements.find(
35
+ (statement): statement is ts.InterfaceDeclaration =>
36
+ ts.isInterfaceDeclaration(statement) && statement.name.text === name
37
+ )!
38
+ typesLookup.set(name, [checker.getTypeAtLocation(declaration)])
39
+ return new Map([
40
+ [SOURCE_FILE, [{ variable: name, type: name, typePath: SOURCE_FILE }]],
41
+ ])
42
+ }
43
+
44
+ return {
45
+ typesLookup,
46
+ singletonServicesTypeImportMap: importMapFor(singletonName),
47
+ wireServicesTypeImportMap: importMapFor(wireName),
48
+ serviceAggregation: {
49
+ requiredServices: new Set<string>(),
50
+ usedFunctions: new Set<string>(),
51
+ usedMiddleware: new Set<string>(),
52
+ usedPermissions: new Set<string>(),
53
+ allSingletonServices: [],
54
+ allWireServices: [],
55
+ },
56
+ functions: { meta: {} },
57
+ middleware: { definitions: {}, tagMiddleware: new Map() },
58
+ permissions: { definitions: {} },
59
+ http: {
60
+ meta: {
61
+ get: {},
62
+ post: {},
63
+ put: {},
64
+ patch: {},
65
+ delete: {},
66
+ head: {},
67
+ options: {},
68
+ },
69
+ routeMiddleware: new Map(),
70
+ },
71
+ channels: { meta: {} },
72
+ queueWorkers: { meta: {} },
73
+ scheduledTasks: { meta: {} },
74
+ mcpEndpoints: { toolsMeta: {}, promptsMeta: {}, resourcesMeta: {} },
75
+ agents: { agentsMeta: {} },
76
+ workflows: { meta: {}, graphMeta: {} },
77
+ wireServicesMeta: new Map(),
78
+ rpc: { internalMeta: {}, exposedMeta: {} },
79
+ scopes: { definitions: [] },
80
+ addonFunctions: {},
81
+ addonRequiredParentServices: [],
82
+ } as unknown as InspectorState
83
+ }
84
+
85
+ const CONVENTIONAL = `
86
+ interface SingletonServices { logger: string; kysely: string }
87
+ interface Services { logger: string; kysely: string; http: string }
88
+ `
89
+
90
+ const RENAMED = `
91
+ interface AppSingletonServices { logger: string; kysely: string }
92
+ interface AppServices { logger: string; kysely: string; http: string }
93
+ `
94
+
95
+ describe('service extraction resolves core types by declaration, not by name', () => {
96
+ test('the conventional names still resolve', () => {
97
+ const state = stateWithCoreTypes(
98
+ CONVENTIONAL,
99
+ 'SingletonServices',
100
+ 'Services'
101
+ )
102
+ aggregateRequiredServices(state)
103
+ assert.deepEqual(state.serviceAggregation.allSingletonServices, [
104
+ 'kysely',
105
+ 'logger',
106
+ ])
107
+ assert.deepEqual(state.serviceAggregation.allWireServices, ['http'])
108
+ })
109
+
110
+ test('a project that renamed its interfaces resolves the same way', () => {
111
+ const state = stateWithCoreTypes(
112
+ RENAMED,
113
+ 'AppSingletonServices',
114
+ 'AppServices'
115
+ )
116
+ aggregateRequiredServices(state)
117
+ assert.deepEqual(
118
+ state.serviceAggregation.allSingletonServices,
119
+ ['kysely', 'logger'],
120
+ 'a renamed SingletonServices must not silently resolve to no services'
121
+ )
122
+ assert.deepEqual(state.serviceAggregation.allWireServices, ['http'])
123
+ })
124
+
125
+ test('no declaration at all leaves the lists empty for PKU724 to catch', () => {
126
+ const state = stateWithCoreTypes(
127
+ CONVENTIONAL,
128
+ 'SingletonServices',
129
+ 'Services'
130
+ )
131
+ state.singletonServicesTypeImportMap = new Map()
132
+ state.wireServicesTypeImportMap = new Map()
133
+ // Left at their initial empty value on purpose: `extractAllServices` only
134
+ // assigns when a type resolves, so seeding them here would make the
135
+ // assertions below true no matter what it did. `typesLookup` still holds
136
+ // both interfaces under their conventional names — resolving one from
137
+ // there is exactly the bug this guards.
138
+ aggregateRequiredServices(state)
139
+ assert.deepEqual(state.serviceAggregation.allSingletonServices, [])
140
+ assert.deepEqual(state.serviceAggregation.allWireServices, [])
141
+ })
142
+ })
@@ -0,0 +1,35 @@
1
+ import type ts from 'typescript'
2
+ import type { PathToNameAndType } from '../types.js'
3
+
4
+ /**
5
+ * Resolves the one interface extending a core type (`CoreConfig`,
6
+ * `CoreSingletonServices`, `CoreServices`) to the `ts.Type` the AST walk
7
+ * recorded for it.
8
+ *
9
+ * `typesLookup` is keyed by whatever the project named the interface, so
10
+ * reading it under a hardcoded `'SingletonServices'` only works for projects
11
+ * that follow the scaffold's naming. A project that renamed the interface still
12
+ * satisfies every required-type check and then resolves to no services at all,
13
+ * which surfaces far downstream as PKU724 or as every service turning optional.
14
+ * The import map carries the real name, so no name matching is needed.
15
+ *
16
+ * A project is only ever meant to declare one of each: `getMetaTypes` records
17
+ * `More than one ... found` when it sees a second, which `checkRequiredTypes`
18
+ * then reports. That check is the caller's to run, and extraction happens
19
+ * first, so where two declarations do exist this takes the first in traversal
20
+ * order rather than diagnosing the ambiguity itself.
21
+ */
22
+ export const resolveCoreType = (
23
+ typesLookup: Map<string, ts.Type[]>,
24
+ importMap: PathToNameAndType
25
+ ): ts.Type | undefined => {
26
+ for (const entries of importMap.values()) {
27
+ for (const { type } of entries) {
28
+ const resolved = type ? typesLookup.get(type)?.[0] : undefined
29
+ if (resolved) {
30
+ return resolved
31
+ }
32
+ }
33
+ }
34
+ return undefined
35
+ }