@pikku/inspector 0.12.49 → 0.12.52

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.
Files changed (104) hide show
  1. package/CHANGELOG.md +501 -0
  2. package/OPTIMIZATION-PLAN.md +9 -3
  3. package/dist/add/add-auth.d.ts +1 -1
  4. package/dist/add/add-auth.js +1 -1
  5. package/dist/add/add-cli.js +7 -0
  6. package/dist/add/add-credential.js +1 -1
  7. package/dist/add/add-functions.js +156 -7
  8. package/dist/add/add-http-route.js +16 -6
  9. package/dist/add/add-middleware.js +46 -3
  10. package/dist/add/add-personas.d.ts +13 -0
  11. package/dist/add/add-personas.js +210 -0
  12. package/dist/add/add-scope.js +1 -1
  13. package/dist/add/add-secret.js +1 -1
  14. package/dist/add/add-system-role.d.ts +2 -0
  15. package/dist/add/add-system-role.js +102 -0
  16. package/dist/add/add-variable.js +1 -1
  17. package/dist/add/add-wire-addon.js +31 -0
  18. package/dist/error-codes.d.ts +10 -1
  19. package/dist/error-codes.js +11 -0
  20. package/dist/index.d.ts +1 -1
  21. package/dist/inspector.js +33 -1
  22. package/dist/types.d.ts +46 -2
  23. package/dist/utils/annotate-http-route-auth.d.ts +22 -0
  24. package/dist/utils/annotate-http-route-auth.js +48 -0
  25. package/dist/utils/extract-function-name.js +13 -4
  26. package/dist/utils/extract-function-node.js +18 -1
  27. package/dist/utils/extract-secret-usage.d.ts +9 -0
  28. package/dist/utils/extract-secret-usage.js +61 -0
  29. package/dist/utils/literal-properties.d.ts +22 -0
  30. package/dist/utils/literal-properties.js +69 -0
  31. package/dist/utils/load-addon-functions-meta.js +26 -1
  32. package/dist/utils/permissions.js +8 -0
  33. package/dist/utils/post-process.d.ts +68 -3
  34. package/dist/utils/post-process.js +243 -13
  35. package/dist/utils/schema-generator.d.ts +24 -0
  36. package/dist/utils/schema-generator.js +118 -19
  37. package/dist/utils/secret-alias-services.d.ts +8 -0
  38. package/dist/utils/secret-alias-services.js +45 -0
  39. package/dist/utils/secret-brokers.d.ts +2 -0
  40. package/dist/utils/secret-brokers.js +9 -0
  41. package/dist/utils/serialize-inspector-state.d.ts +10 -0
  42. package/dist/utils/serialize-inspector-state.js +18 -0
  43. package/dist/utils/serialize-middleware-groups-meta.js +3 -0
  44. package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
  45. package/dist/utils/validate-exposed-functions-gated.js +85 -0
  46. package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
  47. package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
  48. package/dist/visit.js +4 -0
  49. package/package.json +3 -3
  50. package/src/add/add-auth.test.ts +5 -3
  51. package/src/add/add-auth.ts +1 -1
  52. package/src/add/add-cli.ts +8 -0
  53. package/src/add/add-credential.ts +1 -1
  54. package/src/add/add-functions.test.ts +263 -1
  55. package/src/add/add-functions.ts +219 -9
  56. package/src/add/add-http-route.ts +20 -7
  57. package/src/add/add-middleware.test.ts +196 -0
  58. package/src/add/add-middleware.ts +64 -4
  59. package/src/add/add-personas.test.ts +268 -0
  60. package/src/add/add-personas.ts +276 -0
  61. package/src/add/add-scenario-step.test.ts +49 -3
  62. package/src/add/add-scope.test.ts +30 -30
  63. package/src/add/add-scope.ts +1 -1
  64. package/src/add/add-secret.ts +1 -1
  65. package/src/add/add-subject-scenario-steps.test.ts +161 -0
  66. package/src/add/add-system-role.test.ts +214 -0
  67. package/src/add/add-system-role.ts +164 -0
  68. package/src/add/add-variable.ts +1 -1
  69. package/src/add/add-wire-addon.test.ts +111 -0
  70. package/src/add/add-wire-addon.ts +29 -0
  71. package/src/add/addon-bans.test.ts +4 -4
  72. package/src/add/config-value-metadata.test.ts +11 -11
  73. package/src/add/wire-name-literal.test.ts +2 -2
  74. package/src/error-codes.ts +13 -0
  75. package/src/index.ts +4 -1
  76. package/src/inspector.ts +39 -0
  77. package/src/types.ts +47 -2
  78. package/src/utils/annotate-http-route-auth.test.ts +163 -0
  79. package/src/utils/annotate-http-route-auth.ts +52 -0
  80. package/src/utils/extract-function-name.ts +14 -4
  81. package/src/utils/extract-function-node.ts +18 -6
  82. package/src/utils/extract-secret-usage.test.ts +81 -0
  83. package/src/utils/extract-secret-usage.ts +73 -0
  84. package/src/utils/literal-properties.ts +97 -0
  85. package/src/utils/load-addon-functions-meta.ts +32 -2
  86. package/src/utils/permissions-shorthand.test.ts +79 -0
  87. package/src/utils/permissions.ts +7 -0
  88. package/src/utils/post-process.test.ts +133 -0
  89. package/src/utils/post-process.ts +310 -12
  90. package/src/utils/schema-cache.test.ts +124 -0
  91. package/src/utils/schema-generator.test.ts +62 -1
  92. package/src/utils/schema-generator.ts +138 -21
  93. package/src/utils/secret-alias-services.test.ts +146 -0
  94. package/src/utils/secret-alias-services.ts +58 -0
  95. package/src/utils/secret-brokers.test.ts +53 -0
  96. package/src/utils/secret-brokers.ts +11 -0
  97. package/src/utils/serialize-inspector-state.ts +28 -0
  98. package/src/utils/serialize-middleware-groups-meta.ts +3 -0
  99. package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
  100. package/src/utils/validate-exposed-functions-gated.ts +94 -0
  101. package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
  102. package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
  103. package/src/visit.ts +4 -0
  104. package/tsconfig.tsbuildinfo +1 -1
