Compare commits

..

12 Commits

Author SHA1 Message Date
eledobleefe
01894b62ca Fix error in properties types 2026-01-09 13:06:57 +01:00
eledobleefe
44a4020139 Merge main 2026-01-09 12:05:01 +01:00
Stephanie Hingtgen
b0785e506f Dashboard Tags: Validate max length (#116047) 2026-01-09 03:57:39 -07:00
Stephanie Hingtgen
5f8668b3aa Preferences: Add API validation and update documentation (#116045) 2026-01-09 03:57:15 -07:00
eledobleefe
fc49c8d47a POC real example 2025-12-09 11:42:41 +01:00
eledobleefe
5c17e4e05e Run prettier 2025-12-09 11:00:03 +01:00
eledobleefe
bdad692470 Merge branch 'main' into eledobleefe/analytics-framework-user-test 2025-12-09 10:18:04 +01:00
eledobleefe
61097047ff Add command to run the script 2025-11-27 16:41:56 +01:00
eledobleefe
5b234a251e Merge branch 'main' into eledobleefe/analytics-framework-user-test 2025-11-27 15:55:56 +01:00
eledobleefe
9acafa2b50 Add rules 2025-11-27 01:14:05 +01:00
eledobleefe
80c7d17543 Copy analytics frameworks code 2025-11-27 00:37:17 +01:00
eledobleefe
9a7a05be50 Install ts-morph 2025-11-27 00:33:34 +01:00
36 changed files with 981 additions and 53 deletions

View File

@@ -5,7 +5,7 @@ go 1.25.5
require (
github.com/grafana/grafana-app-sdk v0.48.7
github.com/grafana/grafana-app-sdk/logging v0.48.7
github.com/prometheus/common v0.67.5
github.com/prometheus/common v0.67.4
k8s.io/apimachinery v0.34.3
k8s.io/kube-openapi v0.0.0-20251125145642-4e65d59e963e
)

View File

@@ -128,8 +128,8 @@ github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/puzpuzpuz/xsync/v2 v2.5.1 h1:mVGYAvzDSu52+zaGyNjC+24Xw2bQi3kTr4QJ6N9pIIU=

View File

@@ -186,7 +186,7 @@ For the JSON and field usage notes, refer to the [links schema documentation](ht
### `tags`
The tags associated with the dashboard:
Tags associated with the dashboard. Each tag can be up to 50 characters long.
` [...string]`

View File

@@ -25,7 +25,7 @@ Keys:
- **theme** - One of: `light`, `dark`, or an empty string for the default theme
- **homeDashboardId** - Deprecated. Use `homeDashboardUID` instead.
- **homeDashboardUID**: The `:uid` of a dashboard
- **timezone** - One of: `utc`, `browser`, or an empty string for the default
- **timezone** - Any valid IANA timezone string (e.g., `America/New_York`, `Europe/London`), `utc`, `browser`, or an empty string for the default.
Omitting a key will cause the current value to be replaced with the
system default value.

View File

@@ -6,6 +6,7 @@
"version": "12.4.0-pre",
"repository": "github:grafana/grafana",
"scripts": {
"analytics-report": "node --experimental-strip-types ./scripts/cli/analytics/main.mts",
"check-frontend-dev": "./scripts/check-frontend-dev.sh",
"build": "NODE_ENV=production nx exec --verbose -- webpack --config scripts/webpack/webpack.prod.js",
"build:nominify": "yarn run build -- --env noMinify=1",
@@ -430,6 +431,7 @@
"swagger-ui-react": "5.30.3",
"symbol-observable": "4.0.0",
"systemjs": "6.15.1",
"ts-morph": "^27.0.2",
"tslib": "2.8.1",
"tween-functions": "^1.2.0",
"type-fest": "^4.18.2",

View File

@@ -5312,7 +5312,8 @@ export type PatchPrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export type UpdatePrefsCmd = {
@@ -5325,7 +5326,8 @@ export type UpdatePrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark' | 'system';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export type OrgUserDto = {

View File

@@ -86,7 +86,8 @@ export type PatchPrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export type UpdatePrefsCmd = {
@@ -99,7 +100,8 @@ export type UpdatePrefsCmd = {
queryHistory?: QueryHistoryPreference;
regionalFormat?: string;
theme?: 'light' | 'dark' | 'system';
timezone?: 'utc' | 'browser';
/** Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string */
timezone?: string;
weekStart?: string;
};
export const {

View File

@@ -181,3 +181,24 @@ When a violation is detected, the rule reports:
```
Import '../status-history/utils' reaches outside the 'histogram' plugin directory. Plugins should only import from external dependencies or relative paths within their own directory.
```
### `tracking-event-creation`
Checks that the process to create a tracking event is followed in the right way.
#### `eventFactoryLiterals`
Check if the values passed to `createEventFactory` are literals.
```tsx
// Bad ❌
const repo = 'grafana';
const createUnifiedHistoryEvent = createEventFactory(repo, 'unified_history');
// Bad ❌
const history = 'history';
const createUnifiedHistoryEvent = createEventFactory('grafana', `unified_${history}`);
// Good ✅
const createUnifiedHistoryEvent = createEventFactory('grafana', 'unified_history');
```

View File

@@ -5,6 +5,7 @@ const themeTokenUsage = require('./rules/theme-token-usage.cjs');
const noRestrictedImgSrcs = require('./rules/no-restricted-img-srcs.cjs');
const consistentStoryTitles = require('./rules/consistent-story-titles.cjs');
const noPluginExternalImportPaths = require('./rules/no-plugin-external-import-paths.cjs');
const trackingEventCreation = require('./rules/tracking-event-creation.cjs');
module.exports = {
rules: {
@@ -15,5 +16,6 @@ module.exports = {
'no-restricted-img-srcs': noRestrictedImgSrcs,
'consistent-story-titles': consistentStoryTitles,
'no-plugin-external-import-paths': noPluginExternalImportPaths,
'tracking-event-creation': trackingEventCreation,
},
};

View File

@@ -0,0 +1,158 @@
// @ts-check
const { ESLintUtils, AST_NODE_TYPES } = require('@typescript-eslint/utils');
const createRule = ESLintUtils.RuleCreator(
(name) => `https://github.com/grafana/grafana/blob/main/packages/grafana-eslint-rules/README.md#${name}`
);
const trackingEventCreation = createRule({
create(context) {
// Track what name createEventFactory is imported as
let createEventFactoryName = 'createEventFactory';
// Track if createEventFactory is imported
let isCreateEventFactoryImported = false;
// Track variables that store createEventFactory calls
const eventFactoryVariables = new Set();
return {
ImportSpecifier(node) {
if (node.imported.type === AST_NODE_TYPES.Identifier && node.imported.name === 'createEventFactory') {
// Remember what name it was imported as (handles aliased imports)
createEventFactoryName = node.local.name;
isCreateEventFactoryImported = true;
}
},
VariableDeclarator(node) {
if (!isCreateEventFactoryImported) {
return;
}
// Track variables initialized with createEventFactory calls
if (
node.init?.type === AST_NODE_TYPES.CallExpression &&
node.init.callee.type === AST_NODE_TYPES.Identifier &&
node.init.callee.name === createEventFactoryName
) {
const variableName = node.id.type === AST_NODE_TYPES.Identifier && node.id.name;
if (variableName) {
eventFactoryVariables.add(variableName);
}
// Check if arguments are literals
const args = node.init.arguments;
const argsAreNotLiterals = args.some((arg) => arg.type !== AST_NODE_TYPES.Literal);
if (argsAreNotLiterals) {
return context.report({
node: node.init,
messageId: 'eventFactoryLiterals',
});
}
}
},
ExportNamedDeclaration(node) {
if (!isCreateEventFactoryImported) {
return;
}
if (
node.declaration?.type === AST_NODE_TYPES.VariableDeclaration &&
node.declaration.declarations[0].init?.type === AST_NODE_TYPES.CallExpression
) {
const callee = node.declaration.declarations[0].init.callee;
if (callee.type === AST_NODE_TYPES.Identifier && eventFactoryVariables.has(callee.name)) {
// Check for comments
// Check for comments
const comments = context.sourceCode.getCommentsBefore(node);
if (!comments || comments.length === 0) {
return context.report({
node,
messageId: 'missingFunctionComment',
});
}
const jsDocComment = comments.find((comment) => comment.value.slice(0, 1) === '*');
if (!jsDocComment) {
return context.report({
node,
messageId: 'missingJsDocComment',
});
}
}
}
},
TSInterfaceDeclaration(node) {
if (!isCreateEventFactoryImported) {
return;
}
// Check if interface extends TrackingEvent
let extendsTrackingEvent = false;
if (node.extends && node.extends.length > 0) {
const interfaceExtends = node.extends;
extendsTrackingEvent = interfaceExtends.some((extend) => {
return (
extend.expression.type === AST_NODE_TYPES.Identifier && extend.expression.name === 'TrackingEventProps'
);
});
}
if (!node.extends || !extendsTrackingEvent) {
return context.report({
node,
messageId: 'interfaceMustExtend',
});
}
//Check if the interface properties has comments
if (node.body.type === AST_NODE_TYPES.TSInterfaceBody) {
const properties = node.body.body;
properties.forEach((property) => {
const comments = context.sourceCode.getCommentsBefore(property);
if (!comments || comments.length === 0) {
return context.report({
node: property,
messageId: 'missingPropertyComment',
});
}
const jsDocComment = comments.find((comment) => comment.value.slice(0, 1) === '*');
if (!jsDocComment) {
return context.report({
node: property,
messageId: 'missingJsDocComment',
});
}
});
}
},
TSTypeAliasDeclaration(node) {
if (!isCreateEventFactoryImported) {
return;
}
// Check if types has comments
const comments = context.sourceCode.getCommentsBefore(node);
if (!comments || comments.length === 0) {
return context.report({
node,
messageId: 'missingPropertyComment',
});
}
},
};
},
name: 'tracking-event-creation',
meta: {
type: 'problem',
docs: {
description: 'Check that the tracking event is created in the right way',
},
messages: {
eventFactoryLiterals: 'Params passed to `createEventFactory` must be literals',
missingFunctionComment: 'Event function needs to have a description of its purpose',
missingPropertyComment: 'Event property needs to have a description of its purpose',
interfaceMustExtend: 'Interface must extend `TrackingEvent`',
missingJsDocComment: 'Comment needs to be a jsDoc comment (begin comment with `*`)',
},
schema: [],
},
defaultOptions: [],
});
module.exports = trackingEventCreation;

View File

@@ -54,6 +54,7 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
const [newTagName, setNewTagName] = useState('');
const styles = useStyles2(getStyles);
const theme = useTheme2();
const isTagTooLong = newTagName.length > 50;
const onNameChange = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
setNewTagName(event.target.value);
@@ -65,6 +66,9 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
const onAdd = (event?: React.MouseEvent | React.KeyboardEvent) => {
event?.preventDefault();
if (newTagName.length > 50) {
return;
}
if (!tags.includes(newTagName)) {
onChange(tags.concat(newTagName));
}
@@ -94,14 +98,17 @@ export const TagsInput = forwardRef<HTMLInputElement, Props>(
value={newTagName}
onKeyDown={onKeyboardAdd}
onBlur={onBlur}
invalid={invalid}
invalid={invalid || isTagTooLong}
suffix={
<Button
fill="text"
className={styles.addButtonStyle}
onClick={onAdd}
size="md"
disabled={newTagName.length <= 0}
disabled={newTagName.length <= 0 || isTagTooLong}
title={
isTagTooLong ? t('grafana-ui.tags-input.tag-too-long', 'Tag too long, max 50 characters') : undefined
}
>
<Trans i18nKey="grafana-ui.tags-input.add">Add</Trans>
</Button>

View File

@@ -13,7 +13,7 @@ type UpdatePrefsCmd struct {
// Deprecated: Use HomeDashboardUID instead
HomeDashboardID int64 `json:"homeDashboardId"`
HomeDashboardUID *string `json:"homeDashboardUID,omitempty"`
// Enum: utc,browser
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
Timezone string `json:"timezone"`
WeekStart string `json:"weekStart"`
QueryHistory *pref.QueryHistoryPreference `json:"queryHistory,omitempty"`
@@ -31,7 +31,7 @@ type PatchPrefsCmd struct {
// Default:0
// Deprecated: Use HomeDashboardUID instead
HomeDashboardID *int64 `json:"homeDashboardId,omitempty"`
// Enum: utc,browser
// Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string
Timezone *string `json:"timezone,omitempty"`
WeekStart *string `json:"weekStart,omitempty"`
Language *string `json:"language,omitempty"`

View File

@@ -134,6 +134,10 @@ func (hs *HTTPServer) patchPreferencesFor(ctx context.Context, orgID, userID, te
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
}
if dtoCmd.Timezone != nil && !pref.IsValidTimezone(*dtoCmd.Timezone) {
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
}
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
// nolint:staticcheck
dashboardID := dtoCmd.HomeDashboardID

View File

@@ -389,6 +389,11 @@ func (b *DashboardsAPIBuilder) validateCreate(ctx context.Context, a admission.A
return apierrors.NewBadRequest(err.Error())
}
// Validate tags
if err := validateDashboardTags(dashObj); err != nil {
return apierrors.NewBadRequest(err.Error())
}
id, err := identity.GetRequester(ctx)
if err != nil {
return fmt.Errorf("error getting requester: %w", err)
@@ -459,6 +464,11 @@ func (b *DashboardsAPIBuilder) validateUpdate(ctx context.Context, a admission.A
return apierrors.NewBadRequest(err.Error())
}
// Validate tags
if err := validateDashboardTags(newDashObj); err != nil {
return apierrors.NewBadRequest(err.Error())
}
// Validate folder existence if specified and changed
if !a.IsDryRun() && newAccessor.GetFolder() != oldAccessor.GetFolder() && newAccessor.GetFolder() != "" {
id, err := identity.GetRequester(ctx)
@@ -556,6 +566,32 @@ func getDashboardProperties(obj runtime.Object) (string, string, error) {
return title, refresh, nil
}
// validateDashboardTags validates that all dashboard tags are within the maximum length
func validateDashboardTags(obj runtime.Object) error {
var tags []string
switch d := obj.(type) {
case *dashv0.Dashboard:
tags = d.Spec.GetNestedStringSlice("tags")
case *dashv1.Dashboard:
tags = d.Spec.GetNestedStringSlice("tags")
case *dashv2alpha1.Dashboard:
tags = d.Spec.Tags
case *dashv2beta1.Dashboard:
tags = d.Spec.Tags
default:
return fmt.Errorf("unsupported dashboard version: %T", obj)
}
for _, tag := range tags {
if len(tag) > 50 {
return dashboards.ErrDashboardTagTooLong
}
}
return nil
}
func (b *DashboardsAPIBuilder) UpdateAPIGroupInfo(apiGroupInfo *genericapiserver.APIGroupInfo, opts builder.APIGroupOptions) error {
storageOpts := apistore.StorageOptions{
EnableFolderSupport: true,

View File

@@ -208,6 +208,11 @@ func (s *preferenceStorage) save(ctx context.Context, obj runtime.Object) (runti
// Create implements rest.Creater.
func (s *preferenceStorage) Create(ctx context.Context, obj runtime.Object, createValidation rest.ValidateObjectFunc, options *metav1.CreateOptions) (runtime.Object, error) {
if createValidation != nil {
if err := createValidation(ctx, obj); err != nil {
return nil, err
}
}
return s.save(ctx, obj)
}
@@ -223,6 +228,12 @@ func (s *preferenceStorage) Update(ctx context.Context, name string, objInfo res
return nil, false, err
}
if updateValidation != nil {
if err := updateValidation(ctx, obj, old); err != nil {
return nil, false, err
}
}
obj, err = s.save(ctx, obj)
return obj, false, err
}

View File

@@ -1,9 +1,14 @@
package preferences
import (
"context"
"fmt"
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authorization/authorizer"
"k8s.io/apiserver/pkg/registry/rest"
genericapiserver "k8s.io/apiserver/pkg/server"
@@ -24,7 +29,8 @@ import (
)
var (
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupBuilder = (*APIBuilder)(nil)
_ builder.APIGroupValidation = (*APIBuilder)(nil)
)
type APIBuilder struct {
@@ -108,3 +114,31 @@ func (b *APIBuilder) GetAPIRoutes(gv schema.GroupVersion) *builder.APIRoutes {
defs := b.GetOpenAPIDefinitions()(func(path string) spec.Ref { return spec.Ref{} })
return b.merger.GetAPIRoutes(defs)
}
// Validate validates that the preference object has valid theme and timezone (if specified)
func (b *APIBuilder) Validate(ctx context.Context, a admission.Attributes, o admission.ObjectInterfaces) error {
if a.GetResource().Resource != "preferences" {
return nil
}
op := a.GetOperation()
if op != admission.Create && op != admission.Update {
return nil
}
obj := a.GetObject()
p, ok := obj.(*preferences.Preferences)
if !ok {
return apierrors.NewBadRequest(fmt.Sprintf("expected Preferences object, got %T", obj))
}
if p.Spec.Timezone != nil && !pref.IsValidTimezone(*p.Spec.Timezone) {
return apierrors.NewBadRequest("invalid timezone: must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string")
}
if p.Spec.Theme != nil && *p.Spec.Theme != "" && !pref.IsValidThemeID(*p.Spec.Theme) {
return apierrors.NewBadRequest("invalid theme")
}
return nil
}

View File

@@ -542,6 +542,9 @@ func (d *dashboardStore) saveDashboard(ctx context.Context, sess *db.Session, cm
tags := dash.GetTags()
if len(tags) > 0 {
for _, tag := range tags {
if len(tag) > 50 {
return nil, dashboards.ErrDashboardTagTooLong
}
if _, err := sess.Insert(dashboardTag{DashboardId: dash.ID, Term: tag, OrgID: dash.OrgID, DashboardUID: dash.UID}); err != nil {
return nil, err
}

View File

@@ -79,6 +79,11 @@ var (
Reason: "message too long, max 500 characters",
StatusCode: 400,
}
ErrDashboardTagTooLong = dashboardaccess.DashboardErr{
Reason: "dashboard tag too long, max 50 characters",
StatusCode: 400,
Status: "tag-too-long",
}
ErrDashboardCannotSaveProvisionedDashboard = dashboardaccess.DashboardErr{
Reason: "Cannot save provisioned dashboard",
StatusCode: 400,

View File

@@ -20,6 +20,10 @@ func UpdatePreferencesFor(ctx context.Context,
return response.Error(http.StatusBadRequest, "Invalid theme", nil)
}
if !pref.IsValidTimezone(dtoCmd.Timezone) {
return response.Error(http.StatusBadRequest, "Invalid timezone. Must be a valid IANA timezone (e.g., America/New_York), 'utc', 'browser', or empty string", nil)
}
// convert dashboard UID to ID in order to store internally if it exists in the query, otherwise take the id from query
// nolint:staticcheck
dashboardID := dtoCmd.HomeDashboardID

View File

@@ -0,0 +1,21 @@
package pref
import (
"time"
)
// IsValidTimezone checks if the timezone string is valid.
// It accepts:
// - "" - uses default
// - "utc"
// - "browser"
// - Any valid IANA timezone (e.g., "America/New_York", "Europe/London")
func IsValidTimezone(timezone string) bool {
if timezone == "" || timezone == "utc" || timezone == "browser" {
return true
}
// try to load as IANA timezone
_, err := time.LoadLocation(timezone)
return err == nil
}

View File

@@ -0,0 +1,38 @@
package pref
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestIsValidTimezone(t *testing.T) {
tests := []struct {
timezone string
valid bool
}{
{
timezone: "utc",
valid: true,
},
{
timezone: "browser",
valid: true,
},
{
timezone: "Europe/London",
valid: true,
},
{
timezone: "invalid",
valid: false,
},
{
timezone: "",
valid: true,
},
}
for _, test := range tests {
assert.Equal(t, test.valid, IsValidTimezone(test.timezone))
}
}

View File

@@ -393,6 +393,66 @@ func runDashboardValidationTests(t *testing.T, ctx TestContext) {
})
})
t.Run("Dashboard tag validations", func(t *testing.T) {
t.Run("reject dashboard with tag over 50 characters on creation", func(t *testing.T) {
dashObj := createDashboardObject(t, "Dashboard with Long Tag", "", 0)
meta, _ := utils.MetaAccessor(dashObj)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"}
_ = meta.SetSpec(specMap)
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "tag too long")
})
t.Run("reject dashboard update with tag over 50 characters", func(t *testing.T) {
dash, err := createDashboard(t, adminClient, "Valid Dashboard", nil, nil, ctx.Helper)
require.NoError(t, err)
require.NotNil(t, dash)
meta, _ := utils.MetaAccessor(dash)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
specMap["tags"] = []string{"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit"}
_ = meta.SetSpec(specMap)
_, err = adminClient.Resource.Update(context.Background(), dash, v1.UpdateOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "tag too long")
err = adminClient.Resource.Delete(context.Background(), dash.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
})
t.Run("accept dashboard with tag at 50 characters", func(t *testing.T) {
dashObj := createDashboardObject(t, "Dashboard with Valid Tag", "", 0)
meta, _ := utils.MetaAccessor(dashObj)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
specMap["tags"] = []string{"this-tag-is-exactly-fifty-characters-long-12345"}
_ = meta.SetSpec(specMap)
createdDash, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
require.NoError(t, err)
require.NotNil(t, createdDash)
err = adminClient.Resource.Delete(context.Background(), createdDash.GetName(), v1.DeleteOptions{})
require.NoError(t, err)
})
t.Run("reject dashboard with multiple tags where one exceeds limit", func(t *testing.T) {
dashObj := createDashboardObject(t, "Dashboard with Mixed Tags", "", 0)
meta, _ := utils.MetaAccessor(dashObj)
spec, _ := meta.GetSpec()
specMap := spec.(map[string]interface{})
specMap["tags"] = []string{
"valid-tag",
"another-valid-tag",
"this-is-a-very-long-tag-that-exceeds-fifty-characters-limit",
}
_ = meta.SetSpec(specMap)
_, err := adminClient.Resource.Create(context.Background(), dashObj, v1.CreateOptions{})
require.Error(t, err)
require.Contains(t, err.Error(), "tag too long")
})
})
t.Run("Dashboard folder validations", func(t *testing.T) {
// Test non-existent folder UID
t.Run("reject dashboard with non-existent folder UID", func(t *testing.T) {

View File

@@ -67,7 +67,7 @@ func TestIntegrationPreferences(t *testing.T) {
Path: fmt.Sprintf("/api/teams/%d/preferences", helper.Org1.Staff.ID),
Body: []byte(`{
"weekStart": "sunday",
"timezone": "africa"
"timezone": "Africa/Johannesburg"
}`),
}, &raw)
require.Equal(t, http.StatusOK, legacyResponse.Response.StatusCode, "create preference for user")
@@ -79,7 +79,7 @@ func TestIntegrationPreferences(t *testing.T) {
Path: "/api/org/preferences",
Body: []byte(`{
"weekStart": "sunday",
"timezone": "africa",
"timezone": "Africa/Accra",
"theme": "dark"
}`),
}, &raw)
@@ -144,7 +144,7 @@ func TestIntegrationPreferences(t *testing.T) {
jj, _ = json.Marshal(bootdata.Result.User)
require.JSONEq(t, `{
"timezone":"africa",
"timezone":"Africa/Johannesburg",
"weekStart":"saturday",
"theme":"dark",
"language":"en-US", `+ // FROM global default!
@@ -157,10 +157,10 @@ func TestIntegrationPreferences(t *testing.T) {
Path: "/apis/preferences.grafana.app/v1alpha1/namespaces/default/preferences/merged",
}, &preferences.Preferences{})
require.Equal(t, http.StatusOK, merged.Response.StatusCode, "get merged preferences")
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
require.Equal(t, "africa", *merged.Result.Spec.Timezone) // from team
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
require.Equal(t, "saturday", *merged.Result.Spec.WeekStart) // from user
require.Equal(t, "Africa/Johannesburg", *merged.Result.Spec.Timezone) // from team
require.Equal(t, "dark", *merged.Result.Spec.Theme) // from org
require.Equal(t, "en-US", *merged.Result.Spec.Language) // settings.ini
require.Equal(t, "dd/mm/yyyy", *merged.Result.Spec.RegionalFormat) // from user update
})
}

View File

@@ -6152,11 +6152,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
@@ -8657,11 +8654,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"

14
public/api-merged.json generated
View File

@@ -18729,11 +18729,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"
@@ -23120,11 +23117,8 @@
]
},
"timezone": {
"type": "string",
"enum": [
"utc",
"browser"
]
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
"type": "string"

View File

@@ -1,4 +1,4 @@
import { EchoBackend, EchoMeta, EchoEvent, EchoSrv } from '@grafana/runtime';
import { EchoBackend, EchoMeta, EchoEvent, EchoSrv, reportInteraction } from '@grafana/runtime';
import { contextSrv } from '../context_srv';
@@ -90,3 +90,15 @@ export class Echo implements EchoSrv {
};
};
}
/** Analytics framework:
* Foundational types and functions for the new tracking event process
*/
export type TrackingEventProps = {
[key: string]: boolean | string | number | undefined;
};
export const createEventFactory = (product: string, featureName: string) => {
return <P extends TrackingEventProps | undefined = undefined>(eventName: string) =>
(props: P extends undefined ? void : P) =>
reportInteraction(`${product}_${featureName}_${eventName}`, props ?? undefined);
};

View File

@@ -18,6 +18,10 @@ export const validateDashboardJson = (json: string) => {
if (hasInvalidTag) {
return t('dashboard.validation.tags-expected-strings', 'tags expected array of strings');
}
const hasTooLongTag = dashboard.tags.some((tag: string) => tag.length > 50);
if (hasTooLongTag) {
return t('dashboard.validation.tag-too-long', 'Dashboard tag too long, max 50 characters');
}
} else {
return t('dashboard.validation.tags-expected-array', 'tags expected array');
}

View File

@@ -5696,6 +5696,7 @@
"validation": {
"invalid-dashboard-id": "Could not find a valid Grafana.com ID",
"invalid-json": "Not valid JSON",
"tag-too-long": "Dashboard tag too long, max 50 characters",
"tags-expected-array": "tags expected array",
"tags-expected-strings": "tags expected array of strings"
},
@@ -9254,7 +9255,8 @@
"tags-input": {
"add": "Add",
"placeholder-new-tag": "New tag (enter key to add)",
"remove": "Remove tag: {{name}}"
"remove": "Remove tag: {{name}}",
"tag-too-long": "Tag too long, max 50 characters"
},
"time-sync-button": {
"aria-label-sync": "Sync times",

10
public/openapi3.json generated
View File

@@ -8264,10 +8264,7 @@
"type": "string"
},
"timezone": {
"enum": [
"utc",
"browser"
],
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {
@@ -12654,10 +12651,7 @@
"type": "string"
},
"timezone": {
"enum": [
"utc",
"browser"
],
"description": "Any IANA timezone string (e.g. America/New_York), 'utc', 'browser', or empty string",
"type": "string"
},
"weekStart": {

View File

@@ -0,0 +1,135 @@
import { Node, type SourceFile, type ts, type Type, type VariableStatement } from 'ts-morph';
import type { Event, EventNamespace, EventProperty } from './types.mts';
import { resolveType, getMetadataFromJSDocs } from './utils/typeResolution.mts';
/**
* Finds all events - calls to the function returned by createEventFactory - declared in a file
*
* An event feature namespace is defined by:
* const createNavEvent = createEventFactory('grafana', 'navigation');
*
* Which will be used to define multiple events like:
* interface ClickProperties {
* linkText: string;
* }
* const trackClick = createNavEvent<ClickProperties>('click');
*/
export function parseEvents(file: SourceFile, eventNamespaces: Map<string, EventNamespace>): Event[] {
const events: Event[] = [];
const variableDecls = file.getVariableDeclarations();
for (const variableDecl of variableDecls) {
// Get the initializer (right hand side of `=`) of the variable declaration
// and make sure it's a function call
const initializer = variableDecl.getInitializer();
if (!initializer || !Node.isCallExpression(initializer)) {
continue;
}
// Only interested in calls to functions returned by createEventFactory
const initializerFnName = initializer.getExpression().getText();
const eventNamespace = eventNamespaces.get(initializerFnName);
if (!eventNamespace) {
continue;
}
// Events should be defined with a single string literal argument (e.g. createNavEvent('click'))
const [arg, ...restArgs] = initializer.getArguments();
if (!arg || !Node.isStringLiteral(arg) || restArgs.length > 0) {
throw new Error(`Expected ${initializerFnName} to be called with only 1 string literal argument`);
}
// We're currently using the variable declaration (foo = blah), but we need the variable
// statement (const foo = blah) to get the JSDoc nodes
const parent = getParentVariableStatement(variableDecl);
if (!parent) {
throw new Error(`Parent not found for ${variableDecl.getText()}`);
}
const docs = parent.getJsDocs();
const { description, owner } = getMetadataFromJSDocs(docs); // TODO: default owner to codeowner if not found
if (!description) {
throw new Error(`Description not found for ${variableDecl.getText()}`);
}
const eventName = arg.getLiteralText();
const event: Event = {
fullEventName: `${eventNamespace.eventPrefixProject}_${eventNamespace.eventPrefixFeature}_${eventName}`,
eventProject: eventNamespace.eventPrefixProject,
eventFeature: eventNamespace.eventPrefixFeature,
eventName,
description,
owner,
};
// Get the type of the declared variable and assert it's a function
const typeAnnotation = variableDecl.getType();
const [callSignature, ...restCallSignatures] = typeAnnotation.getCallSignatures();
if (callSignature === undefined || restCallSignatures.length > 0) {
const typeAsText = typeAnnotation.getText();
throw new Error(`Expected type to be a function with one call signature, got ${typeAsText}`);
}
// The function always only have one parameter type.
// Events that have no properties will have a void parameter type.
const [parameter, ...restParameters] = callSignature.getParameters();
if (parameter === undefined || restParameters.length > 0) {
throw new Error('Expected function to have one parameter');
}
// Find where the parameter type was declared and get it's type
const parameterType = parameter.getTypeAtLocation(parameter.getDeclarations()[0]);
// Then describe the schema for the parameters the event function is called with
if (parameterType.isObject()) {
event.properties = describeObjectParameters(parameterType);
} else if (!parameterType.isVoid()) {
throw new Error(`Expected parameter type to be an object or void, got ${parameterType.getText()}`);
}
events.push(event);
}
return events;
}
function getParentVariableStatement(node: Node): VariableStatement | undefined {
let parent: Node | undefined = node.getParent();
while (parent && !Node.isVariableStatement(parent)) {
parent = parent.getParent();
}
if (parent && Node.isVariableStatement(parent)) {
return parent;
}
return undefined;
}
function describeObjectParameters(objectType: Type<ts.ObjectType>): EventProperty[] {
const properties = objectType.getProperties().map((property) => {
const declarations = property.getDeclarations();
if (declarations.length !== 1) {
throw new Error(`Expected property to have one declaration, got ${declarations.length}`);
}
const declaration = declarations[0];
const propertyType = property.getTypeAtLocation(declaration);
const resolvedType = resolveType(propertyType);
if (!Node.isPropertySignature(declaration)) {
throw new Error(`Expected property to be a property signature, got ${declaration.getKindName()}`);
}
const { description } = getMetadataFromJSDocs(declaration.getJsDocs());
return {
name: property.getName(),
type: resolvedType,
description,
};
});
return properties;
}

View File

@@ -0,0 +1,104 @@
import type { Event, EventNamespace } from './types.mts';
import { parseEvents } from './eventParser.mts';
import { type SourceFile, Node } from 'ts-morph';
/**
* Finds all events - calls to the function returned by createEventFactory - declared in files
*
* An event feature namespace is defined by:
* const createNavEvent = createEventFactory('grafana', 'navigation');
*
* Which will be used to define multiple events like:
* interface ClickProperties {
* linkText: string;
* }
* const trackClick = createNavEvent<ClickProperties>('click');
* const trackExpand = createNavEvent('expand');
*/
export function findAnalyticsEvents(files: SourceFile[], createEventFactoryPath: string): Event[] {
const allEvents: Event[] = files.flatMap((file) => {
// Get the local imported name of createEventFactory
const createEventFactoryImportedName = getEventFactoryFunctionName(file, createEventFactoryPath);
if (!createEventFactoryImportedName) return [];
// Find all calls to createEventFactory and the namespaces they create
const eventNamespaces = findEventNamespaces(file, createEventFactoryImportedName);
// Find all events defined in the file
const events = parseEvents(file, eventNamespaces);
return events;
});
return allEvents;
}
/**
* Finds the local name of the createEventFactory function imported from the given path
*
* @param file - The file to search for the import
* @param createEventFactoryPath - The path to the createEventFactory function
*/
function getEventFactoryFunctionName(file: SourceFile, createEventFactoryPath: string): string | undefined {
const imports = file.getImportDeclarations();
for (const importDeclaration of imports) {
const namedImports = importDeclaration.getNamedImports();
for (const namedImport of namedImports) {
const importName = namedImport.getName();
if (importName === 'createEventFactory') {
const moduleSpecifier = importDeclaration.getModuleSpecifierSourceFile();
if (!moduleSpecifier) continue;
if (moduleSpecifier.getFilePath() === createEventFactoryPath) {
return namedImport.getAliasNode()?.getText() || importName;
}
}
}
}
return undefined;
}
function findEventNamespaces(file: SourceFile, createEventFactoryImportedName: string): Map<string, EventNamespace> {
const variableDecls = file.getVariableDeclarations();
const eventNamespaces = new Map<string, EventNamespace>();
for (const variableDecl of variableDecls) {
const eventFactoryName = variableDecl.getName();
const initializer = variableDecl.getInitializer();
if (!initializer) continue;
if (!Node.isCallExpression(initializer)) continue;
const initializerFnName = initializer.getExpression().getText();
if (initializerFnName !== createEventFactoryImportedName) continue;
const args = initializer.getArguments();
if (args.length !== 2) {
throw new Error(`Expected ${createEventFactoryImportedName} to have 2 arguments`);
}
const [argA, argB] = args;
if (!Node.isStringLiteral(argA) || !Node.isStringLiteral(argB)) {
throw new Error(`Expected ${createEventFactoryImportedName} to have 2 string arguments`);
}
const eventPrefixRepo = argA.getLiteralText();
const eventPrefixFeature = argB.getLiteralText();
console.log(
`found where ${createEventFactoryImportedName} is called, ${eventFactoryName} = ${eventPrefixRepo}_${eventPrefixFeature}`
);
eventNamespaces.set(eventFactoryName, {
factoryName: eventFactoryName,
eventPrefixProject: eventPrefixRepo,
eventPrefixFeature: eventPrefixFeature,
});
}
return eventNamespaces;
}

View File

@@ -0,0 +1,25 @@
import path from 'path';
import fs from 'fs/promises';
import { Project } from 'ts-morph';
import { findAnalyticsEvents } from './findAllEvents.mts';
import { formatEventsAsMarkdown } from './outputFormats/markdown.mts';
const CREATE_EVENT_FACTORY_PATH = path.resolve('public/app/core/services/echo/Echo.ts');
const SOURCE_FILE_PATTERNS = ['**/*.ts'];
const OUTPUT_FORMAT = 'markdown';
const project = new Project({
tsConfigFilePath: path.resolve('tsconfig.json'),
});
const files = project.getSourceFiles(SOURCE_FILE_PATTERNS);
const events = findAnalyticsEvents(files, CREATE_EVENT_FACTORY_PATH);
if (OUTPUT_FORMAT === 'markdown') {
const markdown = await formatEventsAsMarkdown(events);
console.log(markdown);
await fs.writeFile('analytics-report.md', markdown);
} else {
console.log(JSON.stringify(events, null, 2));
}

View File

@@ -0,0 +1,76 @@
import type { Event } from '../types.mts';
import prettier from 'prettier';
function makeMarkdownTable(properties: Array<Record<string, string | undefined>>): string {
const keys = Object.keys(properties[0]);
const header = `| ${keys.join(' | ')} |`;
const border = `| ${keys.map((header) => '-'.padEnd(header.length, '-')).join(' | ')} |`;
const rows = properties.map((property) => {
const columns = keys.map((key) => {
const value = property[key] ?? '';
return String(value).replace(/\|/g, '\\|');
});
return '| ' + columns.join(' | ') + ' |';
});
return [header, border, ...rows].join('\n');
}
export function formatEventAsMarkdown(event: Event): string {
const preparedProperties =
event.properties?.map((property) => {
return {
name: property.name,
type: '`' + property.type + '`',
description: property.description,
};
}) ?? [];
const propertiesTable = event.properties ? makeMarkdownTable(preparedProperties) : '';
const markdownRows = [
`#### ${event.fullEventName}`,
event.description,
event.owner ? `**Owner:** ${event.owner}` : undefined,
...(event.properties ? [`##### Properties`, propertiesTable] : []),
].filter(Boolean);
return markdownRows.join('\n\n');
}
export async function formatEventsAsMarkdown(events: Event[]): Promise<string> {
const byFeature: Record<string, Event[]> = {};
for (const event of events) {
const feature = event.eventFeature;
byFeature[feature] = byFeature[feature] ?? [];
byFeature[feature].push(event);
}
const markdownPerFeature = Object.entries(byFeature)
.map(([feature, events]) => {
const markdownPerEvent = events.map(formatEventAsMarkdown).join('\n');
return `
### ${feature}
${markdownPerEvent}
`;
})
.join('\n');
const markdown = `
# Analytics report
This report contains all the analytics events that are defined in the project.
## Events
${markdownPerFeature}
`;
return prettier.format(markdown, { parser: 'markdown' });
}

View File

@@ -0,0 +1,22 @@
export interface EventNamespace {
factoryName: string;
eventPrefixProject: string;
eventPrefixFeature: string;
}
export interface EventProperty {
name: string;
type: string;
description?: string;
}
export interface Event {
fullEventName: string;
eventProject: string;
eventFeature: string;
eventName: string;
description: string;
owner?: string;
properties?: EventProperty[];
}

View File

@@ -0,0 +1,98 @@
import type { JSDoc, Type } from 'ts-morph';
/**
* Resolves a TypeScript type to a string representation. For example for:
* type Action = "click" | "hover"
* `Action` resolves to `"click" | "hover"`
*
* @param type Type to resolve
* @returns String representation of the type
*/
export function resolveType(type: Type): string {
// If the type is an alias (e.g., `Action`), resolve its declaration
const aliasSymbol = type.getAliasSymbol();
if (aliasSymbol) {
const aliasType = type.getSymbol()?.getDeclarations()?.[0]?.getType();
if (aliasType) {
return resolveType(aliasType);
}
}
// Step 2: If it's a union type, resolve each member recursively
if (type.isUnion()) {
return type
.getUnionTypes()
.map((t) => resolveType(t))
.join(' | ');
}
// Step 3: If it's a string literal type, return its literal value
if (type.isStringLiteral()) {
return `"${type.getLiteralValue()}"`;
}
// TODO: handle enums. Would want to represent an enum as a union of its values
// If the type is an enum, resolve it to a union of its values
if (type.isEnum()) {
const enumMembers = type.getSymbol()?.getDeclarations()?.[0]?.getChildren() || [];
const values = enumMembers
.filter((member) => member.getKindName() === 'SyntaxList' && member.getText() !== `export`)
.map((member) => {
const value = member.getText();
const stripQuotesAndBackticks = value.replace(/['"`]/g, '').replace(/`/g, '');
const splitOnCommaAndReturn = stripQuotesAndBackticks.split(',\n');
return splitOnCommaAndReturn
.map((v) => {
const trimmed = v.trim().replace(/,/g, '');
const splitOnEquals = trimmed.split('=');
return `"${splitOnEquals[1].trim()}"`;
})
.join(` | `);
});
return values.join(` | `);
}
return type.getText(); // Default to the type's text representation
}
export interface JSDocMetadata {
description?: string;
owner?: string;
}
/**
* Extracts description and owner from a JSDoc comment.
*
* @param docs JSDoc comment nodes to extract metadata from
* @returns Metadata extracted from the JSDoc comments
*/
export function getMetadataFromJSDocs(docs: JSDoc[]): JSDocMetadata {
let description: string | undefined;
let owner: string | undefined;
if (docs.length > 1) {
// TODO: Do we need to handle multiple JSDoc comments? Why would there be more than one?
throw new Error('Expected only one JSDoc comment');
}
for (const doc of docs) {
const desc = trimString(doc.getDescription());
if (desc) {
description = desc;
}
const tags = doc.getTags();
for (const tag of tags) {
if (tag.getTagName() === 'owner') {
const tagText = tag.getCommentText();
owner = tagText && trimString(tagText);
}
}
}
return { description, owner };
}
function trimString(str: string): string {
return str.trim().replace(/\n/g, ' ');
}

View File

@@ -9859,6 +9859,17 @@ __metadata:
languageName: node
linkType: hard
"@ts-morph/common@npm:~0.28.1":
version: 0.28.1
resolution: "@ts-morph/common@npm:0.28.1"
dependencies:
minimatch: "npm:^10.0.1"
path-browserify: "npm:^1.0.1"
tinyglobby: "npm:^0.2.14"
checksum: 10/d5c6ed11cf046c186c7263c28c7e9b5fbefb61c65b99f66cfe6a3b249f70f3fbf116b5aace2980602a7ceabecdc399065d5a7f14aabe5475eb43fd573a3cc665
languageName: node
linkType: hard
"@tsconfig/node10@npm:^1.0.7":
version: 1.0.8
resolution: "@tsconfig/node10@npm:1.0.8"
@@ -14274,6 +14285,13 @@ __metadata:
languageName: node
linkType: hard
"code-block-writer@npm:^13.0.3":
version: 13.0.3
resolution: "code-block-writer@npm:13.0.3"
checksum: 10/771546224f38610eecee0598e83c9e0f86dcd600ea316dbf27c2cfebaab4fed51b042325aa460b8e0f131fac5c1de208f6610a1ddbffe4b22e76f9b5256707cb
languageName: node
linkType: hard
"code-point-at@npm:^1.0.0":
version: 1.1.0
resolution: "code-point-at@npm:1.1.0"
@@ -18176,6 +18194,18 @@ __metadata:
languageName: node
linkType: hard
"fdir@npm:^6.5.0":
version: 6.5.0
resolution: "fdir@npm:6.5.0"
peerDependencies:
picomatch: ^3 || ^4
peerDependenciesMeta:
picomatch:
optional: true
checksum: 10/14ca1c9f0a0e8f4f2e9bf4e8551065a164a09545dae548c12a18d238b72e51e5a7b39bd8e5494b56463a0877672d0a6c1ef62c6fa0677db1b0c847773be939b1
languageName: node
linkType: hard
"fflate@npm:^0.8.2":
version: 0.8.2
resolution: "fflate@npm:0.8.2"
@@ -19790,6 +19820,7 @@ __metadata:
testing-library-selector: "npm:0.3.1"
tracelib: "npm:1.0.1"
ts-jest: "npm:29.4.0"
ts-morph: "npm:^27.0.2"
ts-node: "npm:10.9.2"
tslib: "npm:2.8.1"
tween-functions: "npm:^1.2.0"
@@ -24357,7 +24388,7 @@ __metadata:
languageName: node
linkType: hard
"minimatch@npm:10.1.1, minimatch@npm:^10.1.1":
"minimatch@npm:10.1.1, minimatch@npm:^10.0.1, minimatch@npm:^10.1.1":
version: 10.1.1
resolution: "minimatch@npm:10.1.1"
dependencies:
@@ -26970,6 +27001,13 @@ __metadata:
languageName: node
linkType: hard
"picomatch@npm:^4.0.3":
version: 4.0.3
resolution: "picomatch@npm:4.0.3"
checksum: 10/57b99055f40b16798f2802916d9c17e9744e620a0db136554af01d19598b96e45e2f00014c91d1b8b13874b80caa8c295b3d589a3f72373ec4aaf54baa5962d5
languageName: node
linkType: hard
"pify@npm:5.0.0":
version: 5.0.0
resolution: "pify@npm:5.0.0"
@@ -32415,6 +32453,16 @@ __metadata:
languageName: node
linkType: hard
"tinyglobby@npm:^0.2.14":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
dependencies:
fdir: "npm:^6.5.0"
picomatch: "npm:^4.0.3"
checksum: 10/d72bd826a8b0fa5fa3929e7fe5ba48fceb2ae495df3a231b6c5408cd7d8c00b58ab5a9c2a76ba56a62ee9b5e083626f1f33599734bed1ffc4b792406408f0ca2
languageName: node
linkType: hard
"tinyqueue@npm:^3.0.0":
version: 3.0.0
resolution: "tinyqueue@npm:3.0.0"
@@ -32785,6 +32833,16 @@ __metadata:
languageName: node
linkType: hard
"ts-morph@npm:^27.0.2":
version: 27.0.2
resolution: "ts-morph@npm:27.0.2"
dependencies:
"@ts-morph/common": "npm:~0.28.1"
code-block-writer: "npm:^13.0.3"
checksum: 10/b9bd8ed86d4b76ca23446d3f808787cfe95a3bcb2316769a2f3fa262ea9ab4043bb76bf8aef677f9be0d758576f074df2926e701a535f84fd21946fdbf465148
languageName: node
linkType: hard
"ts-node@npm:10.9.2, ts-node@npm:^10.9.1":
version: 10.9.2
resolution: "ts-node@npm:10.9.2"