From f0bdeb860ab783dad7da74c080040385b0f99e72 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:53:08 +0000 Subject: [PATCH 1/6] chore: split theme type declarations --- .../nextjs/src/theme/types/components.d.ts | 71 ++++++ frontends/nextjs/src/theme/types/layout.d.ts | 70 ++++++ frontends/nextjs/src/theme/types/palette.d.ts | 38 ++++ frontends/nextjs/src/theme/types/theme.d.ts | 202 +----------------- 4 files changed, 185 insertions(+), 196 deletions(-) create mode 100644 frontends/nextjs/src/theme/types/components.d.ts create mode 100644 frontends/nextjs/src/theme/types/layout.d.ts create mode 100644 frontends/nextjs/src/theme/types/palette.d.ts diff --git a/frontends/nextjs/src/theme/types/components.d.ts b/frontends/nextjs/src/theme/types/components.d.ts new file mode 100644 index 000000000..b3c850088 --- /dev/null +++ b/frontends/nextjs/src/theme/types/components.d.ts @@ -0,0 +1,71 @@ +import '@mui/material/styles' +import '@mui/material/Typography' +import '@mui/material/Button' +import '@mui/material/Chip' +import '@mui/material/IconButton' +import '@mui/material/Badge' +import '@mui/material/Alert' + +// Typography variants and component overrides +declare module '@mui/material/styles' { + interface TypographyVariants { + code: React.CSSProperties + kbd: React.CSSProperties + label: React.CSSProperties + } + + interface TypographyVariantsOptions { + code?: React.CSSProperties + kbd?: React.CSSProperties + label?: React.CSSProperties + } +} + +declare module '@mui/material/Typography' { + interface TypographyPropsVariantOverrides { + code: true + kbd: true + label: true + } +} + +declare module '@mui/material/Button' { + interface ButtonPropsVariantOverrides { + soft: true + ghost: true + } + + interface ButtonPropsColorOverrides { + neutral: true + } +} + +declare module '@mui/material/Chip' { + interface ChipPropsVariantOverrides { + soft: true + } + + interface ChipPropsColorOverrides { + neutral: true + } +} + +declare module '@mui/material/IconButton' { + interface IconButtonPropsColorOverrides { + neutral: true + } +} + +declare module '@mui/material/Badge' { + interface BadgePropsColorOverrides { + neutral: true + } +} + +declare module '@mui/material/Alert' { + interface AlertPropsVariantOverrides { + soft: true + } +} + +export {} diff --git a/frontends/nextjs/src/theme/types/layout.d.ts b/frontends/nextjs/src/theme/types/layout.d.ts new file mode 100644 index 000000000..6c9219bfc --- /dev/null +++ b/frontends/nextjs/src/theme/types/layout.d.ts @@ -0,0 +1,70 @@ +import '@mui/material/styles' + +// Custom theme properties for layout and design tokens +declare module '@mui/material/styles' { + interface Theme { + custom: { + fonts: { + body: string + heading: string + mono: string + } + borderRadius: { + none: number + sm: number + md: number + lg: number + xl: number + full: number + } + contentWidth: { + sm: string + md: string + lg: string + xl: string + full: string + } + sidebar: { + width: number + collapsedWidth: number + } + header: { + height: number + } + } + } + + interface ThemeOptions { + custom?: { + fonts?: { + body?: string + heading?: string + mono?: string + } + borderRadius?: { + none?: number + sm?: number + md?: number + lg?: number + xl?: number + full?: number + } + contentWidth?: { + sm?: string + md?: string + lg?: string + xl?: string + full?: string + } + sidebar?: { + width?: number + collapsedWidth?: number + } + header?: { + height?: number + } + } + } +} + +export {} diff --git a/frontends/nextjs/src/theme/types/palette.d.ts b/frontends/nextjs/src/theme/types/palette.d.ts new file mode 100644 index 000000000..edf25b4a1 --- /dev/null +++ b/frontends/nextjs/src/theme/types/palette.d.ts @@ -0,0 +1,38 @@ +import '@mui/material/styles' + +// Extend palette with custom neutral colors +declare module '@mui/material/styles' { + interface Palette { + neutral: { + 50: string + 100: string + 200: string + 300: string + 400: string + 500: string + 600: string + 700: string + 800: string + 900: string + 950: string + } + } + + interface PaletteOptions { + neutral?: { + 50?: string + 100?: string + 200?: string + 300?: string + 400?: string + 500?: string + 600?: string + 700?: string + 800?: string + 900?: string + 950?: string + } + } +} + +export {} diff --git a/frontends/nextjs/src/theme/types/theme.d.ts b/frontends/nextjs/src/theme/types/theme.d.ts index a8623d1fb..e3a795dd5 100644 --- a/frontends/nextjs/src/theme/types/theme.d.ts +++ b/frontends/nextjs/src/theme/types/theme.d.ts @@ -1,200 +1,10 @@ /** * MUI Theme Type Extensions - * - * This file extends Material-UI's theme interface with custom properties. - * All custom design tokens and component variants should be declared here. + * + * This file aggregates the theme augmentation modules to keep the + * main declaration lightweight while still exposing all custom tokens. */ -import '@mui/material/styles' -import '@mui/material/Typography' -import '@mui/material/Button' - -// ============================================================================ -// Custom Palette Extensions -// ============================================================================ - -declare module '@mui/material/styles' { - // Extend palette with custom neutral colors - interface Palette { - neutral: { - 50: string - 100: string - 200: string - 300: string - 400: string - 500: string - 600: string - 700: string - 800: string - 900: string - 950: string - } - } - - interface PaletteOptions { - neutral?: { - 50?: string - 100?: string - 200?: string - 300?: string - 400?: string - 500?: string - 600?: string - 700?: string - 800?: string - 900?: string - 950?: string - } - } - - // Custom typography variants - interface TypographyVariants { - code: React.CSSProperties - kbd: React.CSSProperties - label: React.CSSProperties - } - - interface TypographyVariantsOptions { - code?: React.CSSProperties - kbd?: React.CSSProperties - label?: React.CSSProperties - } - - // Custom theme properties - interface Theme { - custom: { - fonts: { - body: string - heading: string - mono: string - } - borderRadius: { - none: number - sm: number - md: number - lg: number - xl: number - full: number - } - contentWidth: { - sm: string - md: string - lg: string - xl: string - full: string - } - sidebar: { - width: number - collapsedWidth: number - } - header: { - height: number - } - } - } - - interface ThemeOptions { - custom?: { - fonts?: { - body?: string - heading?: string - mono?: string - } - borderRadius?: { - none?: number - sm?: number - md?: number - lg?: number - xl?: number - full?: number - } - contentWidth?: { - sm?: string - md?: string - lg?: string - xl?: string - full?: string - } - sidebar?: { - width?: number - collapsedWidth?: number - } - header?: { - height?: number - } - } - } -} - -// ============================================================================ -// Typography Variant Mapping -// ============================================================================ - -declare module '@mui/material/Typography' { - interface TypographyPropsVariantOverrides { - code: true - kbd: true - label: true - } -} - -// ============================================================================ -// Button Variants & Colors -// ============================================================================ - -declare module '@mui/material/Button' { - interface ButtonPropsVariantOverrides { - soft: true - ghost: true - } - - interface ButtonPropsColorOverrides { - neutral: true - } -} - -// ============================================================================ -// Chip Variants -// ============================================================================ - -declare module '@mui/material/Chip' { - interface ChipPropsVariantOverrides { - soft: true - } - - interface ChipPropsColorOverrides { - neutral: true - } -} - -// ============================================================================ -// IconButton Colors -// ============================================================================ - -declare module '@mui/material/IconButton' { - interface IconButtonPropsColorOverrides { - neutral: true - } -} - -// ============================================================================ -// Badge Colors -// ============================================================================ - -declare module '@mui/material/Badge' { - interface BadgePropsColorOverrides { - neutral: true - } -} - -// ============================================================================ -// Alert Variants -// ============================================================================ - -declare module '@mui/material/Alert' { - interface AlertPropsVariantOverrides { - soft: true - } -} - -export {} +export * from './palette' +export * from './layout' +export * from './components' From 50f934abbbbcc424ddc7d27919a409766edde7b0 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:53:35 +0000 Subject: [PATCH 2/6] refactor: split default schema definitions --- .../nextjs/src/lib/schema/default-schema.ts | 306 +----------------- .../src/lib/schema/default/components.ts | 54 ++++ .../nextjs/src/lib/schema/default/forms.ts | 244 ++++++++++++++ .../src/lib/schema/default/validation.ts | 19 ++ frontends/nextjs/src/lib/schema/index.ts | 3 + 5 files changed, 322 insertions(+), 304 deletions(-) create mode 100644 frontends/nextjs/src/lib/schema/default/components.ts create mode 100644 frontends/nextjs/src/lib/schema/default/forms.ts create mode 100644 frontends/nextjs/src/lib/schema/default/validation.ts diff --git a/frontends/nextjs/src/lib/schema/default-schema.ts b/frontends/nextjs/src/lib/schema/default-schema.ts index 398071c44..a01c0a590 100644 --- a/frontends/nextjs/src/lib/schema/default-schema.ts +++ b/frontends/nextjs/src/lib/schema/default-schema.ts @@ -1,308 +1,6 @@ import type { SchemaConfig } from '../types/schema-types' +import { defaultApps } from './default/components' export const defaultSchema: SchemaConfig = { - apps: [ - { - name: 'blog', - label: 'Blog', - models: [ - { - name: 'post', - label: 'Post', - labelPlural: 'Posts', - icon: 'Article', - listDisplay: ['title', 'author', 'status', 'publishedAt'], - listFilter: ['status', 'author'], - searchFields: ['title', 'content'], - ordering: ['-publishedAt'], - fields: [ - { - name: 'id', - type: 'string', - label: 'ID', - required: true, - unique: true, - editable: false, - listDisplay: false, - }, - { - name: 'title', - type: 'string', - label: 'Title', - required: true, - validation: { - minLength: 3, - maxLength: 200, - }, - listDisplay: true, - searchable: true, - sortable: true, - }, - { - name: 'slug', - type: 'string', - label: 'Slug', - required: true, - unique: true, - helpText: 'URL-friendly version of the title', - validation: { - pattern: '^[a-z0-9-]+$', - }, - listDisplay: false, - sortable: true, - }, - { - name: 'content', - type: 'text', - label: 'Content', - required: true, - helpText: 'Main post content', - listDisplay: false, - searchable: true, - }, - { - name: 'excerpt', - type: 'text', - label: 'Excerpt', - required: false, - helpText: ['Short summary of the post', 'Used in list views and previews'], - validation: { - maxLength: 500, - }, - listDisplay: false, - }, - { - name: 'author', - type: 'relation', - label: 'Author', - required: true, - relatedModel: 'author', - listDisplay: true, - sortable: true, - }, - { - name: 'status', - type: 'select', - label: 'Status', - required: true, - default: 'draft', - choices: [ - { value: 'draft', label: 'Draft' }, - { value: 'published', label: 'Published' }, - { value: 'archived', label: 'Archived' }, - ], - listDisplay: true, - sortable: true, - }, - { - name: 'featured', - type: 'boolean', - label: 'Featured', - default: false, - helpText: 'Display on homepage', - listDisplay: true, - }, - { - name: 'publishedAt', - type: 'datetime', - label: 'Published At', - required: false, - listDisplay: true, - sortable: true, - }, - { - name: 'tags', - type: 'json', - label: 'Tags', - required: false, - helpText: 'JSON array of tag strings', - listDisplay: false, - }, - { - name: 'views', - type: 'number', - label: 'Views', - default: 0, - validation: { - min: 0, - }, - listDisplay: false, - }, - ], - }, - { - name: 'author', - label: 'Author', - labelPlural: 'Authors', - icon: 'User', - listDisplay: ['name', 'email', 'active', 'createdAt'], - listFilter: ['active'], - searchFields: ['name', 'email'], - ordering: ['name'], - fields: [ - { - name: 'id', - type: 'string', - label: 'ID', - required: true, - unique: true, - editable: false, - listDisplay: false, - }, - { - name: 'name', - type: 'string', - label: 'Name', - required: true, - validation: { - minLength: 2, - maxLength: 100, - }, - listDisplay: true, - searchable: true, - sortable: true, - }, - { - name: 'email', - type: 'email', - label: 'Email', - required: true, - unique: true, - listDisplay: true, - searchable: true, - sortable: true, - }, - { - name: 'bio', - type: 'text', - label: 'Bio', - required: false, - helpText: 'Author biography', - validation: { - maxLength: 1000, - }, - listDisplay: false, - }, - { - name: 'website', - type: 'url', - label: 'Website', - required: false, - listDisplay: false, - }, - { - name: 'active', - type: 'boolean', - label: 'Active', - default: true, - listDisplay: true, - }, - { - name: 'createdAt', - type: 'datetime', - label: 'Created At', - required: true, - editable: false, - listDisplay: true, - sortable: true, - }, - ], - }, - ], - }, - { - name: 'ecommerce', - label: 'E-Commerce', - models: [ - { - name: 'product', - label: 'Product', - labelPlural: 'Products', - icon: 'ShoppingCart', - listDisplay: ['name', 'price', 'stock', 'available'], - listFilter: ['available', 'category'], - searchFields: ['name', 'description'], - ordering: ['name'], - fields: [ - { - name: 'id', - type: 'string', - label: 'ID', - required: true, - unique: true, - editable: false, - listDisplay: false, - }, - { - name: 'name', - type: 'string', - label: 'Product Name', - required: true, - validation: { - minLength: 3, - maxLength: 200, - }, - listDisplay: true, - searchable: true, - sortable: true, - }, - { - name: 'description', - type: 'text', - label: 'Description', - required: false, - helpText: 'Product description', - listDisplay: false, - searchable: true, - }, - { - name: 'price', - type: 'number', - label: 'Price', - required: true, - validation: { - min: 0, - }, - listDisplay: true, - sortable: true, - }, - { - name: 'stock', - type: 'number', - label: 'Stock', - required: true, - default: 0, - validation: { - min: 0, - }, - listDisplay: true, - sortable: true, - }, - { - name: 'category', - type: 'select', - label: 'Category', - required: true, - choices: [ - { value: 'electronics', label: 'Electronics' }, - { value: 'clothing', label: 'Clothing' }, - { value: 'books', label: 'Books' }, - { value: 'home', label: 'Home & Garden' }, - { value: 'toys', label: 'Toys' }, - ], - listDisplay: false, - sortable: true, - }, - { - name: 'available', - type: 'boolean', - label: 'Available', - default: true, - listDisplay: true, - }, - ], - }, - ], - }, - ], + apps: defaultApps, } diff --git a/frontends/nextjs/src/lib/schema/default/components.ts b/frontends/nextjs/src/lib/schema/default/components.ts new file mode 100644 index 000000000..9fe3f02bd --- /dev/null +++ b/frontends/nextjs/src/lib/schema/default/components.ts @@ -0,0 +1,54 @@ +import type { AppSchema, ModelSchema } from '../../types/schema-types' +import { authorFields, postFields, productFields } from './forms' + +export const blogModels: ModelSchema[] = [ + { + name: 'post', + label: 'Post', + labelPlural: 'Posts', + icon: 'Article', + listDisplay: ['title', 'author', 'status', 'publishedAt'], + listFilter: ['status', 'author'], + searchFields: ['title', 'content'], + ordering: ['-publishedAt'], + fields: postFields, + }, + { + name: 'author', + label: 'Author', + labelPlural: 'Authors', + icon: 'User', + listDisplay: ['name', 'email', 'active', 'createdAt'], + listFilter: ['active'], + searchFields: ['name', 'email'], + ordering: ['name'], + fields: authorFields, + }, +] + +export const ecommerceModels: ModelSchema[] = [ + { + name: 'product', + label: 'Product', + labelPlural: 'Products', + icon: 'ShoppingCart', + listDisplay: ['name', 'price', 'stock', 'available'], + listFilter: ['available', 'category'], + searchFields: ['name', 'description'], + ordering: ['name'], + fields: productFields, + }, +] + +export const defaultApps: AppSchema[] = [ + { + name: 'blog', + label: 'Blog', + models: blogModels, + }, + { + name: 'ecommerce', + label: 'E-Commerce', + models: ecommerceModels, + }, +] diff --git a/frontends/nextjs/src/lib/schema/default/forms.ts b/frontends/nextjs/src/lib/schema/default/forms.ts new file mode 100644 index 000000000..81ae491a5 --- /dev/null +++ b/frontends/nextjs/src/lib/schema/default/forms.ts @@ -0,0 +1,244 @@ +import type { FieldSchema } from '../../types/schema-types' +import { authorValidations, postValidations, productValidations } from './validation' + +export const postFields: FieldSchema[] = [ + { + name: 'id', + type: 'string', + label: 'ID', + required: true, + unique: true, + editable: false, + listDisplay: false, + }, + { + name: 'title', + type: 'string', + label: 'Title', + required: true, + validation: postValidations.title, + listDisplay: true, + searchable: true, + sortable: true, + }, + { + name: 'slug', + type: 'string', + label: 'Slug', + required: true, + unique: true, + helpText: 'URL-friendly version of the title', + validation: postValidations.slug, + listDisplay: false, + sortable: true, + }, + { + name: 'content', + type: 'text', + label: 'Content', + required: true, + helpText: 'Main post content', + listDisplay: false, + searchable: true, + }, + { + name: 'excerpt', + type: 'text', + label: 'Excerpt', + required: false, + helpText: ['Short summary of the post', 'Used in list views and previews'], + validation: postValidations.excerpt, + listDisplay: false, + }, + { + name: 'author', + type: 'relation', + label: 'Author', + required: true, + relatedModel: 'author', + listDisplay: true, + sortable: true, + }, + { + name: 'status', + type: 'select', + label: 'Status', + required: true, + default: 'draft', + choices: [ + { value: 'draft', label: 'Draft' }, + { value: 'published', label: 'Published' }, + { value: 'archived', label: 'Archived' }, + ], + listDisplay: true, + sortable: true, + }, + { + name: 'featured', + type: 'boolean', + label: 'Featured', + default: false, + helpText: 'Display on homepage', + listDisplay: true, + }, + { + name: 'publishedAt', + type: 'datetime', + label: 'Published At', + required: false, + listDisplay: true, + sortable: true, + }, + { + name: 'tags', + type: 'json', + label: 'Tags', + required: false, + helpText: 'JSON array of tag strings', + listDisplay: false, + }, + { + name: 'views', + type: 'number', + label: 'Views', + default: 0, + validation: postValidations.views, + listDisplay: false, + }, +] + +export const authorFields: FieldSchema[] = [ + { + name: 'id', + type: 'string', + label: 'ID', + required: true, + unique: true, + editable: false, + listDisplay: false, + }, + { + name: 'name', + type: 'string', + label: 'Name', + required: true, + validation: authorValidations.name, + listDisplay: true, + searchable: true, + sortable: true, + }, + { + name: 'email', + type: 'email', + label: 'Email', + required: true, + unique: true, + listDisplay: true, + searchable: true, + sortable: true, + }, + { + name: 'bio', + type: 'text', + label: 'Bio', + required: false, + helpText: 'Author biography', + validation: authorValidations.bio, + listDisplay: false, + }, + { + name: 'website', + type: 'url', + label: 'Website', + required: false, + listDisplay: false, + }, + { + name: 'active', + type: 'boolean', + label: 'Active', + default: true, + listDisplay: true, + }, + { + name: 'createdAt', + type: 'datetime', + label: 'Created At', + required: true, + editable: false, + listDisplay: true, + sortable: true, + }, +] + +export const productFields: FieldSchema[] = [ + { + name: 'id', + type: 'string', + label: 'ID', + required: true, + unique: true, + editable: false, + listDisplay: false, + }, + { + name: 'name', + type: 'string', + label: 'Product Name', + required: true, + validation: productValidations.name, + listDisplay: true, + searchable: true, + sortable: true, + }, + { + name: 'description', + type: 'text', + label: 'Description', + required: false, + helpText: 'Product description', + listDisplay: false, + searchable: true, + }, + { + name: 'price', + type: 'number', + label: 'Price', + required: true, + validation: productValidations.price, + listDisplay: true, + sortable: true, + }, + { + name: 'stock', + type: 'number', + label: 'Stock', + required: true, + default: 0, + validation: productValidations.stock, + listDisplay: true, + sortable: true, + }, + { + name: 'category', + type: 'select', + label: 'Category', + required: true, + choices: [ + { value: 'electronics', label: 'Electronics' }, + { value: 'clothing', label: 'Clothing' }, + { value: 'books', label: 'Books' }, + { value: 'home', label: 'Home & Garden' }, + { value: 'toys', label: 'Toys' }, + ], + listDisplay: false, + sortable: true, + }, + { + name: 'available', + type: 'boolean', + label: 'Available', + default: true, + listDisplay: true, + }, +] diff --git a/frontends/nextjs/src/lib/schema/default/validation.ts b/frontends/nextjs/src/lib/schema/default/validation.ts new file mode 100644 index 000000000..0573520f4 --- /dev/null +++ b/frontends/nextjs/src/lib/schema/default/validation.ts @@ -0,0 +1,19 @@ +import type { FieldSchema } from '../../types/schema-types' + +export const postValidations: Record = { + title: { minLength: 3, maxLength: 200 }, + slug: { pattern: '^[a-z0-9-]+$' }, + excerpt: { maxLength: 500 }, + views: { min: 0 }, +} + +export const authorValidations: Record = { + name: { minLength: 2, maxLength: 100 }, + bio: { maxLength: 1000 }, +} + +export const productValidations: Record = { + name: { minLength: 3, maxLength: 200 }, + price: { min: 0 }, + stock: { min: 0 }, +} diff --git a/frontends/nextjs/src/lib/schema/index.ts b/frontends/nextjs/src/lib/schema/index.ts index 1e68e8671..97056cc3f 100644 --- a/frontends/nextjs/src/lib/schema/index.ts +++ b/frontends/nextjs/src/lib/schema/index.ts @@ -1,3 +1,6 @@ // Schema utilities exports export * from './schema-utils' export { defaultSchema } from './default-schema' +export * from './default/components' +export * from './default/forms' +export * from './default/validation' From 33cc1322cc881825cdfdb50bbf5d296dfbfb9fc2 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:54:27 +0000 Subject: [PATCH 3/6] test: split security scanner coverage --- .../security-scanner.detection.test.ts | 234 ++++++++++++++++ .../security-scanner.reporting.test.ts | 29 ++ .../security/scanner/security-scanner.test.ts | 259 +----------------- 3 files changed, 265 insertions(+), 257 deletions(-) create mode 100644 frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts create mode 100644 frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts diff --git a/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts new file mode 100644 index 000000000..a910bea9d --- /dev/null +++ b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.detection.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it } from 'vitest' + +import { scanForVulnerabilities, securityScanner } from '@/lib/security-scanner' + +describe('security-scanner detection', () => { + describe('scanJavaScript', () => { + it.each([ + { + name: 'flag eval usage as critical', + code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'), + expectedSeverity: 'critical', + expectedSafe: false, + expectedIssueType: 'dangerous', + expectedIssuePattern: 'eval', + expectedLine: 2, + }, + { + name: 'warn on localStorage usage but stay safe', + code: 'localStorage.setItem("k", "v")', + expectedSeverity: 'low', + expectedSafe: true, + expectedIssueType: 'warning', + expectedIssuePattern: 'localStorage', + }, + { + name: 'return safe for benign code', + code: 'const sum = (a, b) => a + b', + expectedSeverity: 'safe', + expectedSafe: true, + }, + ])( + 'should $name', + ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => { + const result = securityScanner.scanJavaScript(code) + expect(result.severity).toBe(expectedSeverity) + expect(result.safe).toBe(expectedSafe) + + if (expectedIssueType || expectedIssuePattern) { + const issue = result.issues.find(item => { + const matchesType = expectedIssueType ? item.type === expectedIssueType : true + const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true + return matchesType && matchesPattern + }) + expect(issue).toBeDefined() + if (expectedLine !== undefined) { + expect(issue?.line).toBe(expectedLine) + } + } else { + expect(result.issues.length).toBe(0) + } + + if (expectedSafe) { + expect(result.sanitizedCode).toBe(code) + } else { + expect(result.sanitizedCode).toBeUndefined() + } + } + ) + }) + + describe('scanLua', () => { + it.each([ + { + name: 'flag os.execute usage as critical', + code: 'os.execute("rm -rf /")', + expectedSeverity: 'critical', + expectedSafe: false, + expectedIssueType: 'malicious', + expectedIssuePattern: 'os.execute', + }, + { + name: 'return safe for simple Lua function', + code: 'function add(a, b) return a + b end', + expectedSeverity: 'safe', + expectedSafe: true, + }, + ])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => { + const result = securityScanner.scanLua(code) + expect(result.severity).toBe(expectedSeverity) + expect(result.safe).toBe(expectedSafe) + + if (expectedIssueType || expectedIssuePattern) { + const issue = result.issues.find(item => { + const matchesType = expectedIssueType ? item.type === expectedIssueType : true + const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true + return matchesType && matchesPattern + }) + expect(issue).toBeDefined() + } else { + expect(result.issues.length).toBe(0) + } + + if (expectedSafe) { + expect(result.sanitizedCode).toBe(code) + } else { + expect(result.sanitizedCode).toBeUndefined() + } + }) + }) + + describe('scanJSON', () => { + it.each([ + { + name: 'flag invalid JSON as medium severity', + json: '{"value": }', + expectedSeverity: 'medium', + expectedSafe: false, + expectedIssuePattern: 'JSON parse error', + }, + { + name: 'flag prototype pollution in JSON as critical', + json: '{"__proto__": {"polluted": true}}', + expectedSeverity: 'critical', + expectedSafe: false, + expectedIssuePattern: '__proto__', + }, + { + name: 'return safe for valid JSON', + json: '{"ok": true}', + expectedSeverity: 'safe', + expectedSafe: true, + }, + ])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => { + const result = securityScanner.scanJSON(json) + expect(result.severity).toBe(expectedSeverity) + expect(result.safe).toBe(expectedSafe) + + if (expectedIssuePattern) { + expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true) + } else { + expect(result.issues.length).toBe(0) + } + + if (expectedSafe) { + expect(result.sanitizedCode).toBe(json) + } else { + expect(result.sanitizedCode).toBeUndefined() + } + }) + }) + + describe('scanHTML', () => { + it.each([ + { + name: 'flag script tags as critical', + html: '
', + expectedSeverity: 'critical', + expectedSafe: false, + }, + { + name: 'flag inline handlers as high', + html: '', + expectedSeverity: 'high', + expectedSafe: false, + }, + { + name: 'return safe for plain markup', + html: '
Safe
', + expectedSeverity: 'safe', + expectedSafe: true, + }, + ])('should $name', ({ html, expectedSeverity, expectedSafe }) => { + const result = securityScanner.scanHTML(html) + expect(result.severity).toBe(expectedSeverity) + expect(result.safe).toBe(expectedSafe) + }) + }) + + describe('sanitizeInput', () => { + it.each([ + { + name: 'remove script tags and inline handlers from text', + input: '
Click
x', + type: 'text' as const, + shouldExclude: ['', + type: 'html' as const, + shouldExclude: ['data:text/html', ' { + const sanitized = securityScanner.sanitizeInput(input, type) + shouldExclude.forEach(value => { + expect(sanitized).not.toContain(value) + }) + shouldInclude.forEach(value => { + expect(sanitized).toContain(value) + }) + }) + }) + + describe('scanForVulnerabilities', () => { + it.each([ + { + name: 'auto-detects JSON and flags prototype pollution', + code: '{"__proto__": {"polluted": true}}', + expectedSeverity: 'critical', + }, + { + name: 'auto-detects Lua when function/end present', + code: 'function dangerous() os.execute("rm -rf /") end', + expectedSeverity: 'critical', + }, + { + name: 'auto-detects HTML and flags script tags', + code: '
', + expectedSeverity: 'critical', + }, + { + name: 'falls back to JavaScript scanning', + code: 'const result = eval("1 + 1")', + expectedSeverity: 'critical', + }, + { + name: 'honors explicit type parameter', + code: 'return 1', + type: 'lua' as const, + expectedSeverity: 'safe', + }, + ])('should $name', ({ code, type, expectedSeverity }) => { + const result = scanForVulnerabilities(code, type) + expect(result.severity).toBe(expectedSeverity) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts new file mode 100644 index 000000000..42f9a2dd2 --- /dev/null +++ b/frontends/nextjs/src/lib/security/scanner/__tests__/security-scanner.reporting.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' + +import { getSeverityColor, getSeverityIcon } from '@/lib/security-scanner' + +describe('security-scanner reporting', () => { + describe('getSeverityColor', () => { + it.each([ + { severity: 'critical', expected: 'error' }, + { severity: 'high', expected: 'warning' }, + { severity: 'medium', expected: 'info' }, + { severity: 'low', expected: 'secondary' }, + { severity: 'safe', expected: 'success' }, + ])('should map $severity to expected classes', ({ severity, expected }) => { + expect(getSeverityColor(severity)).toBe(expected) + }) + }) + + describe('getSeverityIcon', () => { + it.each([ + { severity: 'critical', expected: '\u{1F6A8}' }, + { severity: 'high', expected: '\u26A0\uFE0F' }, + { severity: 'medium', expected: '\u26A1' }, + { severity: 'low', expected: '\u2139\uFE0F' }, + { severity: 'safe', expected: '\u2713' }, + ])('should map $severity to expected icon', ({ severity, expected }) => { + expect(getSeverityIcon(severity)).toBe(expected) + }) + }) +}) diff --git a/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts b/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts index 142a8997b..0e9fcbd80 100644 --- a/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts +++ b/frontends/nextjs/src/lib/security/scanner/security-scanner.test.ts @@ -1,257 +1,2 @@ -import { describe, it, expect } from 'vitest' -import { securityScanner, scanForVulnerabilities, getSeverityColor, getSeverityIcon } from '@/lib/security-scanner' - -describe('security-scanner', () => { - describe('scanJavaScript', () => { - it.each([ - { - name: 'flag eval usage as critical', - code: ['const safe = true;', 'const result = eval("1 + 1")'].join('\n'), - expectedSeverity: 'critical', - expectedSafe: false, - expectedIssueType: 'dangerous', - expectedIssuePattern: 'eval', - expectedLine: 2, - }, - { - name: 'warn on localStorage usage but stay safe', - code: 'localStorage.setItem("k", "v")', - expectedSeverity: 'low', - expectedSafe: true, - expectedIssueType: 'warning', - expectedIssuePattern: 'localStorage', - }, - { - name: 'return safe for benign code', - code: 'const sum = (a, b) => a + b', - expectedSeverity: 'safe', - expectedSafe: true, - }, - ])( - 'should $name', - ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern, expectedLine }) => { - const result = securityScanner.scanJavaScript(code) - expect(result.severity).toBe(expectedSeverity) - expect(result.safe).toBe(expectedSafe) - - if (expectedIssueType || expectedIssuePattern) { - const issue = result.issues.find(item => { - const matchesType = expectedIssueType ? item.type === expectedIssueType : true - const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true - return matchesType && matchesPattern - }) - expect(issue).toBeDefined() - if (expectedLine !== undefined) { - expect(issue?.line).toBe(expectedLine) - } - } else { - expect(result.issues.length).toBe(0) - } - - if (expectedSafe) { - expect(result.sanitizedCode).toBe(code) - } else { - expect(result.sanitizedCode).toBeUndefined() - } - } - ) - }) - - describe('scanLua', () => { - it.each([ - { - name: 'flag os.execute usage as critical', - code: 'os.execute("rm -rf /")', - expectedSeverity: 'critical', - expectedSafe: false, - expectedIssueType: 'malicious', - expectedIssuePattern: 'os.execute', - }, - { - name: 'return safe for simple Lua function', - code: 'function add(a, b) return a + b end', - expectedSeverity: 'safe', - expectedSafe: true, - }, - ])('should $name', ({ code, expectedSeverity, expectedSafe, expectedIssueType, expectedIssuePattern }) => { - const result = securityScanner.scanLua(code) - expect(result.severity).toBe(expectedSeverity) - expect(result.safe).toBe(expectedSafe) - - if (expectedIssueType || expectedIssuePattern) { - const issue = result.issues.find(item => { - const matchesType = expectedIssueType ? item.type === expectedIssueType : true - const matchesPattern = expectedIssuePattern ? item.pattern.includes(expectedIssuePattern) : true - return matchesType && matchesPattern - }) - expect(issue).toBeDefined() - } else { - expect(result.issues.length).toBe(0) - } - - if (expectedSafe) { - expect(result.sanitizedCode).toBe(code) - } else { - expect(result.sanitizedCode).toBeUndefined() - } - }) - }) - - describe('scanJSON', () => { - it.each([ - { - name: 'flag invalid JSON as medium severity', - json: '{"value": }', - expectedSeverity: 'medium', - expectedSafe: false, - expectedIssuePattern: 'JSON parse error', - }, - { - name: 'flag prototype pollution in JSON as critical', - json: '{"__proto__": {"polluted": true}}', - expectedSeverity: 'critical', - expectedSafe: false, - expectedIssuePattern: '__proto__', - }, - { - name: 'return safe for valid JSON', - json: '{"ok": true}', - expectedSeverity: 'safe', - expectedSafe: true, - }, - ])('should $name', ({ json, expectedSeverity, expectedSafe, expectedIssuePattern }) => { - const result = securityScanner.scanJSON(json) - expect(result.severity).toBe(expectedSeverity) - expect(result.safe).toBe(expectedSafe) - - if (expectedIssuePattern) { - expect(result.issues.some(issue => issue.pattern.includes(expectedIssuePattern))).toBe(true) - } else { - expect(result.issues.length).toBe(0) - } - - if (expectedSafe) { - expect(result.sanitizedCode).toBe(json) - } else { - expect(result.sanitizedCode).toBeUndefined() - } - }) - }) - - describe('scanHTML', () => { - it.each([ - { - name: 'flag script tags as critical', - html: '
', - expectedSeverity: 'critical', - expectedSafe: false, - }, - { - name: 'flag inline handlers as high', - html: '', - expectedSeverity: 'high', - expectedSafe: false, - }, - { - name: 'return safe for plain markup', - html: '
Safe
', - expectedSeverity: 'safe', - expectedSafe: true, - }, - ])('should $name', ({ html, expectedSeverity, expectedSafe }) => { - const result = securityScanner.scanHTML(html) - expect(result.severity).toBe(expectedSeverity) - expect(result.safe).toBe(expectedSafe) - }) - }) - - describe('sanitizeInput', () => { - it.each([ - { - name: 'remove script tags and inline handlers from text', - input: '
Click
x', - type: 'text' as const, - shouldExclude: ['', - type: 'html' as const, - shouldExclude: ['data:text/html', ' { - const sanitized = securityScanner.sanitizeInput(input, type) - shouldExclude.forEach(value => { - expect(sanitized).not.toContain(value) - }) - shouldInclude.forEach(value => { - expect(sanitized).toContain(value) - }) - }) - }) - - describe('getSeverityColor', () => { - it.each([ - { severity: 'critical', expected: 'error' }, - { severity: 'high', expected: 'warning' }, - { severity: 'medium', expected: 'info' }, - { severity: 'low', expected: 'secondary' }, - { severity: 'safe', expected: 'success' }, - ])('should map $severity to expected classes', ({ severity, expected }) => { - expect(getSeverityColor(severity)).toBe(expected) - }) - }) - - describe('getSeverityIcon', () => { - it.each([ - { severity: 'critical', expected: '\u{1F6A8}' }, - { severity: 'high', expected: '\u26A0\uFE0F' }, - { severity: 'medium', expected: '\u26A1' }, - { severity: 'low', expected: '\u2139\uFE0F' }, - { severity: 'safe', expected: '\u2713' }, - ])('should map $severity to expected icon', ({ severity, expected }) => { - expect(getSeverityIcon(severity)).toBe(expected) - }) - }) - - describe('scanForVulnerabilities', () => { - it.each([ - { - name: 'auto-detects JSON and flags prototype pollution', - code: '{"__proto__": {"polluted": true}}', - expectedSeverity: 'critical', - }, - { - name: 'auto-detects Lua when function/end present', - code: 'function dangerous() os.execute("rm -rf /") end', - expectedSeverity: 'critical', - }, - { - name: 'auto-detects HTML and flags script tags', - code: '
', - expectedSeverity: 'critical', - }, - { - name: 'falls back to JavaScript scanning', - code: 'const result = eval("1 + 1")', - expectedSeverity: 'critical', - }, - { - name: 'honors explicit type parameter', - code: 'return 1', - type: 'lua' as const, - expectedSeverity: 'safe', - }, - ])('should $name', ({ code, type, expectedSeverity }) => { - const result = scanForVulnerabilities(code, type) - expect(result.severity).toBe(expectedSeverity) - }) - }) -}) +import './__tests__/security-scanner.detection.test' +import './__tests__/security-scanner.reporting.test' From a37459ed627101f996029b4eefe5a28aaad9a322 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:55:07 +0000 Subject: [PATCH 4/6] chore: split javascript security patterns --- .../functions/patterns/javascript-patterns.ts | 181 +----------------- .../patterns/javascript/injection.ts | 53 +++++ .../functions/patterns/javascript/misc.ts | 81 ++++++++ .../functions/patterns/javascript/xss.ts | 53 +++++ 4 files changed, 193 insertions(+), 175 deletions(-) create mode 100644 frontends/nextjs/src/lib/security/functions/patterns/javascript/injection.ts create mode 100644 frontends/nextjs/src/lib/security/functions/patterns/javascript/misc.ts create mode 100644 frontends/nextjs/src/lib/security/functions/patterns/javascript/xss.ts diff --git a/frontends/nextjs/src/lib/security/functions/patterns/javascript-patterns.ts b/frontends/nextjs/src/lib/security/functions/patterns/javascript-patterns.ts index aa8214905..266675ae6 100644 --- a/frontends/nextjs/src/lib/security/functions/patterns/javascript-patterns.ts +++ b/frontends/nextjs/src/lib/security/functions/patterns/javascript-patterns.ts @@ -4,181 +4,12 @@ */ import type { SecurityPattern } from '../types' +import { JAVASCRIPT_INJECTION_PATTERNS } from './javascript/injection' +import { JAVASCRIPT_MISC_PATTERNS } from './javascript/misc' +import { JAVASCRIPT_XSS_PATTERNS } from './javascript/xss' export const JAVASCRIPT_PATTERNS: SecurityPattern[] = [ - { - pattern: /eval\s*\(/gi, - type: 'dangerous', - severity: 'critical', - message: 'Use of eval() detected - can execute arbitrary code', - recommendation: 'Use safe alternatives like JSON.parse() or Function constructor with strict validation' - }, - { - pattern: /Function\s*\(/gi, - type: 'dangerous', - severity: 'high', - message: 'Dynamic Function constructor detected', - recommendation: 'Avoid dynamic code generation or use with extreme caution' - }, - { - pattern: /innerHTML\s*=/gi, - type: 'dangerous', - severity: 'high', - message: 'innerHTML assignment detected - XSS vulnerability risk', - recommendation: 'Use textContent, createElement, or React JSX instead' - }, - { - pattern: /dangerouslySetInnerHTML/gi, - type: 'dangerous', - severity: 'high', - message: 'dangerouslySetInnerHTML detected - XSS vulnerability risk', - recommendation: 'Sanitize HTML content or use safe alternatives' - }, - { - pattern: /document\.write\s*\(/gi, - type: 'dangerous', - severity: 'medium', - message: 'document.write() detected - can cause security issues', - recommendation: 'Use DOM manipulation methods instead' - }, - { - pattern: /\.call\s*\(\s*window/gi, - type: 'suspicious', - severity: 'medium', - message: 'Calling functions with window context', - recommendation: 'Be careful with context manipulation' - }, - { - pattern: /\.apply\s*\(\s*window/gi, - type: 'suspicious', - severity: 'medium', - message: 'Applying functions with window context', - recommendation: 'Be careful with context manipulation' - }, - { - pattern: /__proto__/gi, - type: 'dangerous', - severity: 'critical', - message: 'Prototype pollution attempt detected', - recommendation: 'Never manipulate __proto__ directly' - }, - { - pattern: /constructor\s*\[\s*['"]prototype['"]\s*\]/gi, - type: 'dangerous', - severity: 'critical', - message: 'Prototype manipulation detected', - recommendation: 'Use Object.create() or proper class syntax' - }, - { - pattern: /import\s+.*\s+from\s+['"]https?:/gi, - type: 'dangerous', - severity: 'critical', - message: 'Remote code import detected', - recommendation: 'Only import from trusted, local sources' - }, - { - pattern: /]*>/gi, - type: 'dangerous', - severity: 'critical', - message: 'Script tag injection detected', - recommendation: 'Never inject script tags dynamically' - }, - { - pattern: /on(click|load|error|mouseover|mouseout|focus|blur)\s*=/gi, - type: 'suspicious', - severity: 'medium', - message: 'Inline event handler detected', - recommendation: 'Use addEventListener or React event handlers' - }, - { - pattern: /javascript:\s*/gi, - type: 'dangerous', - severity: 'high', - message: 'javascript: protocol detected', - recommendation: 'Never use javascript: protocol in URLs' - }, - { - pattern: /data:\s*text\/html/gi, - type: 'dangerous', - severity: 'high', - message: 'Data URI with HTML detected', - recommendation: 'Avoid data URIs with executable content' - }, - { - pattern: /setTimeout\s*\(\s*['"`]/gi, - type: 'dangerous', - severity: 'high', - message: 'setTimeout with string argument detected', - recommendation: 'Use setTimeout with function reference instead' - }, - { - pattern: /setInterval\s*\(\s*['"`]/gi, - type: 'dangerous', - severity: 'high', - message: 'setInterval with string argument detected', - recommendation: 'Use setInterval with function reference instead' - }, - { - pattern: /localStorage|sessionStorage/gi, - type: 'warning', - severity: 'low', - message: 'Local/session storage usage detected', - recommendation: 'Use useKV hook for persistent data instead' - }, - { - pattern: /crypto\.subtle|atob|btoa/gi, - type: 'warning', - severity: 'low', - message: 'Cryptographic operation detected', - recommendation: 'Ensure proper key management and secure practices' - }, - { - pattern: /XMLHttpRequest|fetch\s*\(\s*['"`]http/gi, - type: 'warning', - severity: 'medium', - message: 'External HTTP request detected', - recommendation: 'Ensure CORS and security headers are properly configured' - }, - { - pattern: /window\.open/gi, - type: 'suspicious', - severity: 'medium', - message: 'window.open detected', - recommendation: 'Be cautious with popup windows' - }, - { - pattern: /location\.href\s*=/gi, - type: 'suspicious', - severity: 'medium', - message: 'Direct location manipulation detected', - recommendation: 'Use React Router or validate URLs carefully' - }, - { - pattern: /require\s*\(\s*[^'"`]/gi, - type: 'dangerous', - severity: 'high', - message: 'Dynamic require() detected', - recommendation: 'Use static imports only' - }, - { - pattern: /\.exec\s*\(|child_process|spawn|fork|execFile/gi, - type: 'malicious', - severity: 'critical', - message: 'System command execution attempt detected', - recommendation: 'This is not allowed in browser environment' - }, - { - pattern: /fs\.|path\.|os\./gi, - type: 'malicious', - severity: 'critical', - message: 'Node.js system module usage detected', - recommendation: 'File system access not allowed in browser' - }, - { - pattern: /process\.env|process\.exit/gi, - type: 'suspicious', - severity: 'medium', - message: 'Process manipulation detected', - recommendation: 'Not applicable in browser environment' - } + ...JAVASCRIPT_INJECTION_PATTERNS, + ...JAVASCRIPT_XSS_PATTERNS, + ...JAVASCRIPT_MISC_PATTERNS ] diff --git a/frontends/nextjs/src/lib/security/functions/patterns/javascript/injection.ts b/frontends/nextjs/src/lib/security/functions/patterns/javascript/injection.ts new file mode 100644 index 000000000..a3a5a0cdd --- /dev/null +++ b/frontends/nextjs/src/lib/security/functions/patterns/javascript/injection.ts @@ -0,0 +1,53 @@ +import type { SecurityPattern } from '../../types' + +export const JAVASCRIPT_INJECTION_PATTERNS: SecurityPattern[] = [ + { + pattern: /eval\s*\(/gi, + type: 'dangerous', + severity: 'critical', + message: 'Use of eval() detected - can execute arbitrary code', + recommendation: 'Use safe alternatives like JSON.parse() or Function constructor with strict validation' + }, + { + pattern: /Function\s*\(/gi, + type: 'dangerous', + severity: 'high', + message: 'Dynamic Function constructor detected', + recommendation: 'Avoid dynamic code generation or use with extreme caution' + }, + { + pattern: /import\s+.*\s+from\s+['"]https?:/gi, + type: 'dangerous', + severity: 'critical', + message: 'Remote code import detected', + recommendation: 'Only import from trusted, local sources' + }, + { + pattern: /setTimeout\s*\(\s*['"`]/gi, + type: 'dangerous', + severity: 'high', + message: 'setTimeout with string argument detected', + recommendation: 'Use setTimeout with function reference instead' + }, + { + pattern: /setInterval\s*\(\s*['"`]/gi, + type: 'dangerous', + severity: 'high', + message: 'setInterval with string argument detected', + recommendation: 'Use setInterval with function reference instead' + }, + { + pattern: /require\s*\(\s*[^'"`]/gi, + type: 'dangerous', + severity: 'high', + message: 'Dynamic require() detected', + recommendation: 'Use static imports only' + }, + { + pattern: /\.exec\s*\(|child_process|spawn|fork|execFile/gi, + type: 'malicious', + severity: 'critical', + message: 'System command execution attempt detected', + recommendation: 'This is not allowed in browser environment' + } +] diff --git a/frontends/nextjs/src/lib/security/functions/patterns/javascript/misc.ts b/frontends/nextjs/src/lib/security/functions/patterns/javascript/misc.ts new file mode 100644 index 000000000..3b1efdaf9 --- /dev/null +++ b/frontends/nextjs/src/lib/security/functions/patterns/javascript/misc.ts @@ -0,0 +1,81 @@ +import type { SecurityPattern } from '../../types' + +export const JAVASCRIPT_MISC_PATTERNS: SecurityPattern[] = [ + { + pattern: /\.call\s*\(\s*window/gi, + type: 'suspicious', + severity: 'medium', + message: 'Calling functions with window context', + recommendation: 'Be careful with context manipulation' + }, + { + pattern: /\.apply\s*\(\s*window/gi, + type: 'suspicious', + severity: 'medium', + message: 'Applying functions with window context', + recommendation: 'Be careful with context manipulation' + }, + { + pattern: /__proto__/gi, + type: 'dangerous', + severity: 'critical', + message: 'Prototype pollution attempt detected', + recommendation: 'Never manipulate __proto__ directly' + }, + { + pattern: /constructor\s*\[\s*['"]prototype['"]\s*\]/gi, + type: 'dangerous', + severity: 'critical', + message: 'Prototype manipulation detected', + recommendation: 'Use Object.create() or proper class syntax' + }, + { + pattern: /localStorage|sessionStorage/gi, + type: 'warning', + severity: 'low', + message: 'Local/session storage usage detected', + recommendation: 'Use useKV hook for persistent data instead' + }, + { + pattern: /crypto\.subtle|atob|btoa/gi, + type: 'warning', + severity: 'low', + message: 'Cryptographic operation detected', + recommendation: 'Ensure proper key management and secure practices' + }, + { + pattern: /XMLHttpRequest|fetch\s*\(\s*['"`]http/gi, + type: 'warning', + severity: 'medium', + message: 'External HTTP request detected', + recommendation: 'Ensure CORS and security headers are properly configured' + }, + { + pattern: /window\.open/gi, + type: 'suspicious', + severity: 'medium', + message: 'window.open detected', + recommendation: 'Be cautious with popup windows' + }, + { + pattern: /location\.href\s*=/gi, + type: 'suspicious', + severity: 'medium', + message: 'Direct location manipulation detected', + recommendation: 'Use React Router or validate URLs carefully' + }, + { + pattern: /fs\.|path\.|os\./gi, + type: 'malicious', + severity: 'critical', + message: 'Node.js system module usage detected', + recommendation: 'File system access not allowed in browser' + }, + { + pattern: /process\.env|process\.exit/gi, + type: 'suspicious', + severity: 'medium', + message: 'Process manipulation detected', + recommendation: 'Not applicable in browser environment' + } +] diff --git a/frontends/nextjs/src/lib/security/functions/patterns/javascript/xss.ts b/frontends/nextjs/src/lib/security/functions/patterns/javascript/xss.ts new file mode 100644 index 000000000..378fa8fd4 --- /dev/null +++ b/frontends/nextjs/src/lib/security/functions/patterns/javascript/xss.ts @@ -0,0 +1,53 @@ +import type { SecurityPattern } from '../../types' + +export const JAVASCRIPT_XSS_PATTERNS: SecurityPattern[] = [ + { + pattern: /innerHTML\s*=/gi, + type: 'dangerous', + severity: 'high', + message: 'innerHTML assignment detected - XSS vulnerability risk', + recommendation: 'Use textContent, createElement, or React JSX instead' + }, + { + pattern: /dangerouslySetInnerHTML/gi, + type: 'dangerous', + severity: 'high', + message: 'dangerouslySetInnerHTML detected - XSS vulnerability risk', + recommendation: 'Sanitize HTML content or use safe alternatives' + }, + { + pattern: /document\.write\s*\(/gi, + type: 'dangerous', + severity: 'medium', + message: 'document.write() detected - can cause security issues', + recommendation: 'Use DOM manipulation methods instead' + }, + { + pattern: /]*>/gi, + type: 'dangerous', + severity: 'critical', + message: 'Script tag injection detected', + recommendation: 'Never inject script tags dynamically' + }, + { + pattern: /on(click|load|error|mouseover|mouseout|focus|blur)\s*=/gi, + type: 'suspicious', + severity: 'medium', + message: 'Inline event handler detected', + recommendation: 'Use addEventListener or React event handlers' + }, + { + pattern: /javascript:\s*/gi, + type: 'dangerous', + severity: 'high', + message: 'javascript: protocol detected', + recommendation: 'Never use javascript: protocol in URLs' + }, + { + pattern: /data:\s*text\/html/gi, + type: 'dangerous', + severity: 'high', + message: 'Data URI with HTML detected', + recommendation: 'Avoid data URIs with executable content' + } +] From 7282290d1a3eff7d7daa12703e5c249b71baa065 Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:56:18 +0000 Subject: [PATCH 5/6] refactor: modularize template configs --- .../templates/configs/advanced.ts | 3 + .../nerd-mode-ide/templates/configs/base.ts | 267 +++++++++++++++++ .../templates/configs/experimental.ts | 3 + .../templates/template-configs.ts | 271 +----------------- 4 files changed, 281 insertions(+), 263 deletions(-) create mode 100644 frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/advanced.ts create mode 100644 frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts create mode 100644 frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/experimental.ts diff --git a/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/advanced.ts b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/advanced.ts new file mode 100644 index 000000000..6c92a720b --- /dev/null +++ b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/advanced.ts @@ -0,0 +1,3 @@ +import type { PackageTemplateConfig } from '../../types' + +export const ADVANCED_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [] diff --git a/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts new file mode 100644 index 000000000..4a129db79 --- /dev/null +++ b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/base.ts @@ -0,0 +1,267 @@ +import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../../types' + +export const BASE_REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = { + id: 'react_next_starter', + name: 'Next.js Web App', + description: 'A clean Next.js starter with app router, hero component, and typed config files.', + rootName: 'web_app', + tags: ['nextjs', 'react', 'web', 'starter'], +} + +const socialHubComponents = [ + { + id: 'social_hub_root', + type: 'Stack', + props: { className: 'flex flex-col gap-6' }, + children: [ + { + id: 'social_hub_hero', + type: 'Card', + props: { className: 'p-6' }, + children: [ + { + id: 'social_hub_heading', + type: 'Heading', + props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' }, + children: [], + }, + { + id: 'social_hub_subtitle', + type: 'Text', + props: { children: 'A modern feed for creator updates, curated stories, and live moments.' }, + children: [], + }, + ], + }, + { + id: 'social_hub_stats', + type: 'Grid', + props: { className: 'grid grid-cols-3 gap-4' }, + children: [ + { + id: 'social_hub_stat_1', + type: 'Card', + props: { className: 'p-4' }, + children: [ + { + id: 'social_hub_stat_label_1', + type: 'Text', + props: { children: 'Creators live', className: 'text-sm text-muted-foreground' }, + children: [], + }, + { + id: 'social_hub_stat_value_1', + type: 'Heading', + props: { children: '128', level: '3', className: 'text-xl font-semibold' }, + children: [], + }, + ], + }, + { + id: 'social_hub_stat_2', + type: 'Card', + props: { className: 'p-4' }, + children: [ + { + id: 'social_hub_stat_label_2', + type: 'Text', + props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' }, + children: [], + }, + { + id: 'social_hub_stat_value_2', + type: 'Heading', + props: { children: '42', level: '3', className: 'text-xl font-semibold' }, + children: [], + }, + ], + }, + { + id: 'social_hub_stat_3', + type: 'Card', + props: { className: 'p-4' }, + children: [ + { + id: 'social_hub_stat_label_3', + type: 'Text', + props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' }, + children: [], + }, + { + id: 'social_hub_stat_value_3', + type: 'Heading', + props: { children: '7', level: '3', className: 'text-xl font-semibold' }, + children: [], + }, + ], + }, + ], + }, + { + id: 'social_hub_composer', + type: 'Card', + props: { className: 'p-4' }, + children: [ + { + id: 'social_hub_composer_label', + type: 'Label', + props: { children: 'Share a quick update' }, + children: [], + }, + { + id: 'social_hub_composer_input', + type: 'Textarea', + props: { placeholder: 'What are you building today?', rows: 3 }, + children: [], + }, + { + id: 'social_hub_composer_actions', + type: 'Flex', + props: { className: 'flex gap-2' }, + children: [ + { + id: 'social_hub_composer_publish', + type: 'Button', + props: { children: 'Publish', variant: 'default' }, + children: [], + }, + { + id: 'social_hub_composer_media', + type: 'Button', + props: { children: 'Add media', variant: 'outline' }, + children: [], + }, + ], + }, + ], + }, + { + id: 'social_hub_feed', + type: 'Stack', + props: { className: 'flex flex-col gap-4' }, + children: [ + { + id: 'social_hub_feed_post_1', + type: 'Card', + props: { className: 'p-5' }, + children: [ + { + id: 'social_hub_feed_post_1_title', + type: 'Heading', + props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' }, + children: [], + }, + { + id: 'social_hub_feed_post_1_body', + type: 'Text', + props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' }, + children: [], + }, + { + id: 'social_hub_feed_post_1_badge', + type: 'Badge', + props: { children: 'Community' }, + children: [], + }, + ], + }, + { + id: 'social_hub_feed_post_2', + type: 'Card', + props: { className: 'p-5' }, + children: [ + { + id: 'social_hub_feed_post_2_title', + type: 'Heading', + props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' }, + children: [], + }, + { + id: 'social_hub_feed_post_2_body', + type: 'Text', + props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' }, + children: [], + }, + { + id: 'social_hub_feed_post_2_badge', + type: 'Badge', + props: { children: 'Spotlight', variant: 'secondary' }, + children: [], + }, + ], + }, + ], + }, + ], + }, +] + +const socialHubExamples = { + feedItems: [ + { + id: 'post_001', + author: 'Nova', + title: 'Launch day recap', + summary: 'We shipped live rooms and doubled community sessions.', + tags: ['launch', 'community'], + }, + { + id: 'post_002', + author: 'Kai', + title: 'Build log: day 42', + summary: 'Refined the moderation pipeline and added creator scorecards.', + tags: ['buildinpublic'], + }, + ], + trendingTags: ['#buildinpublic', '#metabuilder', '#live'], + rooms: [ + { id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true }, + { id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false }, + ], +} + +const socialHubLuaScripts = [ + { + fileName: 'init.lua', + description: 'Lifecycle hooks for package installation.', + code: 'local M = {}\\n\\nfunction M.on_install(context)\\n return { message = "Social Hub installed", version = context.version }\\nend\\n\\nfunction M.on_uninstall()\\n return { message = "Social Hub removed" }\\nend\\n\\nreturn M', + }, + { + fileName: 'permissions.lua', + description: 'Role-based access rules for posting and moderation.', + code: 'local Permissions = {}\\n\\nfunction Permissions.can_post(user)\\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\\nend\\n\\nfunction Permissions.can_moderate(user)\\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\\nend\\n\\nreturn Permissions', + }, + { + fileName: 'feed_rank.lua', + description: 'Score feed items based on recency and engagement.', + code: 'local FeedRank = {}\\n\\nfunction FeedRank.score(item)\\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\\n return freshness + engagement\\nend\\n\\nreturn FeedRank', + }, + { + fileName: 'moderation.lua', + description: 'Flag content for review using lightweight heuristics.', + code: 'local Moderation = {}\\n\\nfunction Moderation.flag(content)\\n local lowered = string.lower(content or "")\\n if string.find(lowered, "spam") then\\n return { flagged = true, reason = "spam_keyword" }\\n end\\n return { flagged = false }\\nend\\n\\nreturn Moderation', + }, + { + fileName: 'analytics.lua', + description: 'Aggregate engagement signals for dashboards.', + code: 'local Analytics = {}\\n\\nfunction Analytics.aggregate(events)\\n local summary = { views = 0, likes = 0, comments = 0 }\\n for _, event in ipairs(events or {}) do\\n summary.views = summary.views + (event.views or 0)\\n summary.likes = summary.likes + (event.likes or 0)\\n summary.comments = summary.comments + (event.comments or 0)\\n end\\n return summary\\nend\\n\\nreturn Analytics', + }, +] + +export const BASE_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [ + { + id: 'package_social_hub', + name: 'Social Hub Package', + description: 'A package blueprint for social feeds, creator updates, and live rooms.', + rootName: 'social_hub', + packageId: 'social_hub', + author: 'MetaBuilder', + version: '1.0.0', + category: 'social', + summary: 'Modern social feed with creator tools and live rooms.', + components: socialHubComponents, + examples: socialHubExamples, + luaScripts: socialHubLuaScripts, + tags: ['package', 'social', 'feed', 'lua'], + }, +] diff --git a/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/experimental.ts b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/experimental.ts new file mode 100644 index 000000000..3180b351b --- /dev/null +++ b/frontends/nextjs/src/lib/nerd-mode-ide/templates/configs/experimental.ts @@ -0,0 +1,3 @@ +import type { PackageTemplateConfig } from '../../types' + +export const EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [] diff --git a/frontends/nextjs/src/lib/nerd-mode-ide/templates/template-configs.ts b/frontends/nextjs/src/lib/nerd-mode-ide/templates/template-configs.ts index 60badbc19..b134a9822 100644 --- a/frontends/nextjs/src/lib/nerd-mode-ide/templates/template-configs.ts +++ b/frontends/nextjs/src/lib/nerd-mode-ide/templates/template-configs.ts @@ -1,267 +1,12 @@ -import type { PackageTemplateConfig, ReactAppTemplateConfig } from './types' +import type { PackageTemplateConfig, ReactAppTemplateConfig } from '../types' +import { ADVANCED_PACKAGE_TEMPLATE_CONFIGS } from './configs/advanced' +import { BASE_PACKAGE_TEMPLATE_CONFIGS, BASE_REACT_APP_TEMPLATE_CONFIG } from './configs/base' +import { EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS } from './configs/experimental' -export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = { - id: 'react_next_starter', - name: 'Next.js Web App', - description: 'A clean Next.js starter with app router, hero component, and typed config files.', - rootName: 'web_app', - tags: ['nextjs', 'react', 'web', 'starter'], -} - -const socialHubComponents = [ - { - id: 'social_hub_root', - type: 'Stack', - props: { className: 'flex flex-col gap-6' }, - children: [ - { - id: 'social_hub_hero', - type: 'Card', - props: { className: 'p-6' }, - children: [ - { - id: 'social_hub_heading', - type: 'Heading', - props: { children: 'Social Hub', level: '2', className: 'text-2xl font-bold' }, - children: [], - }, - { - id: 'social_hub_subtitle', - type: 'Text', - props: { children: 'A modern feed for creator updates, curated stories, and live moments.' }, - children: [], - }, - ], - }, - { - id: 'social_hub_stats', - type: 'Grid', - props: { className: 'grid grid-cols-3 gap-4' }, - children: [ - { - id: 'social_hub_stat_1', - type: 'Card', - props: { className: 'p-4' }, - children: [ - { - id: 'social_hub_stat_label_1', - type: 'Text', - props: { children: 'Creators live', className: 'text-sm text-muted-foreground' }, - children: [], - }, - { - id: 'social_hub_stat_value_1', - type: 'Heading', - props: { children: '128', level: '3', className: 'text-xl font-semibold' }, - children: [], - }, - ], - }, - { - id: 'social_hub_stat_2', - type: 'Card', - props: { className: 'p-4' }, - children: [ - { - id: 'social_hub_stat_label_2', - type: 'Text', - props: { children: 'Trending tags', className: 'text-sm text-muted-foreground' }, - children: [], - }, - { - id: 'social_hub_stat_value_2', - type: 'Heading', - props: { children: '42', level: '3', className: 'text-xl font-semibold' }, - children: [], - }, - ], - }, - { - id: 'social_hub_stat_3', - type: 'Card', - props: { className: 'p-4' }, - children: [ - { - id: 'social_hub_stat_label_3', - type: 'Text', - props: { children: 'Live rooms', className: 'text-sm text-muted-foreground' }, - children: [], - }, - { - id: 'social_hub_stat_value_3', - type: 'Heading', - props: { children: '7', level: '3', className: 'text-xl font-semibold' }, - children: [], - }, - ], - }, - ], - }, - { - id: 'social_hub_composer', - type: 'Card', - props: { className: 'p-4' }, - children: [ - { - id: 'social_hub_composer_label', - type: 'Label', - props: { children: 'Share a quick update' }, - children: [], - }, - { - id: 'social_hub_composer_input', - type: 'Textarea', - props: { placeholder: 'What are you building today?', rows: 3 }, - children: [], - }, - { - id: 'social_hub_composer_actions', - type: 'Flex', - props: { className: 'flex gap-2' }, - children: [ - { - id: 'social_hub_composer_publish', - type: 'Button', - props: { children: 'Publish', variant: 'default' }, - children: [], - }, - { - id: 'social_hub_composer_media', - type: 'Button', - props: { children: 'Add media', variant: 'outline' }, - children: [], - }, - ], - }, - ], - }, - { - id: 'social_hub_feed', - type: 'Stack', - props: { className: 'flex flex-col gap-4' }, - children: [ - { - id: 'social_hub_feed_post_1', - type: 'Card', - props: { className: 'p-5' }, - children: [ - { - id: 'social_hub_feed_post_1_title', - type: 'Heading', - props: { children: 'Launch day recap', level: '3', className: 'text-lg font-semibold' }, - children: [], - }, - { - id: 'social_hub_feed_post_1_body', - type: 'Text', - props: { children: 'We shipped the new live rooms and saw a 32% boost in engagement.' }, - children: [], - }, - { - id: 'social_hub_feed_post_1_badge', - type: 'Badge', - props: { children: 'Community' }, - children: [], - }, - ], - }, - { - id: 'social_hub_feed_post_2', - type: 'Card', - props: { className: 'p-5' }, - children: [ - { - id: 'social_hub_feed_post_2_title', - type: 'Heading', - props: { children: 'Creator spotlight', level: '3', className: 'text-lg font-semibold' }, - children: [], - }, - { - id: 'social_hub_feed_post_2_body', - type: 'Text', - props: { children: 'Nova shares her workflow for livestreaming and managing subscribers.' }, - children: [], - }, - { - id: 'social_hub_feed_post_2_badge', - type: 'Badge', - props: { children: 'Spotlight', variant: 'secondary' }, - children: [], - }, - ], - }, - ], - }, - ], - }, -] - -const socialHubExamples = { - feedItems: [ - { - id: 'post_001', - author: 'Nova', - title: 'Launch day recap', - summary: 'We shipped live rooms and doubled community sessions.', - tags: ['launch', 'community'], - }, - { - id: 'post_002', - author: 'Kai', - title: 'Build log: day 42', - summary: 'Refined the moderation pipeline and added creator scorecards.', - tags: ['buildinpublic'], - }, - ], - trendingTags: ['#buildinpublic', '#metabuilder', '#live'], - rooms: [ - { id: 'room_1', title: 'Creator Q&A', host: 'Eli', live: true }, - { id: 'room_2', title: 'Patch Notes', host: 'Nova', live: false }, - ], -} - -const socialHubLuaScripts = [ - { - fileName: 'init.lua', - description: 'Lifecycle hooks for package installation.', - code: 'local M = {}\n\nfunction M.on_install(context)\n return { message = "Social Hub installed", version = context.version }\nend\n\nfunction M.on_uninstall()\n return { message = "Social Hub removed" }\nend\n\nreturn M', - }, - { - fileName: 'permissions.lua', - description: 'Role-based access rules for posting and moderation.', - code: 'local Permissions = {}\n\nfunction Permissions.can_post(user)\n return user and (user.role == "user" or user.role == "admin" or user.role == "god")\nend\n\nfunction Permissions.can_moderate(user)\n return user and (user.role == "admin" or user.role == "god" or user.role == "supergod")\nend\n\nreturn Permissions', - }, - { - fileName: 'feed_rank.lua', - description: 'Score feed items based on recency and engagement.', - code: 'local FeedRank = {}\n\nfunction FeedRank.score(item)\n local freshness = item.age_minutes and (100 - item.age_minutes) or 50\n local engagement = (item.likes or 0) * 2 + (item.comments or 0) * 3\n return freshness + engagement\nend\n\nreturn FeedRank', - }, - { - fileName: 'moderation.lua', - description: 'Flag content for review using lightweight heuristics.', - code: 'local Moderation = {}\n\nfunction Moderation.flag(content)\n local lowered = string.lower(content or "")\n if string.find(lowered, "spam") then\n return { flagged = true, reason = "spam_keyword" }\n end\n return { flagged = false }\nend\n\nreturn Moderation', - }, - { - fileName: 'analytics.lua', - description: 'Aggregate engagement signals for dashboards.', - code: 'local Analytics = {}\n\nfunction Analytics.aggregate(events)\n local summary = { views = 0, likes = 0, comments = 0 }\n for _, event in ipairs(events or {}) do\n summary.views = summary.views + (event.views or 0)\n summary.likes = summary.likes + (event.likes or 0)\n summary.comments = summary.comments + (event.comments or 0)\n end\n return summary\nend\n\nreturn Analytics', - }, -] +export const REACT_APP_TEMPLATE_CONFIG: ReactAppTemplateConfig = BASE_REACT_APP_TEMPLATE_CONFIG export const PACKAGE_TEMPLATE_CONFIGS: PackageTemplateConfig[] = [ - { - id: 'package_social_hub', - name: 'Social Hub Package', - description: 'A package blueprint for social feeds, creator updates, and live rooms.', - rootName: 'social_hub', - packageId: 'social_hub', - author: 'MetaBuilder', - version: '1.0.0', - category: 'social', - summary: 'Modern social feed with creator tools and live rooms.', - components: socialHubComponents, - examples: socialHubExamples, - luaScripts: socialHubLuaScripts, - tags: ['package', 'social', 'feed', 'lua'], - }, + ...BASE_PACKAGE_TEMPLATE_CONFIGS, + ...ADVANCED_PACKAGE_TEMPLATE_CONFIGS, + ...EXPERIMENTAL_PACKAGE_TEMPLATE_CONFIGS, ] From 0656df5a0f9a389b663831108b413e4e2eaaa11b Mon Sep 17 00:00:00 2001 From: johndoe6345789 Date: Sat, 27 Dec 2025 18:56:55 +0000 Subject: [PATCH 6/6] refactor: split workflow run analysis helpers --- .../runs/analyze-workflow-runs.test.ts | 70 +++++++- .../analysis/runs/analyze-workflow-runs.ts | 168 ++---------------- .../github/workflows/analysis/runs/parser.ts | 50 ++++++ .../github/workflows/analysis/runs/stats.ts | 153 ++++++++++++++++ 4 files changed, 283 insertions(+), 158 deletions(-) create mode 100644 frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts create mode 100644 frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts index ce3d5d2a1..ce1897add 100644 --- a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts +++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.test.ts @@ -1,5 +1,52 @@ import { describe, it, expect } from 'vitest' -import { summarizeWorkflowRuns } from './analyze-workflow-runs' +import { + analyzeWorkflowRuns, + parseWorkflowRuns, + summarizeWorkflowRuns, +} from './analyze-workflow-runs' + +describe('parseWorkflowRuns', () => { + it('normalizes unknown entries and ignores items without numeric IDs', () => { + const runs = [ + { + id: 1, + name: 'Build', + status: 'completed', + conclusion: 'success', + created_at: '2024-01-01T00:00:00Z', + updated_at: '2024-01-01T00:10:00Z', + head_branch: 'main', + event: 'push', + }, + { id: 'not-a-number' }, + { + id: 2, + name: '', + status: '', + conclusion: 'failure', + created_at: '', + updated_at: '', + head_branch: '', + event: '', + }, + ] + + const parsed = parseWorkflowRuns(runs) + + expect(parsed).toHaveLength(2) + expect(parsed[0].name).toBe('Build') + expect(parsed[1]).toEqual({ + id: 2, + name: 'Unknown workflow', + status: 'unknown', + conclusion: 'failure', + created_at: '', + updated_at: '', + head_branch: 'unknown', + event: 'unknown', + }) + }) +}) describe('summarizeWorkflowRuns', () => { it('summarizes totals, success rate, and failure hotspots', () => { @@ -60,3 +107,24 @@ describe('summarizeWorkflowRuns', () => { expect(summary.mostRecent).toBeNull() }) }) + +describe('analyzeWorkflowRuns', () => { + it('returns parsed summary and formatted output', () => { + const result = analyzeWorkflowRuns([ + { + id: 7, + name: 'Deploy', + status: 'completed', + conclusion: 'success', + created_at: '2024-02-01T00:00:00Z', + updated_at: '2024-02-01T00:05:00Z', + head_branch: 'main', + event: 'workflow_dispatch', + }, + ]) + + expect(result.summary.total).toBe(1) + expect(result.formatted).toContain('Workflow Run Analysis') + expect(result.formatted).toContain('Deploy') + }) +}) diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts index 0c9de1453..2f0049237 100644 --- a/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts +++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/analyze-workflow-runs.ts @@ -1,164 +1,18 @@ -export type WorkflowRunLike = { - id: number - name: string - status: string - conclusion: string | null - created_at: string - updated_at: string - head_branch: string - event: string -} +import { parseWorkflowRuns, WorkflowRunLike } from './parser' +import { formatWorkflowRunAnalysis, summarizeWorkflowRuns, WorkflowRunSummary } from './stats' -export type WorkflowRunSummary = { - total: number - completed: number - successful: number - failed: number - cancelled: number - inProgress: number - successRate: number - mostRecent: WorkflowRunLike | null - recentRuns: WorkflowRunLike[] - topFailingWorkflows: Array<{ name: string; failures: number }> - failingBranches: Array<{ branch: string; failures: number }> - failingEvents: Array<{ event: string; failures: number }> -} +export type { WorkflowRunLike, WorkflowRunSummary } +export { parseWorkflowRuns, summarizeWorkflowRuns, formatWorkflowRunAnalysis } -const DEFAULT_RECENT_COUNT = 5 -const DEFAULT_TOP_COUNT = 3 - -function toTopCounts( - values: string[], - topCount: number -): Array<{ key: string; count: number }> { - const counts = new Map() - values.forEach((value) => { - counts.set(value, (counts.get(value) || 0) + 1) - }) - - return Array.from(counts.entries()) - .map(([key, count]) => ({ key, count })) - .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)) - .slice(0, topCount) -} - -export function summarizeWorkflowRuns( - runs: WorkflowRunLike[], +export function analyzeWorkflowRuns( + runs: unknown[], options?: { recentCount?: number; topCount?: number } -): WorkflowRunSummary { - const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT - const topCount = options?.topCount ?? DEFAULT_TOP_COUNT - const total = runs.length - - const completedRuns = runs.filter((run) => run.status === 'completed') - const successful = completedRuns.filter((run) => run.conclusion === 'success').length - const failed = completedRuns.filter((run) => run.conclusion === 'failure').length - const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length - const inProgress = total - completedRuns.length - const successRate = completedRuns.length - ? Math.round((successful / completedRuns.length) * 100) - : 0 - - const sortedByUpdated = [...runs].sort( - (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() - ) - const mostRecent = sortedByUpdated[0] ?? null - const recentRuns = sortedByUpdated.slice(0, recentCount) - - const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure') - const topFailingWorkflows = toTopCounts( - failureRuns.map((run) => run.name), - topCount - ).map((entry) => ({ name: entry.key, failures: entry.count })) - - const failingBranches = toTopCounts( - failureRuns.map((run) => run.head_branch), - topCount - ).map((entry) => ({ branch: entry.key, failures: entry.count })) - - const failingEvents = toTopCounts( - failureRuns.map((run) => run.event), - topCount - ).map((entry) => ({ event: entry.key, failures: entry.count })) +) { + const parsedRuns = parseWorkflowRuns(runs) + const summary = summarizeWorkflowRuns(parsedRuns, options) return { - total, - completed: completedRuns.length, - successful, - failed, - cancelled, - inProgress, - successRate, - mostRecent, - recentRuns, - topFailingWorkflows, - failingBranches, - failingEvents, + summary, + formatted: formatWorkflowRunAnalysis(summary), } } - -export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) { - const lines: string[] = [] - - lines.push('Workflow Run Analysis') - lines.push('---------------------') - lines.push(`Total runs: ${summary.total}`) - lines.push( - `Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})` - ) - lines.push(`In progress: ${summary.inProgress}`) - lines.push(`Success rate: ${summary.successRate}%`) - - if (summary.mostRecent) { - lines.push('') - lines.push('Most recent run:') - lines.push( - `- ${summary.mostRecent.name} | ${summary.mostRecent.status}${ - summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : '' - } | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}` - ) - } - - if (summary.recentRuns.length > 0) { - lines.push('') - lines.push('Recent runs:') - summary.recentRuns.forEach((run) => { - lines.push( - `- ${run.name} | ${run.status}${ - run.conclusion ? `/${run.conclusion}` : '' - } | ${run.head_branch} | ${run.updated_at}` - ) - }) - } - - if (summary.topFailingWorkflows.length > 0) { - lines.push('') - lines.push('Top failing workflows:') - summary.topFailingWorkflows.forEach((entry) => { - lines.push(`- ${entry.name}: ${entry.failures}`) - }) - } - - if (summary.failingBranches.length > 0) { - lines.push('') - lines.push('Failing branches:') - summary.failingBranches.forEach((entry) => { - lines.push(`- ${entry.branch}: ${entry.failures}`) - }) - } - - if (summary.failingEvents.length > 0) { - lines.push('') - lines.push('Failing events:') - summary.failingEvents.forEach((entry) => { - lines.push(`- ${entry.event}: ${entry.failures}`) - }) - } - - if (summary.total === 0) { - lines.push('') - lines.push('No workflow runs available to analyze.') - } - - return lines.join('\n') -} diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts new file mode 100644 index 000000000..25570391d --- /dev/null +++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/parser.ts @@ -0,0 +1,50 @@ +export type WorkflowRunLike = { + id: number + name: string + status: string + conclusion: string | null + created_at: string + updated_at: string + head_branch: string + event: string +} + +const FALLBACK_NAME = 'Unknown workflow' +const FALLBACK_STATUS = 'unknown' +const FALLBACK_BRANCH = 'unknown' +const FALLBACK_EVENT = 'unknown' + +function toStringOrFallback(value: unknown, fallback: string) { + return typeof value === 'string' && value.trim() ? value : fallback +} + +export function parseWorkflowRuns(runs: unknown[]): WorkflowRunLike[] { + if (!Array.isArray(runs)) { + return [] + } + + return runs + .map((run) => { + const candidate = run as Partial & { id?: unknown } + const id = Number(candidate.id) + + if (!Number.isFinite(id)) { + return null + } + + return { + id, + name: toStringOrFallback(candidate.name, FALLBACK_NAME), + status: toStringOrFallback(candidate.status, FALLBACK_STATUS), + conclusion: + candidate.conclusion === null || typeof candidate.conclusion === 'string' + ? candidate.conclusion + : null, + created_at: toStringOrFallback(candidate.created_at, ''), + updated_at: toStringOrFallback(candidate.updated_at, ''), + head_branch: toStringOrFallback(candidate.head_branch, FALLBACK_BRANCH), + event: toStringOrFallback(candidate.event, FALLBACK_EVENT), + } + }) + .filter((run): run is WorkflowRunLike => Boolean(run)) +} diff --git a/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts b/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts new file mode 100644 index 000000000..138771e60 --- /dev/null +++ b/frontends/nextjs/src/lib/github/workflows/analysis/runs/stats.ts @@ -0,0 +1,153 @@ +import { WorkflowRunLike } from './parser' + +export type WorkflowRunSummary = { + total: number + completed: number + successful: number + failed: number + cancelled: number + inProgress: number + successRate: number + mostRecent: WorkflowRunLike | null + recentRuns: WorkflowRunLike[] + topFailingWorkflows: Array<{ name: string; failures: number }> + failingBranches: Array<{ branch: string; failures: number }> + failingEvents: Array<{ event: string; failures: number }> +} + +const DEFAULT_RECENT_COUNT = 5 +const DEFAULT_TOP_COUNT = 3 + +function toTopCounts( + values: string[], + topCount: number +): Array<{ key: string; count: number }> { + const counts = new Map() + values.forEach((value) => { + counts.set(value, (counts.get(value) || 0) + 1) + }) + + return Array.from(counts.entries()) + .map(([key, count]) => ({ key, count })) + .sort((a, b) => b.count - a.count || a.key.localeCompare(b.key)) + .slice(0, topCount) +} + +export function summarizeWorkflowRuns( + runs: WorkflowRunLike[], + options?: { recentCount?: number; topCount?: number } +): WorkflowRunSummary { + const recentCount = options?.recentCount ?? DEFAULT_RECENT_COUNT + const topCount = options?.topCount ?? DEFAULT_TOP_COUNT + const total = runs.length + + const completedRuns = runs.filter((run) => run.status === 'completed') + const successful = completedRuns.filter((run) => run.conclusion === 'success').length + const failed = completedRuns.filter((run) => run.conclusion === 'failure').length + const cancelled = completedRuns.filter((run) => run.conclusion === 'cancelled').length + const inProgress = total - completedRuns.length + const successRate = completedRuns.length + ? Math.round((successful / completedRuns.length) * 100) + : 0 + + const sortedByUpdated = [...runs].sort( + (a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime() + ) + const mostRecent = sortedByUpdated[0] ?? null + const recentRuns = sortedByUpdated.slice(0, recentCount) + + const failureRuns = completedRuns.filter((run) => run.conclusion === 'failure') + const topFailingWorkflows = toTopCounts( + failureRuns.map((run) => run.name), + topCount + ).map((entry) => ({ name: entry.key, failures: entry.count })) + + const failingBranches = toTopCounts( + failureRuns.map((run) => run.head_branch), + topCount + ).map((entry) => ({ branch: entry.key, failures: entry.count })) + + const failingEvents = toTopCounts( + failureRuns.map((run) => run.event), + topCount + ).map((entry) => ({ event: entry.key, failures: entry.count })) + + return { + total, + completed: completedRuns.length, + successful, + failed, + cancelled, + inProgress, + successRate, + mostRecent, + recentRuns, + topFailingWorkflows, + failingBranches, + failingEvents, + } +} + +export function formatWorkflowRunAnalysis(summary: WorkflowRunSummary) { + const lines: string[] = [] + + lines.push('Workflow Run Analysis') + lines.push('---------------------') + lines.push(`Total runs: ${summary.total}`) + lines.push( + `Completed: ${summary.completed} (success: ${summary.successful}, failed: ${summary.failed}, cancelled: ${summary.cancelled})` + ) + lines.push(`In progress: ${summary.inProgress}`) + lines.push(`Success rate: ${summary.successRate}%`) + + if (summary.mostRecent) { + lines.push('') + lines.push('Most recent run:') + lines.push( + `- ${summary.mostRecent.name} | ${summary.mostRecent.status}${ + summary.mostRecent.conclusion ? `/${summary.mostRecent.conclusion}` : '' + } | ${summary.mostRecent.head_branch} | ${summary.mostRecent.updated_at}` + ) + } + + if (summary.recentRuns.length > 0) { + lines.push('') + lines.push('Recent runs:') + summary.recentRuns.forEach((run) => { + lines.push( + `- ${run.name} | ${run.status}${run.conclusion ? `/${run.conclusion}` : ''} | ${run.head_branch} | ${run.updated_at}` + ) + }) + } + + if (summary.topFailingWorkflows.length > 0) { + lines.push('') + lines.push('Top failing workflows:') + summary.topFailingWorkflows.forEach((entry) => { + lines.push(`- ${entry.name}: ${entry.failures}`) + }) + } + + if (summary.failingBranches.length > 0) { + lines.push('') + lines.push('Failing branches:') + summary.failingBranches.forEach((entry) => { + lines.push(`- ${entry.branch}: ${entry.failures}`) + }) + } + + if (summary.failingEvents.length > 0) { + lines.push('') + lines.push('Failing events:') + summary.failingEvents.forEach((entry) => { + lines.push(`- ${entry.event}: ${entry.failures}`) + }) + } + + if (summary.total === 0) { + lines.push('') + lines.push('No workflow runs available to analyze.') + } + + return lines.join('\n') +}