@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
@@ -10,10 +10,14 @@ import type {
10
10
  MiddlewareMetadata,
11
11
  PermissionMetadata,
12
12
  } from '@pikku/core'
13
+ import type ts from 'typescript'
13
14
  import { existsSync, readFileSync } from 'node:fs'
14
15
  import { join } from 'node:path'
15
16
  import { extractTypeKeys } from './type-utils.js'
16
17
  import { ErrorCode } from '../error-codes.js'
18
+ import { isSecretBrokerFunction } from './secret-brokers.js'
19
+ import { findSecretAliasServices } from './secret-alias-services.js'
20
+ import { relative } from 'node:path'
17
21
  import { AUTH_HANDLER_FUNC_ID } from '../add/add-auth.js'
18
22
  import { flattenScopeDefinitions } from '@pikku/core/scope'
19
23
  import type { WorkflowStepMeta } from '@pikku/core/workflow'
@@ -611,6 +615,9 @@ const serializeGroupMap = (
611
615
  count: meta.count,
612
616
  instanceIds: meta.instanceIds,
613
617
  isFactory: meta.isFactory,
618
+ ...(meta.additionalRegistrations && {
619
+ additionalRegistrations: meta.additionalRegistrations,
620
+ }),
614
621
  }
615
622
  }
616
623
  return result