@@ -1,6 +1,12 @@
1
1
  import * as ts from 'typescript'
2
2
  import { createHash } from 'crypto'
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
3
+ import {
4
+ existsSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from 'fs'
4
10
  import { dirname, join, resolve } from 'path'
5
11
  import { createGenerator, RootlessError } from 'ts-json-schema-generator'
6
12
  import { register } from 'tsx/esm/api'
@@ -75,6 +81,7 @@ let cachedParsedConfig: ts.ParsedCommandLine | undefined
75
81
  let cachedTsconfigPath: string | undefined
76
82
  let cachedCustomTypesContent: string | undefined
77
83
  let cachedTSSchemas: Record<string, JSONValue> | undefined
84
+ let cachedTSSchemaDeps: SchemaDep[] | undefined
78
85
 
79
86
  const SCHEMA_CACHE_VERSION = 1
80
87
 
@@ -118,32 +125,102 @@ function schemaCacheFile(cacheDir: string): string {
118
125
  return join(cacheDir, 'ts-schemas.json')
119
126
  }
120
127
 
121
- function readDiskTSSchemas(
128
+ /** One file the generated schemas were derived from, as of when they were generated. */
129
+ export type SchemaDep = { path: string; mtimeMs: number; size: number }
130
+
131
+ /**
132
+ * The files a set of generated schemas was actually derived from.
133
+ *
134
+ * The cache key alone cannot answer whether the schemas are stale. It hashes the
135
+ * *synthesized* custom-types source, but a named type — `KnowledgeBundle`,
136
+ * `DbSchema`, anything a function declares as its input or output — appears
137
+ * there only as a name; its definition is resolved out of a project file or a
138
+ * dependency's `.d.ts`. Edit one of those and the key is unchanged, so a stale
139
+ * schema is served for a type that no longer looks like that. The failure is
140
+ * silent and outlives `rm -rf .pikku`, because this cache lives in
141
+ * `node_modules/.cache`.
142
+ *
143
+ * The program is rooted at the virtual file, so its source files are exactly the
144
+ * transitive closure the schemas depend on — nothing wider, nothing missed.
145
+ * Default-lib files are skipped: they change only when TypeScript itself does,
146
+ * which the inspector version in the key already covers.
147
+ */
148
+ function fingerprintSchemaDeps(program: ts.Program): SchemaDep[] {
149
+ const deps: SchemaDep[] = []
150
+ for (const sourceFile of program.getSourceFiles()) {
151
+ if (program.isSourceFileDefaultLibrary(sourceFile)) continue
152
+ try {
153
+ const stat = statSync(sourceFile.fileName)
154
+ deps.push({
155
+ path: sourceFile.fileName,
156
+ mtimeMs: stat.mtimeMs,
157
+ size: stat.size,
158
+ })
159
+ } catch {
160
+ // The virtual root has no file on disk. Its content is the custom-types
161
+ // source, which the cache key already hashes.
162
+ }
163
+ }
164
+ return deps
165
+ }
166
+
167
+ /**
168
+ * Whether every file the schemas were derived from is still exactly as it was.
169
+ *
170
+ * mtime-and-size rather than a content hash: this runs on the cache-hit path,
171
+ * where the whole point is to answer in milliseconds without building a TS
172
+ * program. Anything that rewrites a file — a dependency rebuild, a checkout, an
173
+ * edit — moves its mtime, and a missing file reads as changed, so the answer errs
174
+ * toward regenerating.
175
+ */
176
+ function schemaDepsUnchanged(deps: unknown): boolean {
177
+ if (!Array.isArray(deps)) return false
178
+ for (const dep of deps as SchemaDep[]) {
179
+ if (!dep || typeof dep.path !== 'string') return false
180
+ try {
181
+ const stat = statSync(dep.path)
182
+ if (stat.mtimeMs !== dep.mtimeMs || stat.size !== dep.size) return false
183
+ } catch {
184
+ return false
185
+ }
186
+ }
187
+ return true
188
+ }
189
+
190
+ export function readDiskTSSchemas(
122
191
  logger: InspectorLogger,
123
192
  cacheDir: string,
124
193
  key: string
125
- ): Record<string, JSONValue> | null {
194
+ ): { schemas: Record<string, JSONValue>; deps: SchemaDep[] } | null {
126
195
  const file = schemaCacheFile(cacheDir)
127
196
  if (!existsSync(file)) return null
128
197
  try {
129
198
  const parsed = JSON.parse(readFileSync(file, 'utf-8'))
130
- if (parsed?.key === key && parsed.schemas) return parsed.schemas
199
+ if (parsed?.key !== key || !parsed.schemas) return null
200
+ if (!schemaDepsUnchanged(parsed.deps)) {
201
+ logger.debug(
202
+ 'Discarding TS-schema cache: a type it was built from changed'
203
+ )
204
+ return null
205
+ }
206
+ return { schemas: parsed.schemas, deps: parsed.deps }
131
207
  } catch (e) {
132
208
  logger.debug(`Ignoring unreadable TS-schema cache: ${(e as Error).message}`)
133
209
  }
134
210
  return null
135
211
  }
136
212
 
137
- function writeDiskTSSchemas(
213
+ export function writeDiskTSSchemas(
138
214
  logger: InspectorLogger,
139
215
  cacheDir: string,
140
216
  key: string,
141
- schemas: Record<string, JSONValue>
217
+ schemas: Record<string, JSONValue>,
218
+ deps: SchemaDep[]
142
219
  ): void {
143
220
  const file = schemaCacheFile(cacheDir)
144
221
  try {
145
222
  mkdirSync(dirname(file), { recursive: true })
146
- writeFileSync(file, JSON.stringify({ key, schemas }))
223
+ writeFileSync(file, JSON.stringify({ key, schemas, deps }))
147
224
  } catch (e) {
148
225
  logger.debug(`Failed to persist TS-schema cache: ${(e as Error).message}`)
149
226
  }
@@ -234,7 +311,7 @@ function generateTSSchemas(
234
311
  additionalTypes?: string[],
235
312
  additionalProperties: boolean = false,
236
313
  generatedZodSchemas?: Record<string, JSONValue>
237
- ): Record<string, JSONValue> {
314
+ ): { schemas: Record<string, JSONValue>; deps: SchemaDep[] } {
238
315
  const schemasSet = new Set(typesMap.customTypes.keys())
239
316
  for (const { inputs, outputs } of Object.values(functionMeta)) {
240
317
  const types = [...(inputs || []), ...(outputs || [])]
@@ -277,7 +354,7 @@ function generateTSSchemas(
277
354
  (s) => !PRIMITIVE_TYPES.has(s) && !generatedZodSchemas?.[s]
278
355
  )
279
356
  if (uncoveredSchemas.length === 0) {
280
- return {}
357
+ return { schemas: {}, deps: [] }
281
358
  }
282
359
  logger.debug(
283
360
  `generateTSSchemas needed for ${uncoveredSchemas.length} types: ${uncoveredSchemas.slice(0, 3).join(', ')}${uncoveredSchemas.length > 3 ? '...' : ''}`
@@ -335,7 +412,28 @@ function generateTSSchemas(
335
412
  }
336
413
  })
337
414
 
338
- return schemas
415
+ // Fingerprinted here, while the program is still alive — it is released as soon
416
+ // as the schemas are out of it.
417
+ return { schemas, deps: fingerprintSchemaDeps(program) }
418
+ }
419
+
420
+ /**
421
+ * Where the RUNTIME value of a schema lives, given where TypeScript resolved it.
422
+ *
423
+ * A schema imported from a built workspace package resolves to that package's
424
+ * declaration file, and importing a `.d.ts` yields a module with no exports at
425
+ * all — every one of them is a type. The value is in the emitted JS beside it, so
426
+ * that is what gets imported.
427
+ *
428
+ * Falls back to the original path when no sibling JS exists, which keeps the
429
+ * "could not find exported schema" error pointing at the file the developer
430
+ * actually wrote.
431
+ */
432
+ export const schemaRuntimeFile = (sourceFile: string): string => {
433
+ const match = /\.d\.([cm]?)ts$/.exec(sourceFile)
434
+ if (!match) return sourceFile
435
+ const emitted = sourceFile.replace(/\.d\.([cm]?)ts$/, '.$1js')
436
+ return existsSync(emitted) ? emitted : sourceFile
339
437
  }
340
438
 
341
439
  /**
@@ -472,7 +570,9 @@ async function generateZodSchemas(
472
570
 
473
571
  // Collect unique source files and batch-import them in parallel
474
572
  const uniqueSourceFiles = [
475
- ...new Set([...schemaLookup.values()].map((ref) => ref.sourceFile)),
573
+ ...new Set(
574
+ [...schemaLookup.values()].map((ref) => schemaRuntimeFile(ref.sourceFile))
575
+ ),
476
576
  ]
477
577
  logger.debug(
478
578
  `[TIMING] Zod schemas: ${schemaLookup.size} schemas from ${uniqueSourceFiles.length} files`
@@ -492,12 +592,13 @@ async function generateZodSchemas(
492
592
  const fallbackSchemas: [string, SchemaRef][] = []
493
593
 
494
594
  for (const [schemaName, ref] of schemaLookup.entries()) {
495
- const mod = importedModules?.get(ref.sourceFile)
595
+ const runtimeFile = schemaRuntimeFile(ref.sourceFile)
596
+ const mod = importedModules?.get(runtimeFile)
496
597
  if (mod) {
497
598
  const zodSchema = mod[ref.variableName]
498
599
  if (!zodSchema) {
499
600
  errors.push(
500
- `Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`
601
+ `Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(mod).join(', ')}`
501
602
  )
502
603
  continue
503
604
  }
@@ -531,12 +632,13 @@ async function generateZodSchemas(
531
632
  `Falling back to register() import for ${fallbackSchemas.length} schema(s)`
532
633
  )
533
634
  for (const [schemaName, ref] of fallbackSchemas) {
635
+ const runtimeFile = schemaRuntimeFile(ref.sourceFile)
534
636
  try {
535
- const module = await importWithRegister(ref.sourceFile)
637
+ const module = await importWithRegister(runtimeFile)
536
638
  const zodSchema = module[ref.variableName]
537
639
  if (!zodSchema) {
538
640
  errors.push(
539
- `Could not find exported schema '${ref.variableName}' in ${ref.sourceFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`
641
+ `Could not find exported schema '${ref.variableName}' in ${runtimeFile} for ${schemaName}. Available exports: ${Object.keys(module).join(', ')}`
540
642
  )
541
643
  continue
542
644
  }
@@ -595,8 +697,15 @@ export async function generateAllSchemas(
595
697
  requiredTypes
596
698
  )
597
699
 
598
- // Fast path: same process, types unchanged — reuse the in-memory result.
599
- if (cachedTSSchemas && cachedCustomTypesContent === customTypesContent) {
700
+ // Fast path: same process, types unchanged — reuse the in-memory result. The
701
+ // dep check is what makes this correct under `pikku dev`, where the process
702
+ // outlives the edits: the custom-types source is identical whenever a named
703
+ // type changes shape without changing its name.
704
+ if (
705
+ cachedTSSchemas &&
706
+ cachedCustomTypesContent === customTypesContent &&
707
+ schemaDepsUnchanged(cachedTSSchemaDeps)
708
+ ) {
600
709
  logger.debug('Reusing cached TS schemas (types unchanged)')
601
710
  return { ...cachedTSSchemas, ...zodSchemas }
602
711
  }
@@ -612,12 +721,13 @@ export async function generateAllSchemas(
612
721
  if (disk) {
613
722
  logger.debug('Reusing on-disk TS schemas (types unchanged across runs)')
614
723
  cachedCustomTypesContent = customTypesContent
615
- cachedTSSchemas = disk
616
- return { ...disk, ...zodSchemas }
724
+ cachedTSSchemas = disk.schemas
725
+ cachedTSSchemaDeps = disk.deps
726
+ return { ...disk.schemas, ...zodSchemas }
617
727
  }
618
728
  }
619
729
 
620
- const tsSchemas = generateTSSchemas(
730
+ const { schemas: tsSchemas, deps: tsSchemaDeps } = generateTSSchemas(
621
731
  logger,
622
732
  config.tsconfig,
623
733
  customTypesContent,
@@ -639,9 +749,16 @@ export async function generateAllSchemas(
639
749
 
640
750
  cachedCustomTypesContent = customTypesContent
641
751
  cachedTSSchemas = tsSchemas
752
+ cachedTSSchemaDeps = tsSchemaDeps
642
753
 
643
754
  if (config.cacheDir && cacheKey) {
644
- writeDiskTSSchemas(logger, config.cacheDir, cacheKey, tsSchemas)
755
+ writeDiskTSSchemas(
756
+ logger,
757
+ config.cacheDir,
758
+ cacheKey,
759
+ tsSchemas,
760
+ tsSchemaDeps
761
+ )
645
762
  }
646
763
 
647
764
  return { ...tsSchemas, ...zodSchemas }
@@ -0,0 +1,146 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import ts from 'typescript'
4
+ import {
5
+ findSecretAliasServices,
6
+ isSecretServiceType,
7
+ } from './secret-alias-services.js'
8
+
9
+ const SECRET_SERVICE = `
10
+ interface SecretService {
11
+ getSecret(key: string): Promise<string>
12
+ hasSecret(key: string): Promise<boolean>
13
+ setSecret(key: string, value: unknown): Promise<void>
14
+ deleteSecret(key: string): Promise<void>
15
+ getSecrets(keys: string[]): Promise<Record<string, unknown>>
16
+ }
17
+ `
18
+
19
+ const singletonServicesTypeOf = (source: string) => {
20
+ const fileName = '/services.ts'
21
+ const host: ts.CompilerHost = {
22
+ ...ts.createCompilerHost({}),
23
+ getSourceFile: (name, languageVersion) =>
24
+ name === fileName
25
+ ? ts.createSourceFile(name, source, languageVersion, true)
26
+ : undefined,
27
+ fileExists: (name) => name === fileName,
28
+ readFile: (name) => (name === fileName ? source : undefined),
29
+ }
30
+ const program = ts.createProgram([fileName], { noLib: true }, host)
31
+ const checker = program.getTypeChecker()
32
+ const sourceFile = program.getSourceFile(fileName)!
33
+ const declaration = sourceFile.statements.find(
34
+ (statement): statement is ts.InterfaceDeclaration =>
35
+ ts.isInterfaceDeclaration(statement) &&
36
+ statement.name.text === 'SingletonServices'
37
+ )!
38
+ return {
39
+ checker,
40
+ type: checker.getTypeAtLocation(declaration),
41
+ }
42
+ }
43
+
44
+ describe('findSecretAliasServices', () => {
45
+ test('finds a secret service exposed under another name', () => {
46
+ const { type, checker } = singletonServicesTypeOf(`
47
+ ${SECRET_SERVICE}
48
+ interface SingletonServices {
49
+ secrets: SecretService
50
+ cfg: SecretService
51
+ }
52
+ `)
53
+ assert.deepEqual(findSecretAliasServices(type, checker), ['cfg'])
54
+ })
55
+
56
+ test('never reports `secrets` itself', () => {
57
+ const { type, checker } = singletonServicesTypeOf(`
58
+ ${SECRET_SERVICE}
59
+ interface SingletonServices {
60
+ secrets: SecretService
61
+ }
62
+ `)
63
+ assert.deepEqual(findSecretAliasServices(type, checker), [])
64
+ })
65
+
66
+ test('leaves ordinary services alone', () => {
67
+ const { type, checker } = singletonServicesTypeOf(`
68
+ ${SECRET_SERVICE}
69
+ interface Logger { info(message: string): void }
70
+ interface SingletonServices {
71
+ secrets: SecretService
72
+ logger: Logger
73
+ }
74
+ `)
75
+ assert.deepEqual(findSecretAliasServices(type, checker), [])
76
+ })
77
+
78
+ test('is not fooled by a service that only has getSecret', () => {
79
+ const { type, checker } = singletonServicesTypeOf(`
80
+ ${SECRET_SERVICE}
81
+ interface Partialish { getSecret(key: string): Promise<string> }
82
+ interface SingletonServices {
83
+ secrets: SecretService
84
+ partialish: Partialish
85
+ }
86
+ `)
87
+ assert.deepEqual(findSecretAliasServices(type, checker), [])
88
+ })
89
+
90
+ test('sees through an optional service', () => {
91
+ const { type, checker } = singletonServicesTypeOf(`
92
+ ${SECRET_SERVICE}
93
+ interface SingletonServices {
94
+ secrets: SecretService
95
+ cfg?: SecretService
96
+ }
97
+ `)
98
+ assert.deepEqual(findSecretAliasServices(type, checker), ['cfg'])
99
+ })
100
+
101
+ test('finds a subtype that adds methods', () => {
102
+ const { type, checker } = singletonServicesTypeOf(`
103
+ ${SECRET_SERVICE}
104
+ interface Scoped extends SecretService { getAllStatus(): Promise<void> }
105
+ interface SingletonServices {
106
+ secrets: SecretService
107
+ scoped: Scoped
108
+ }
109
+ `)
110
+ assert.deepEqual(findSecretAliasServices(type, checker), ['scoped'])
111
+ })
112
+
113
+ test('reports every alias, sorted', () => {
114
+ const { type, checker } = singletonServicesTypeOf(`
115
+ ${SECRET_SERVICE}
116
+ interface SingletonServices {
117
+ secrets: SecretService
118
+ zed: SecretService
119
+ alpha: SecretService
120
+ }
121
+ `)
122
+ assert.deepEqual(findSecretAliasServices(type, checker), ['alpha', 'zed'])
123
+ })
124
+ })
125
+
126
+ describe('isSecretServiceType', () => {
127
+ test('refuses a type whose members are not callable', () => {
128
+ const { type, checker } = singletonServicesTypeOf(`
129
+ ${SECRET_SERVICE}
130
+ interface Lookalike {
131
+ getSecret: string
132
+ hasSecret: string
133
+ setSecret: string
134
+ deleteSecret: string
135
+ getSecrets: string
136
+ }
137
+ interface SingletonServices { lookalike: Lookalike }
138
+ `)
139
+ const property = type.getProperty('lookalike')!
140
+ const propertyType = checker.getTypeOfSymbolAtLocation(
141
+ property,
142
+ property.valueDeclaration!
143
+ )
144
+ assert.equal(isSecretServiceType(propertyType, checker), false)
145
+ })
146
+ })
@@ -0,0 +1,58 @@
1
+ import type ts from 'typescript'
2
+
3
+ const SECRET_SERVICE_METHODS = [
4
+ 'getSecret',
5
+ 'hasSecret',
6
+ 'setSecret',
7
+ 'deleteSecret',
8
+ 'getSecrets',
9
+ ]
10
+
11
+ /** Whether a type structurally satisfies `SecretService`. */
12
+ export const isSecretServiceType = (
13
+ type: ts.Type,
14
+ checker: ts.TypeChecker
15
+ ): boolean => {
16
+ const nonNullable = checker.getNonNullableType(type)
17
+ return SECRET_SERVICE_METHODS.every((method) => {
18
+ const property = nonNullable.getProperty(method)
19
+ if (!property) {
20
+ return false
21
+ }
22
+ const declaration = property.valueDeclaration ?? property.declarations?.[0]
23
+ if (!declaration) {
24
+ return false
25
+ }
26
+ const propertyType = checker.getTypeOfSymbolAtLocation(
27
+ property,
28
+ declaration
29
+ )
30
+ return propertyType.getCallSignatures().length > 0
31
+ })
32
+ }
33
+
34
+ /**
35
+ * Singleton service names, other than `secrets`, whose type is a
36
+ * `SecretService`.
37
+ */
38
+ export const findSecretAliasServices = (
39
+ singletonServicesType: ts.Type,
40
+ checker: ts.TypeChecker
41
+ ): string[] => {
42
+ const aliases: string[] = []
43
+ for (const property of singletonServicesType.getProperties()) {
44
+ const name = property.getName()
45
+ if (name === 'secrets') {
46
+ continue
47
+ }
48
+ const declaration = property.valueDeclaration ?? property.declarations?.[0]
49
+ if (!declaration) {
50
+ continue
51
+ }
52
+ const type = checker.getTypeOfSymbolAtLocation(property, declaration)
53
+ if (isSecretServiceType(type, checker)) {
54
+ aliases.push(name)
55
+ }
56
+ }
57
+ return aliases.sort()
58
+ }
@@ -0,0 +1,53 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { isSecretBrokerFunction } from './secret-brokers.js'
4
+ import { computeSecretBrokers } from './post-process.js'
5
+ import type { InspectorState } from '../types.js'
6
+
7
+ describe('isSecretBrokerFunction', () => {
8
+ test('admits the console secret-admin functions', () => {
9
+ assert.ok(isSecretBrokerFunction('pikkuConsoleGetSecret'))
10
+ assert.ok(isSecretBrokerFunction('pikkuConsoleSetSecret'))
11
+ assert.ok(isSecretBrokerFunction('pikkuConsoleHasSecret'))
12
+ })
13
+
14
+ test('admits them when namespaced by an addon', () => {
15
+ assert.ok(isSecretBrokerFunction('pikku:pikkuConsoleGetSecret'))
16
+ })
17
+
18
+ test('admits nothing else', () => {
19
+ assert.equal(isSecretBrokerFunction('pikkuConsoleGetVariable'), false)
20
+ assert.equal(isSecretBrokerFunction('httpRequest'), false)
21
+ assert.equal(isSecretBrokerFunction('myFunction'), false)
22
+ })
23
+
24
+ test('is not fooled by a name that merely contains a broker name', () => {
25
+ assert.equal(isSecretBrokerFunction('notPikkuConsoleGetSecret'), false)
26
+ assert.equal(isSecretBrokerFunction('pikkuConsoleGetSecretly'), false)
27
+ })
28
+ })
29
+
30
+ describe('computeSecretBrokers', () => {
31
+ const stateWith = (meta: Record<string, any>) =>
32
+ ({ functions: { meta } }) as unknown as InspectorState
33
+
34
+ test('flags a broker so the runner keeps its secrets', () => {
35
+ const state = stateWith({
36
+ pikkuConsoleGetSecret: { pikkuFuncId: 'pikkuConsoleGetSecret' },
37
+ })
38
+ computeSecretBrokers(state)
39
+ assert.equal(state.functions.meta.pikkuConsoleGetSecret!.secretBroker, true)
40
+ })
41
+
42
+ test('leaves an ordinary function unflagged', () => {
43
+ const state = stateWith({ createTodo: { pikkuFuncId: 'createTodo' } })
44
+ computeSecretBrokers(state)
45
+ assert.equal(state.functions.meta.createTodo!.secretBroker, undefined)
46
+ })
47
+
48
+ test('falls back to the meta key when there is no pikkuFuncId', () => {
49
+ const state = stateWith({ pikkuConsoleSetSecret: {} })
50
+ computeSecretBrokers(state)
51
+ assert.equal(state.functions.meta.pikkuConsoleSetSecret!.secretBroker, true)
52
+ })
53
+ })
@@ -0,0 +1,11 @@
1
+ /** The console's secret administration functions, and nothing else. */
2
+ const SECRET_BROKER_FUNCTIONS = new Set([
3
+ 'pikkuConsoleSetSecret',
4
+ 'pikkuConsoleGetSecret',
5
+ 'pikkuConsoleHasSecret',
6
+ ])
7
+
8
+ /** Whether this function keeps the full `SecretService`. */
9
+ export const isSecretBrokerFunction = (functionId: string): boolean =>
10
+ SECRET_BROKER_FUNCTIONS.has(functionId) ||
11
+ SECRET_BROKER_FUNCTIONS.has(functionId.split(':').pop() ?? '')
@@ -1,3 +1,4 @@
1
+ import type { SecretUsage } from './extract-secret-usage.js'
1
2
  import type { JSONValue } from '@pikku/core'
2
3
  import type {
3
4
  InspectorDiagnostic,
@@ -201,6 +202,7 @@ export interface SerializableInspectorState {
201
202
  secrets: {
202
203
  definitions: InspectorState['secrets']['definitions']
203
204
  files: string[]
205
+ usage: Record<string, SecretUsage>
204
206
  }
205
207
  credentials: {
206
208
  definitions: InspectorState['credentials']['definitions']
@@ -210,6 +212,14 @@ export interface SerializableInspectorState {
210
212
  definitions: InspectorState['scopes']['definitions']
211
213
  files: string[]
212
214
  }
215
+ systemRoles: {
216
+ definitions: InspectorState['systemRoles']['definitions']
217
+ files: string[]
218
+ }
219
+ personas: {
220
+ definitions: InspectorState['personas']['definitions']
221
+ files: string[]
222
+ }
213
223
  variables: {
214
224
  definitions: InspectorState['variables']['definitions']
215
225
  files: string[]
@@ -416,6 +426,7 @@ export function serializeInspectorState(
416
426
  secrets: {
417
427
  definitions: state.secrets.definitions,
418
428
  files: Array.from(state.secrets.files),
429
+ usage: Object.fromEntries(state.secrets.usage),
419
430
  },
420
431
  credentials: {
421
432
  definitions: state.credentials.definitions,
@@ -425,6 +436,14 @@ export function serializeInspectorState(
425
436
  definitions: state.scopes.definitions,
426
437
  files: Array.from(state.scopes.files),
427
438
  },
439
+ systemRoles: {
440
+ definitions: state.systemRoles.definitions,
441
+ files: Array.from(state.systemRoles.files),
442
+ },
443
+ personas: {
444
+ definitions: state.personas.definitions,
445
+ files: Array.from(state.personas.files),
446
+ },
428
447
  variables: {
429
448
  definitions: state.variables.definitions,
430
449
  files: Array.from(state.variables.files),
@@ -611,6 +630,7 @@ export function deserializeInspectorState(
611
630
  secrets: {
612
631
  definitions: data.secrets?.definitions || [],
613
632
  files: new Set(data.secrets?.files || []),
633
+ usage: new Map(Object.entries(data.secrets?.usage || {})),
614
634
  },
615
635
  credentials: {
616
636
  definitions: data.credentials?.definitions || [],
@@ -620,6 +640,14 @@ export function deserializeInspectorState(
620
640
  definitions: data.scopes?.definitions || [],
621
641
  files: new Set(data.scopes?.files || []),
622
642
  },
643
+ systemRoles: {
644
+ definitions: data.systemRoles?.definitions || [],
645
+ files: new Set(data.systemRoles?.files || []),
646
+ },
647
+ personas: {
648
+ definitions: data.personas?.definitions || [],
649
+ files: new Set(data.personas?.files || []),
650
+ },
623
651
  variables: {
624
652
  definitions: data.variables?.definitions || [],
625
653
  files: new Set(data.variables?.files || []),
@@ -13,6 +13,9 @@ const serializeGroupMap = (
13
13
  count: meta.count,
14
14
  instanceIds: meta.instanceIds,
15
15
  isFactory: meta.isFactory,
16
+ ...(meta.additionalRegistrations && {
17
+ additionalRegistrations: meta.additionalRegistrations,
18
+ }),
16
19
  }
17
20
  }
18
21
  return result