@@ -745,8 +752,11 @@ export function validateScenarioServices(
745
752
  *
746
753
  * - the step target must be a static string literal (PKU678) — otherwise it
747
754
  * can't be bundled, typed or drawn;
748
- * - a `browser: true` step must be given an actor (PKU677), since the browser
749
- * context signs in as that persona.
755
+ * - a step declaring a `browser` binding must be given an actor (PKU677), since
756
+ * the browser context signs in as that persona. It is checked statically even
757
+ * though the binding only runs on a browser run: a step that can never be
758
+ * driven by a human is a latent failure, not a passing default run.
759
+ * - a scenario written as a step ladder must assert something (PKU680).
750
760
  *
751
761
  * It also marks the step's function as used, so `scenario run` boots the
752
762
  * singletons the step actually needs.
@@ -755,9 +765,12 @@ export function validateScenarioSteps(
755
765
  logger: InspectorLogger,
756
766
  state: InspectorState | Omit<InspectorState, 'typesLookup'>
757
767
  ): void {
768
+ const phasesSeen = new Set<string>()
769
+
758
770
  const visit = (workflowName: string, steps: WorkflowStepMeta[]): void => {
759
771
  for (const step of steps) {
760
772
  if (step.type === 'scenarioStep') {
773
+ phasesSeen.add(step.phase)
761
774
  if (step.stepFunc === DYNAMIC_SCENARIO_STEP_TARGET) {
762
775
  logger.critical(
763
776
  ErrorCode.SCENARIO_STEP_TARGET_NOT_STATIC,
@@ -769,10 +782,13 @@ export function validateScenarioSteps(
769
782
  }
770
783
  state.serviceAggregation.usedFunctions.add(step.stepFunc)
771
784
  const stepMeta = state.functions.meta[step.stepFunc]
772
- if (stepMeta?.scenarioStepBrowser && !step.actor) {
785
+ if (
786
+ stepMeta?.scenarioStepSurfaces?.includes('browser') &&
787
+ !step.actor
788
+ ) {
773
789
  logger.critical(
774
790
  ErrorCode.SCENARIO_BROWSER_STEP_NEEDS_ACTOR,
775
- `Scenario '${workflowName}' calls browser step '${step.stepFunc}' without an actor. ` +
791
+ `Scenario '${workflowName}' calls step '${step.stepFunc}', which declares a browser binding, without an actor. ` +
776
792
  `Pass { actor: actors.<name> } so the browser context signs in as that persona.`
777
793
  )
778
794
  }
@@ -795,7 +811,20 @@ export function validateScenarioSteps(
795
811
  }
796
812
 
797
813
  for (const [workflowName, meta] of Object.entries(state.workflows.meta)) {
814
+ phasesSeen.clear()
798
815
  visit(workflowName, meta.steps ?? [])
816
+ // A scenario written as a step ladder but with no `then` proves only that
817
+ // nothing threw. It is also invisible to witness coverage — it contributes
818
+ // 0/0, so it can never lower the number — which makes an assertion-free
819
+ // flow the cheapest way to make a suite look better covered than it is.
820
+ if (phasesSeen.size > 0 && !phasesSeen.has('then')) {
821
+ logger.critical(
822
+ ErrorCode.SCENARIO_HAS_NO_ASSERTION,
823
+ `Scenario '${workflowName}' has ${[...phasesSeen].join('/')} steps but never asserts. ` +
824
+ `Add a scenario.then(...) naming what the actor should now see — a flow with no 'then' ` +
825
+ `only proves nothing threw, and contributes nothing to witness coverage.`
826
+ )
827
+ }
799
828
  }
800
829
  }
801
830
 
@@ -863,13 +892,18 @@ export function validateSchemaWiringSeparation(
863
892
  wiringFiles.add(file)
864
893
  }
865
894
 
866
- // Middleware wirings (addHTTPMiddleware calls)
867
- for (const meta of state.http.routeMiddleware.values()) {
868
- wiringFiles.add(meta.sourceFile)
869
- }
870
- for (const meta of state.middleware.tagMiddleware.values()) {
871
- wiringFiles.add(meta.sourceFile)
895
+ // Middleware wirings (addHTTPMiddleware calls). A group can be registered
896
+ // from more than one file, and every one of them is a wiring file.
897
+ const addGroupFiles = (groups: Map<string, MiddlewareGroupMeta>) => {
898
+ for (const meta of groups.values()) {
899
+ wiringFiles.add(meta.sourceFile)
900
+ for (const registration of meta.additionalRegistrations ?? []) {
901
+ wiringFiles.add(registration.sourceFile)
902
+ }
903
+ }
872
904
  }
905
+ addGroupFiles(state.http.routeMiddleware)
906
+ addGroupFiles(state.middleware.tagMiddleware)
873
907
 
874
908
  // Check for overlap
875
909
  for (const file of schemaFiles) {
@@ -888,6 +922,101 @@ export function validateSchemaWiringSeparation(
888
922
  }
889
923
  }
890
924
 
925
+ /** Marks the functions that keep the full `SecretService`. */
926
+ export function computeSecretBrokers(state: InspectorState): void {
927
+ for (const [id, meta] of Object.entries(state.functions.meta)) {
928
+ if (isSecretBrokerFunction(meta.pikkuFuncId ?? id)) {
929
+ meta.secretBroker = true
930
+ }
931
+ }
932
+ }
933
+
934
+ /**
935
+ * Refuses a singleton service that is a `SecretService` under another name.
936
+ *
937
+ * `secrets` is omitted from every function-facing services type, but
938
+ * `pikkuServices(async ({ secrets }) => ({ cfg: secrets }))` re-exposes it under
939
+ * a name the omit does not cover. A name-based rule misses this; the type does
940
+ * not.
941
+ */
942
+ export function validateNoSecretAliasServices(
943
+ logger: InspectorLogger,
944
+ checker: ts.TypeChecker,
945
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
946
+ ): void {
947
+ if (!('typesLookup' in state)) {
948
+ return
949
+ }
950
+ const singletonServicesTypes = state.typesLookup.get('SingletonServices')
951
+ if (!singletonServicesTypes?.length) {
952
+ return
953
+ }
954
+ const aliases = findSecretAliasServices(singletonServicesTypes[0]!, checker)
955
+ if (aliases.length === 0) {
956
+ return
957
+ }
958
+
959
+ const report = (kind: string, id: string, services: string[]) => {
960
+ const used = services.filter((service) => aliases.includes(service))
961
+ if (used.length === 0) {
962
+ return
963
+ }
964
+ logger.critical(
965
+ ErrorCode.SECRET_SERVICE_ALIASED,
966
+ `${kind} '${id}' receives ${used.map((s) => `'${s}'`).join(', ')}, which ${used.length === 1 ? 'is' : 'are'} a SecretService under another name. ` +
967
+ `SecretService is confined to pikkuServices, pikkuWireServices, addon service factories and middleware — give a service the secret value when you construct it and expose only what the function needs.`
968
+ )
969
+ }
970
+
971
+ for (const [id, meta] of Object.entries(state.functions.meta)) {
972
+ if (meta.secretBroker) {
973
+ continue
974
+ }
975
+ report('Function', id, meta.services?.services ?? [])
976
+ }
977
+ for (const [id, def] of Object.entries(state.permissions.definitions)) {
978
+ report('Permission', id, def.services?.services ?? [])
979
+ }
980
+ }
981
+
982
+ /**
983
+ * Reports secret reads the catalogue does not account for.
984
+ *
985
+ * `getSecret('X')` with no matching `defineSecret` is a value that will be missing
986
+ * at first request rather than at deploy; a non-literal key is a read the
987
+ * manifest cannot cover, so a per-unit scope cannot be narrowed around it.
988
+ */
989
+ export function validateSecretUsage(
990
+ logger: InspectorLogger,
991
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
992
+ ): void {
993
+ const declared = new Set(state.secrets.definitions.map((d) => d.secretId))
994
+ for (const definition of state.secrets.definitions) {
995
+ if (definition.oauth2?.tokenSecretId) {
996
+ declared.add(definition.oauth2.tokenSecretId)
997
+ }
998
+ }
999
+
1000
+ for (const [file, usage] of state.secrets.usage) {
1001
+ const relativeFile = relative(state.rootDir, file)
1002
+ const undeclared = usage.keys.filter((key) => !declared.has(key))
1003
+ if (undeclared.length > 0) {
1004
+ logger.diagnostic({
1005
+ severity: 'warn',
1006
+ code: ErrorCode.SECRET_NOT_DECLARED,
1007
+ message: `${relativeFile} reads ${undeclared.map((k) => `'${k}'`).join(', ')}, which no defineSecret declares. Declare it so it is validated at deploy time and shown to whoever has to provision it.`,
1008
+ })
1009
+ }
1010
+ if (usage.dynamic.length > 0) {
1011
+ logger.diagnostic({
1012
+ severity: 'warn',
1013
+ code: ErrorCode.SECRET_KEY_NOT_STATIC,
1014
+ message: `${relativeFile} reads a secret with a non-literal key (${usage.dynamic.join(', ')}). The deployment cannot narrow its secret scope around a key it cannot see.`,
1015
+ })
1016
+ }
1017
+ }
1018
+ }
1019
+
891
1020
  export function computeDiagnostics(state: InspectorState): void {
892
1021
  const diagnostics: InspectorDiagnostic[] = []
893
1022
 
@@ -969,7 +1098,7 @@ export function computeDiagnostics(state: InspectorState): void {
969
1098
 
970
1099
  /**
971
1100
  * Validates that every scope referenced by a function is declared via
972
- * `wireScope`. Runs after all visitors, so declaration order does not matter.
1101
+ * `defineScope`. Runs after all visitors, so declaration order does not matter.
973
1102
  *
974
1103
  * A `*` suffix is a wildcard requirement: `admin:*` requires the `admin` scope
975
1104
  * to be declared, and grants its whole subtree.
@@ -1001,7 +1130,176 @@ export function validateScopeReferences(
1001
1130
  const available = Array.from(declared)
1002
1131
  logger.critical(
1003
1132
  ErrorCode.INVALID_VALUE,
1004
- `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with wireScope. Available scopes: ${available.join(', ') || 'none'}`
1133
+ `Function '${funcName}' requires scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`
1134
+ )
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1139
+
1140
+ /**
1141
+ * Resolves every `tools:` entry on every agent and reports the ones that do not
1142
+ * land on a real function, or that land on one with nothing to tell the model.
1143
+ *
1144
+ * `ref('todos:listTodos')` is a bare string to the inspector — `ref` is not
1145
+ * resolved, only unwrapped — so until now a reference to a function that never
1146
+ * existed generated cleanly and failed at agent-run time. Addon namespaces come
1147
+ * from `wireAddon`, and each addon's generated metadata is read off disk, which
1148
+ * is the only view of it this project has.
1149
+ *
1150
+ * The description check is separate and opt-in (`--strict-meta`), because the
1151
+ * description is what the model is told the tool does: without one, the tool is
1152
+ * offered to it under its own name (`ai-agent-prepare.ts`), which is a silent
1153
+ * quality loss rather than a failure. A title deliberately does not satisfy it —
1154
+ * a title labels a tool in a UI, it does not tell a model when to reach for it.
1155
+ */
1156
+ export function validateAgentToolReferences(
1157
+ logger: InspectorLogger,
1158
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>,
1159
+ options: InspectorOptions = {}
1160
+ ): void {
1161
+ const agents = Object.entries(state.agents.agentsMeta)
1162
+ if (agents.length === 0) return
1163
+
1164
+ const { wireAddonDeclarations } = state.rpc
1165
+
1166
+ for (const [agentKey, agent] of agents) {
1167
+ const where = agent.sourceFile ? ` (${agent.sourceFile})` : ''
1168
+
1169
+ for (const tool of agent.tools ?? []) {
1170
+ // A workflow reference is resolved against the workflow map, not here.
1171
+ if (tool.startsWith('workflow:')) continue
1172
+
1173
+ const separator = tool.indexOf(':')
1174
+ let meta: { description?: string; title?: string } | undefined
1175
+
1176
+ if (separator === -1) {
1177
+ const funcId = state.rpc.internalMeta[tool] ?? tool
1178
+ meta = state.functions.meta[funcId]
1179
+ if (!meta) {
1180
+ logger.critical(
1181
+ ErrorCode.AGENT_TOOL_NOT_FOUND,
1182
+ `AI agent '${agentKey}'${where} references tool '${tool}', which is not a function in this project.`
1183
+ )
1184
+ continue
1185
+ }
1186
+ } else {
1187
+ const namespace = tool.slice(0, separator)
1188
+ const funcName = tool.slice(separator + 1)
1189
+ const addon = wireAddonDeclarations?.get(namespace)
1190
+ if (!addon) {
1191
+ const known = Array.from(wireAddonDeclarations?.keys() ?? [])
1192
+ logger.critical(
1193
+ ErrorCode.AGENT_TOOL_UNKNOWN_NAMESPACE,
1194
+ `AI agent '${agentKey}'${where} references tool '${tool}', but no addon is wired under the namespace '${namespace}'. ` +
1195
+ `Wired namespaces: ${known.join(', ') || 'none'}.`
1196
+ )
1197
+ continue
1198
+ }
1199
+
1200
+ // An addon that has not been built yet contributed no metadata, which
1201
+ // is not the same as one whose function is missing.
1202
+ const addonMeta = state.addonFunctions[namespace]
1203
+ if (!addonMeta) continue
1204
+
1205
+ meta = addonMeta[funcName]
1206
+ if (!meta) {
1207
+ logger.critical(
1208
+ ErrorCode.AGENT_TOOL_NOT_FOUND,
1209
+ `AI agent '${agentKey}'${where} references tool '${tool}', but addon '${namespace}' ('${addon.package}') exposes no function '${funcName}'.`
1210
+ )
1211
+ continue
1212
+ }
1213
+ }
1214
+
1215
+ if (options.strictMeta && !meta.description) {
1216
+ logger.critical(
1217
+ ErrorCode.AGENT_TOOL_MISSING_DESCRIPTION,
1218
+ `AI agent '${agentKey}'${where} uses tool '${tool}', which has no description. ` +
1219
+ `An agent tool's description is what the model is told it does; without one it is offered the tool under its own name. ` +
1220
+ `Add a 'description' to '${tool}'${meta.title ? " (a 'title' does not count — it labels the tool, it does not explain it)" : ''}.`
1221
+ )
1222
+ }
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ /**
1228
+ * Validates that every scope granted by a system role is declared via
1229
+ * `defineScope`. Runs after all visitors, so declaration order does not matter.
1230
+ *
1231
+ * The same rule functions get, for a sharper reason: a role granting an
1232
+ * undeclared scope is not merely useless, it is *invisibly* useless. No
1233
+ * function checks the scope, so the role appears to work — every call a
1234
+ * persona holding it makes succeeds or fails for unrelated reasons, and the
1235
+ * authorization boundary it was written to describe is never exercised.
1236
+ *
1237
+ * A `*` suffix grants a subtree; the node it hangs off must exist.
1238
+ */
1239
+ export function validateSystemRoleScopes(
1240
+ logger: InspectorLogger,
1241
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
1242
+ ): void {
1243
+ if (!state.systemRoles.definitions.length) {
1244
+ return
1245
+ }
1246
+
1247
+ const declared = new Set(
1248
+ flattenScopeDefinitions(state.scopes.definitions).map((s) => s.id)
1249
+ )
1250
+
1251
+ for (const role of state.systemRoles.definitions) {
1252
+ for (const scope of role.scopes) {
1253
+ if (scope === '*') {
1254
+ logger.critical(
1255
+ ErrorCode.INVALID_VALUE,
1256
+ `System role '${role.name}' grants the bare wildcard scope '*'. Grant the roots it should cover instead — '*' hides what the role actually confers.`
1257
+ )
1258
+ continue
1259
+ }
1260
+
1261
+ const declaredForm = scope.endsWith(':*') ? scope.slice(0, -2) : scope
1262
+ if (!declared.has(declaredForm)) {
1263
+ const available = Array.from(declared)
1264
+ logger.critical(
1265
+ ErrorCode.INVALID_VALUE,
1266
+ `System role '${role.name}' grants scope '${scope}' which is not declared. Declare it with defineScope. Available scopes: ${available.join(', ') || 'none'}`
1267
+ )
1268
+ }
1269
+ }
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * Validates that every role a persona names is declared with
1275
+ * `defineSystemRole`. Runs after all visitors, so declaration order does not
1276
+ * matter.
1277
+ *
1278
+ * Custom roles are refused rather than tolerated. A role composed in the
1279
+ * console does not exist at build time and can be deleted after it, so a
1280
+ * persona pinned to one silently stops testing what it claims to — and the
1281
+ * symptom is a scatter of 403s that read like authorization findings rather
1282
+ * than like a deleted role.
1283
+ */
1284
+ export function validatePersonaRoles(
1285
+ logger: InspectorLogger,
1286
+ state: InspectorState | Omit<InspectorState, 'typesLookup'>
1287
+ ): void {
1288
+ if (!state.personas.definitions.length) {
1289
+ return
1290
+ }
1291
+
1292
+ const declared = new Set(state.systemRoles.definitions.map((r) => r.name))
1293
+
1294
+ for (const persona of state.personas.definitions) {
1295
+ for (const role of persona.roles) {
1296
+ if (!declared.has(role)) {
1297
+ const available = Array.from(declared).sort()
1298
+ logger.critical(
1299
+ ErrorCode.INVALID_VALUE,
1300
+ `Persona '${persona.id}' holds role '${role}', which is not declared with defineSystemRole. ` +
1301
+ `A persona may only name a system role — a role composed in the console can be deleted, and the persona would go on claiming to test it. ` +
1302
+ `Declared roles: ${available.join(', ') || 'none'}`
1005
1303
  )
1006
1304
  }
1007
1305
  }
@@ -0,0 +1,124 @@
1
+ import { test, describe } from 'node:test'
2
+ import { strict as assert } from 'node:assert'
3
+ import {
4
+ mkdtempSync,
5
+ rmSync,
6
+ statSync,
7
+ utimesSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import {
13
+ readDiskTSSchemas,
14
+ writeDiskTSSchemas,
15
+ type SchemaDep,
16
+ } from './schema-generator.js'
17
+ import type { InspectorLogger } from '../types.js'
18
+
19
+ const silentLogger = {
20
+ debug: () => {},
21
+ info: () => {},
22
+ warn: () => {},
23
+ error: () => {},
24
+ } as unknown as InspectorLogger
25
+
26
+ const KEY = 'test-key'
27
+ const SCHEMAS = { Thing: { type: 'object' } }
28
+
29
+ /** A cache directory and one source file the cached schemas claim to derive from. */
30
+ function makeCache(sourceContent: string): {
31
+ cacheDir: string
32
+ source: string
33
+ deps: SchemaDep[]
34
+ cleanup: () => void
35
+ } {
36
+ const dir = mkdtempSync(join(tmpdir(), 'pikku-schema-cache-'))
37
+ const source = join(dir, 'thing.ts')
38
+ writeFileSync(source, sourceContent)
39
+ const stat = statSync(source)
40
+ return {
41
+ cacheDir: dir,
42
+ source,
43
+ deps: [{ path: source, mtimeMs: stat.mtimeMs, size: stat.size }],
44
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
45
+ }
46
+ }
47
+
48
+ describe('TS schema disk cache', () => {
49
+ test('serves the cached schemas while their sources are untouched', () => {
50
+ const { cacheDir, deps, cleanup } = makeCache('export interface Thing {}')
51
+ try {
52
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
53
+ const hit = readDiskTSSchemas(silentLogger, cacheDir, KEY)
54
+ assert.deepEqual(hit?.schemas, SCHEMAS)
55
+ } finally {
56
+ cleanup()
57
+ }
58
+ })
59
+
60
+ test('discards the cache when a type it was built from changes', () => {
61
+ const { cacheDir, source, deps, cleanup } = makeCache(
62
+ 'export interface Thing {}'
63
+ )
64
+ try {
65
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
66
+ // The shape changes but the name does not, so the cache key — which hashes
67
+ // only the synthesized custom-types source — is identical. This is the case
68
+ // that used to serve a stale schema, and it survived `rm -rf .pikku`
69
+ // because the cache lives under node_modules/.cache.
70
+ writeFileSync(source, 'export interface Thing { added: string }')
71
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
72
+ } finally {
73
+ cleanup()
74
+ }
75
+ })
76
+
77
+ test('discards the cache when a source is rewritten to the same size', () => {
78
+ const { cacheDir, source, deps, cleanup } = makeCache('export type A = 1')
79
+ try {
80
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
81
+ // Same byte count, different type: size alone would call this unchanged, so
82
+ // the mtime half of the fingerprint is what has to catch it.
83
+ writeFileSync(source, 'export type A = 2')
84
+ utimesSync(source, new Date(), new Date(Date.now() + 5000))
85
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
86
+ } finally {
87
+ cleanup()
88
+ }
89
+ })
90
+
91
+ test('discards the cache when a source it was built from is deleted', () => {
92
+ const { cacheDir, source, deps, cleanup } = makeCache('export type A = 1')
93
+ try {
94
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
95
+ rmSync(source)
96
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
97
+ } finally {
98
+ cleanup()
99
+ }
100
+ })
101
+
102
+ test('discards a cache written before deps were tracked', () => {
103
+ const { cacheDir, cleanup } = makeCache('export type A = 1')
104
+ try {
105
+ writeFileSync(
106
+ join(cacheDir, 'ts-schemas.json'),
107
+ JSON.stringify({ key: KEY, schemas: SCHEMAS })
108
+ )
109
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
110
+ } finally {
111
+ cleanup()
112
+ }
113
+ })
114
+
115
+ test('still misses on a different key', () => {
116
+ const { cacheDir, deps, cleanup } = makeCache('export type A = 1')
117
+ try {
118
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
119
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, 'other'), null)
120
+ } finally {
121
+ cleanup()
122
+ }
123
+ })
124
+ })
@@ -5,7 +5,7 @@ import { tmpdir } from 'os'
5
5
  import { join } from 'path'
6
6
 
7
7
  import { getInitialInspectorState } from '../inspector.js'
8
- import { generateAllSchemas } from './schema-generator.js'
8
+ import { generateAllSchemas, schemaRuntimeFile } from './schema-generator.js'
9
9
 
10
10
  const debugLines: string[] = []
11
11
  const logger = {
@@ -162,3 +162,64 @@ describe('generateAllSchemas', () => {
162
162
  )
163
163
  })
164
164
  })
165
+
166
+ /**
167
+ * A schema imported from a built workspace package resolves to that package's
168
+ * declaration file, and a `.d.ts` has no runtime exports at all — every one of
169
+ * them is a type. Importing it to read the zod object yields an empty module and
170
+ * every schema in it is reported missing, so the emitted JS beside it is what has
171
+ * to be imported.
172
+ */
173
+ describe('schemaRuntimeFile', () => {
174
+ test('a source file the developer wrote is imported as it is', () => {
175
+ const written = join(projectDir, 'src', 'types.ts')
176
+ assert.equal(schemaRuntimeFile(written), written)
177
+ })
178
+
179
+ test('a declaration file resolves to the JS emitted beside it', () => {
180
+ writeFileSync(
181
+ join(projectDir, 'src', 'reindex.d.ts'),
182
+ 'export declare const a: number\n'
183
+ )
184
+ writeFileSync(join(projectDir, 'src', 'reindex.js'), 'export const a = 1\n')
185
+ assert.equal(
186
+ schemaRuntimeFile(join(projectDir, 'src', 'reindex.d.ts')),
187
+ join(projectDir, 'src', 'reindex.js')
188
+ )
189
+ })
190
+
191
+ test('.d.mts resolves to the ESM file beside it', () => {
192
+ writeFileSync(
193
+ join(projectDir, 'src', 'esm.d.mts'),
194
+ 'export declare const a: number\n'
195
+ )
196
+ writeFileSync(join(projectDir, 'src', 'esm.mjs'), 'export const a = 1\n')
197
+ assert.equal(
198
+ schemaRuntimeFile(join(projectDir, 'src', 'esm.d.mts')),
199
+ join(projectDir, 'src', 'esm.mjs')
200
+ )
201
+ })
202
+
203
+ test('.d.cts resolves to the CommonJS file beside it', () => {
204
+ // Its own test rather than a second assertion above: the two extensions are
205
+ // separate branches, and one covering both is one that passes while half of
206
+ // it is broken.
207
+ writeFileSync(
208
+ join(projectDir, 'src', 'commonjs.d.cts'),
209
+ 'export declare const a: number\n'
210
+ )
211
+ writeFileSync(join(projectDir, 'src', 'commonjs.cjs'), 'exports.a = 1\n')
212
+ assert.equal(
213
+ schemaRuntimeFile(join(projectDir, 'src', 'commonjs.d.cts')),
214
+ join(projectDir, 'src', 'commonjs.cjs')
215
+ )
216
+ })
217
+
218
+ test('a declaration file with no emitted JS keeps its own path', () => {
219
+ // So the "could not find exported schema" error still names a file the
220
+ // developer can open, rather than one that was never written.
221
+ const orphan = join(projectDir, 'src', 'types-only.d.ts')
222
+ writeFileSync(orphan, 'export declare const a: number\n')
223
+ assert.equal(schemaRuntimeFile(orphan), orphan)
224
+ })
225
+ })