Compare commits
17 Commits
8418d2535b
...
6fb2519cfd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fb2519cfd | ||
|
|
dd82b88e26 | ||
|
|
6979a87d92 | ||
|
|
f69e824f78 | ||
|
|
9a0099a95a | ||
|
|
43113fc865 | ||
|
|
d0adca8583 | ||
|
|
0749c22d30 | ||
|
|
338c41c3c8 | ||
|
|
4f3636d66d | ||
|
|
56cca5b4b8 | ||
|
|
ff4de855cc | ||
|
|
de43bca80f | ||
|
|
cc19b7eabb | ||
|
|
8d844f2d61 | ||
|
|
422674ded0 | ||
|
|
1693ced112 |
@ -1,6 +1,6 @@
|
|||||||
# check-safety
|
# check-safety
|
||||||
|
|
||||||
Static safety checker for the BlockNinja codebase. Walks a target tree, runs 31 invariant
|
Static safety checker for the BlockNinja codebase. Walks a target tree, runs 33 invariant
|
||||||
checks across Go and frontend sources, and exits non-zero on any violation.
|
checks across Go and frontend sources, and exits non-zero on any violation.
|
||||||
|
|
||||||
Lives at `~/src/blockninja/check-safety/` as a standalone Go module, alongside `cms/`,
|
Lives at `~/src/blockninja/check-safety/` as a standalone Go module, alongside `cms/`,
|
||||||
@ -96,7 +96,7 @@ tool prints in each check header):
|
|||||||
| 1 | Secret env var reads | Secret env vars (`JWT_SECRET`, `VAULT_*`, encryption keys, …) are read only inside `config.Load()` (`config/config.go`) or `_test.go` files. |
|
| 1 | Secret env var reads | Secret env vars (`JWT_SECRET`, `VAULT_*`, encryption keys, …) are read only inside `config.Load()` (`config/config.go`) or `_test.go` files. |
|
||||||
| 2 | RBAC registration | Every RPC method is registered in the RBAC interceptor. Internal / plugin-managed services (e.g. `ManagementService`, Symposium's `WikiService`) are exempted. |
|
| 2 | RBAC registration | Every RPC method is registered in the RBAC interceptor. Internal / plugin-managed services (e.g. `ManagementService`, Symposium's `WikiService`) are exempted. |
|
||||||
| 2b | Plugin proto ownership | Plugins own their proto/RBAC definitions correctly (no poaching of core proto packages). |
|
| 2b | Plugin proto ownership | Plugins own their proto/RBAC definitions correctly (no poaching of core proto packages). |
|
||||||
| 2c | Plugin SDK boundaries | Standalone plugins import only the published `block/core` SDK boundary — verified via imports, `go.mod`, and version. |
|
| 2c | Plugin SDK boundaries | Standalone plugins build against the published `block/pluginsdk` SDK (a coexisting `block/core` require is allowed) — verified via imports, `go.mod`, and version. |
|
||||||
| 2d | sqlc UUID overrides | sqlc UUID overrides in standalone plugins and `cmd` configs must use `github.com/google/uuid`. |
|
| 2d | sqlc UUID overrides | sqlc UUID overrides in standalone plugins and `cmd` configs must use `github.com/google/uuid`. |
|
||||||
| 2e | `any` usage | Warns on `any` usage in Go and TypeScript (capped at 120 printed warnings). |
|
| 2e | `any` usage | Warns on `any` usage in Go and TypeScript (capped at 120 printed warnings). |
|
||||||
| 2f | Codegen freshness | `sqlc compile` and `buf generate` must succeed for every scanned root that defines them (generated code is up to date). |
|
| 2f | Codegen freshness | `sqlc compile` and `buf generate` must succeed for every scanned root that defines them (generated code is up to date). |
|
||||||
|
|||||||
@ -23,11 +23,14 @@ const coreSDKModulePath = "git.dev.alexdunmow.com/block/core"
|
|||||||
// "missing from MethodRoles", a not-applicable result rather than a finding.
|
// "missing from MethodRoles", a not-applicable result rather than a finding.
|
||||||
//
|
//
|
||||||
// - core: the shared proto SDK.
|
// - core: the shared proto SDK.
|
||||||
|
// - pluginsdk: the standalone-plugin SDK (2026-07-07 extraction). It
|
||||||
|
// carries proto definitions but serves nothing.
|
||||||
// - cli: the ninja developer CLI (WO-WZ-023). It vendors
|
// - cli: the ninja developer CLI (WO-WZ-023). It vendors
|
||||||
// orchestrator/v1/plugin_registry.proto to generate a CLIENT for the
|
// orchestrator/v1/plugin_registry.proto to generate a CLIENT for the
|
||||||
// registry; it has no server and no RBAC interceptor.
|
// registry; it has no server and no RBAC interceptor.
|
||||||
var definitionsOnlyModulePaths = map[string]bool{
|
var definitionsOnlyModulePaths = map[string]bool{
|
||||||
coreSDKModulePath: true,
|
coreSDKModulePath: true,
|
||||||
|
"git.dev.alexdunmow.com/block/pluginsdk": true,
|
||||||
"git.dev.alexdunmow.com/block/cli": true,
|
"git.dev.alexdunmow.com/block/cli": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
check_renderperf.go
Normal file
51
check_renderperf.go
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
register(Check{
|
||||||
|
Seq: 300,
|
||||||
|
ID: "30",
|
||||||
|
Title: "Render benchmark: home-page p50 within baseline (WO-RP-013)",
|
||||||
|
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||||
|
benchDir := filepath.Join(ctx.repoRoot, "e2e", "render-bench")
|
||||||
|
baselinePath := filepath.Join(benchDir, "baseline.json")
|
||||||
|
if _, err := os.Stat(baselinePath); err != nil {
|
||||||
|
rep.Skip("no e2e/render-bench/baseline.json in this repo — render-perf gate not applicable")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latestPath := filepath.Join(benchDir, "results", "latest.json")
|
||||||
|
if _, err := os.Stat(latestPath); err != nil {
|
||||||
|
rep.Skip("render bench not run (no results/latest.json) — run the `render-bench` suite from dojo to gate render performance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
baseline, err := loadRenderBench(baselinePath)
|
||||||
|
if err != nil {
|
||||||
|
rep.Warn("render-perf baseline unreadable: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
latest, err := loadRenderBench(latestPath)
|
||||||
|
if err != nil {
|
||||||
|
rep.Warn("render-perf latest.json unreadable: %v — re-run the render-bench suite from dojo", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
v := compareRenderPerf(baseline, latest, time.Now())
|
||||||
|
switch {
|
||||||
|
case v.skip:
|
||||||
|
rep.Skip("%s", v.message)
|
||||||
|
case v.fail:
|
||||||
|
rep.Fail("%s", v.message)
|
||||||
|
case v.warn:
|
||||||
|
rep.Warn("%s", v.message)
|
||||||
|
default:
|
||||||
|
rep.OK("%s", v.message)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
99
check_schemapurity.go
Normal file
99
check_schemapurity.go
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
register(Check{
|
||||||
|
Seq: 310,
|
||||||
|
ID: "32",
|
||||||
|
Title: "schema.sql contains only tables created by core migrations",
|
||||||
|
Run: func(ctx *ScanContext, rep *Reporter) {
|
||||||
|
schemaPath := filepath.Join(ctx.backendDir, "sql", "schema.sql")
|
||||||
|
migrationsDir := filepath.Join(ctx.backendDir, "sql", "migrations")
|
||||||
|
if !fileExists(schemaPath) || !dirExists(migrationsDir) {
|
||||||
|
rep.Skip("no backend/sql/schema.sql + migrations pair to check")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
schemaTables, err := schemaCreatedTables(schemaPath)
|
||||||
|
if err != nil {
|
||||||
|
rep.Fatal("reading schema.sql: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
migrationTables, err := migrationCreatedTables(migrationsDir)
|
||||||
|
if err != nil {
|
||||||
|
rep.Fatal("reading migrations: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var leaked []string
|
||||||
|
for table := range schemaTables {
|
||||||
|
if !migrationTables[table] {
|
||||||
|
leaked = append(leaked, table)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(leaked) > 0 {
|
||||||
|
sort.Strings(leaked)
|
||||||
|
rep.Fail("%d table(s) in schema.sql are not created by any core migration (plugin leakage; regenerate with `make schema`, which dumps a clean-room DB)", len(leaked))
|
||||||
|
for _, t := range leaked {
|
||||||
|
rep.Findingf("backend/sql/schema.sql — table %q has no CREATE TABLE in backend/sql/migrations", t)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
rep.OK("all %d schema.sql tables originate from core migrations", len(schemaTables))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
var schemaCreateTableRe = regexp.MustCompile(`(?im)^CREATE TABLE (?:IF NOT EXISTS )?(?:public\.)?([a-zA-Z0-9_]+)`)
|
||||||
|
|
||||||
|
// Migrations are hand-written: CREATE TABLE may be indented (inside DO blocks
|
||||||
|
// etc.), so no line anchor.
|
||||||
|
var migrationCreateTableRe = regexp.MustCompile(`(?i)CREATE TABLE (?:IF NOT EXISTS )?(?:public\.)?([a-zA-Z0-9_]+)`)
|
||||||
|
|
||||||
|
func schemaCreatedTables(path string) (map[string]bool, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tables := map[string]bool{}
|
||||||
|
for _, m := range schemaCreateTableRe.FindAllStringSubmatch(string(data), -1) {
|
||||||
|
tables[strings.ToLower(m[1])] = true
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrationCreatedTables collects every table name a migration creates or
|
||||||
|
// renames to, from both Up and Down sections (a superset is fine: the check
|
||||||
|
// only flags schema.sql tables NO migration could have produced).
|
||||||
|
var migrationRenameRe = regexp.MustCompile(`(?i)ALTER TABLE (?:IF EXISTS )?(?:ONLY )?(?:public\.)?[a-zA-Z0-9_]+ RENAME TO ([a-zA-Z0-9_]+)`)
|
||||||
|
|
||||||
|
func migrationCreatedTables(dir string) (map[string]bool, error) {
|
||||||
|
tables := map[string]bool{"goose_db_version": true}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, m := range migrationCreateTableRe.FindAllStringSubmatch(string(data), -1) {
|
||||||
|
tables[strings.ToLower(m[1])] = true
|
||||||
|
}
|
||||||
|
for _, m := range migrationRenameRe.FindAllStringSubmatch(string(data), -1) {
|
||||||
|
tables[strings.ToLower(m[1])] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tables, nil
|
||||||
|
}
|
||||||
@ -11,7 +11,9 @@ func init() {
|
|||||||
var pluginGoModViolations []pluginGoModViolation
|
var pluginGoModViolations []pluginGoModViolation
|
||||||
var standalonePluginLabels []string
|
var standalonePluginLabels []string
|
||||||
checkedStandalonePluginRoots := make(map[string]bool)
|
checkedStandalonePluginRoots := make(map[string]bool)
|
||||||
requiredSDKVersion, requiredSDKVersionErr := currentCMSCoreSDKVersion()
|
// Version anchor = the CMS backend's pluginsdk pin; empty (skip
|
||||||
|
// version enforcement) until the CMS itself migrates to pluginsdk.
|
||||||
|
requiredSDKVersion, requiredSDKVersionErr := currentCMSPluginSDKVersion()
|
||||||
if requiredSDKVersionErr != nil {
|
if requiredSDKVersionErr != nil {
|
||||||
rep.Fatal("failed to resolve CMS SDK version: %v", requiredSDKVersionErr)
|
rep.Fatal("failed to resolve CMS SDK version: %v", requiredSDKVersionErr)
|
||||||
}
|
}
|
||||||
@ -58,7 +60,7 @@ func init() {
|
|||||||
if len(pluginImportViolations) > 0 {
|
if len(pluginImportViolations) > 0 {
|
||||||
rep.Fail("%d standalone plugin import violation(s)", len(pluginImportViolations))
|
rep.Fail("%d standalone plugin import violation(s)", len(pluginImportViolations))
|
||||||
for _, v := range pluginImportViolations {
|
for _, v := range pluginImportViolations {
|
||||||
rep.Findingf("%s:%d imports BlockNinja CMS package %q", v.file, v.line, v.importPath)
|
rep.Findingf("%s:%d imports first-party BlockNinja package %q (plugins may only use block/pluginsdk)", v.file, v.line, v.importPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(pluginGoModViolations) > 0 {
|
if len(pluginGoModViolations) > 0 {
|
||||||
@ -72,7 +74,11 @@ func init() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if len(standalonePluginLabels) > 0 {
|
} else if len(standalonePluginLabels) > 0 {
|
||||||
|
if requiredSDKVersion != "" {
|
||||||
rep.OK("Standalone plugin imports and go.mod stay on SDK version %s", requiredSDKVersion)
|
rep.OK("Standalone plugin imports and go.mod stay on SDK version %s", requiredSDKVersion)
|
||||||
|
} else {
|
||||||
|
rep.OK("Standalone plugin imports and go.mod build against the plugin SDK")
|
||||||
|
}
|
||||||
} else if ctx.includeCoreTargets {
|
} else if ctx.includeCoreTargets {
|
||||||
rep.OK("%s/go.mod does not locally replace %s", ctx.backendTargets[0].displayOrRoot(), blockCoreImportPrefix)
|
rep.OK("%s/go.mod does not locally replace %s", ctx.backendTargets[0].displayOrRoot(), blockCoreImportPrefix)
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@ -137,6 +137,7 @@ var colorAllowedFiles = map[string]bool{
|
|||||||
"routes/admin/system.tsx": true, // Theme preset picker — displays theme color swatches using actual HSL values
|
"routes/admin/system.tsx": true, // Theme preset picker — displays theme color swatches using actual HSL values
|
||||||
"components/settings/theme-card.tsx": true, // Orchestrator theme gallery — preview swatches need real HSL ramps
|
"components/settings/theme-card.tsx": true, // Orchestrator theme gallery — preview swatches need real HSL ramps
|
||||||
"routes/dashboard/sites/new/step-design.tsx": true, // Template chooser artwork uses explicit preview ramps
|
"routes/dashboard/sites/new/step-design.tsx": true, // Template chooser artwork uses explicit preview ramps
|
||||||
|
"components/icons/palette-multicolor.tsx": true, // Multicolor palette icon - fixed paint-dab fills are the point
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tailwind color names that should use semantic tokens instead
|
// Tailwind color names that should use semantic tokens instead
|
||||||
|
|||||||
@ -24,6 +24,7 @@ var comingSoonAllowedFiles = map[string]bool{
|
|||||||
"components/settings/site-status-section.tsx": true, // Legitimate Site Status feature UI
|
"components/settings/site-status-section.tsx": true, // Legitimate Site Status feature UI
|
||||||
"lib/nav-items.ts": true, // Admin navigation metadata
|
"lib/nav-items.ts": true, // Admin navigation metadata
|
||||||
"routes/admin/pages.tsx": true, // System page type enum wiring
|
"routes/admin/pages.tsx": true, // System page type enum wiring
|
||||||
|
"routes/admin/pages.system.tsx": true, // System page type enum wiring
|
||||||
"lib/api/gen/blockninja/v1/pages_pb.d.ts": true, // Generated proto enum SYSTEM_PAGE_TYPE_COMING_SOON
|
"lib/api/gen/blockninja/v1/pages_pb.d.ts": true, // Generated proto enum SYSTEM_PAGE_TYPE_COMING_SOON
|
||||||
"lib/api/gen/blockninja/v1/settings_pb.d.ts": true, // Generated proto enum SITE_MODE_COMING_SOON
|
"lib/api/gen/blockninja/v1/settings_pb.d.ts": true, // Generated proto enum SITE_MODE_COMING_SOON
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,13 +39,11 @@ var allowedFrontendFiles = map[string]bool{
|
|||||||
// command palette); generated per-RPC hooks can't express this without 10
|
// command palette); generated per-RPC hooks can't express this without 10
|
||||||
// independent hook instances and manual aggregation.
|
// independent hook instances and manual aggregation.
|
||||||
"hooks/use-entity-search.ts": true,
|
"hooks/use-entity-search.ts": true,
|
||||||
"routes/admin/plugins.tsx": true, // Server-streaming (for await...of) — useMutation doesn't support streaming
|
|
||||||
"hooks/use-restart-operation.ts": true, // Server-streaming + raw Connect envelope for cross-origin orchestrator stream
|
|
||||||
"routes/admin/menus.tsx": true, // Imperative async callback in useCallback
|
"routes/admin/menus.tsx": true, // Imperative async callback in useCallback
|
||||||
"components/data-platform/tables/creation-wizard.tsx": true, // useMutation has serialization bug with oneof fields
|
"components/data-platform/tables/creation-wizard.tsx": true, // useMutation has serialization bug with oneof fields
|
||||||
// SiteAgentService.SendMessage is a Connect server-streaming RPC consumed
|
// SiteAgentService.SendMessage is a Connect server-streaming RPC consumed
|
||||||
// with `for await` — generated Connect Query hooks are unary-only and
|
// with `for await` — generated Connect Query hooks are unary-only and
|
||||||
// cannot express a streaming turn (same reason as use-restart-operation).
|
// cannot express a streaming turn.
|
||||||
"components/site-agent/use-site-agent-stream.ts": true,
|
"components/site-agent/use-site-agent-stream.ts": true,
|
||||||
|
|
||||||
// Registry browse/detail call the ORCHESTRATOR's public Connect endpoints
|
// Registry browse/detail call the ORCHESTRATOR's public Connect endpoints
|
||||||
@ -65,6 +63,8 @@ var allowedPluginRESTFiles = map[string]bool{
|
|||||||
// bundled path (backend/internal/plugins/calcomblock/web/...).
|
// bundled path (backend/internal/plugins/calcomblock/web/...).
|
||||||
"plugins/calcomblock/web/settings.tsx": true, // Plugin settings/test endpoints are mounted via HTTPHandler; no generated hooks exist yet
|
"plugins/calcomblock/web/settings.tsx": true, // Plugin settings/test endpoints are mounted via HTTPHandler; no generated hooks exist yet
|
||||||
"plugins/calcomblock/web/editor.tsx": true, // Block editor reads plugin /settings + /event-types via HTTPHandler routes
|
"plugins/calcomblock/web/editor.tsx": true, // Block editor reads plugin /settings + /event-types via HTTPHandler routes
|
||||||
|
"plugins/judgefestblock/web/settings.tsx": true, // Same HTTPHandler REST shape as calcomblock (settings/test endpoints)
|
||||||
|
"plugins/judgefestblock/web/editor.tsx": true, // Block editor reads plugin /events-picker via HTTPHandler routes
|
||||||
}
|
}
|
||||||
|
|
||||||
// Specific fetch paths that are non-proto REST endpoints (no ConnectRPC equivalent)
|
// Specific fetch paths that are non-proto REST endpoints (no ConnectRPC equivalent)
|
||||||
@ -78,6 +78,7 @@ var knownNonProtoFetches = map[string]bool{
|
|||||||
"/api/ai/chat/stream": true, // AI chat SSE streaming (EventSource/fetch — ConnectRPC doesn't support SSE)
|
"/api/ai/chat/stream": true, // AI chat SSE streaming (EventSource/fetch — ConnectRPC doesn't support SSE)
|
||||||
"/api/support/": true, // CMS helpdesk widget: multipart attachment upload to the Chi proxy — connect-query can't send FormData
|
"/api/support/": true, // CMS helpdesk widget: multipart attachment upload to the Chi proxy — connect-query can't send FormData
|
||||||
"/api/helpdesk/upload": true, // Orchestrator helpdesk: multipart attachment upload — same FormData limitation
|
"/api/helpdesk/upload": true, // Orchestrator helpdesk: multipart attachment upload — same FormData limitation
|
||||||
|
"/api/push/upload": true, // Orchestrator push/restore: multipart backup-zip upload — same FormData limitation
|
||||||
"/.well-known/skills/": true, // Public well-known skill discovery index (instance-derived skill name) — no proto service
|
"/.well-known/skills/": true, // Public well-known skill discovery index (instance-derived skill name) — no proto service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -12,6 +12,7 @@ type FontFile struct {
|
|||||||
Style string
|
Style string
|
||||||
URL string
|
URL string
|
||||||
Format string // "woff2", "woff", "truetype"
|
Format string // "woff2", "woff", "truetype"
|
||||||
|
UnicodeRange string // optional; preserves Google's subset splitting for self-hosted fonts
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateCSS generates CSS variable declarations from theme settings
|
// GenerateCSS generates CSS variable declarations from theme settings
|
||||||
@ -31,8 +32,11 @@ func GenerateCSS(theme *Theme, googleFontURLs []string, fontFaces []FontFile) st
|
|||||||
font-weight: %s;
|
font-weight: %s;
|
||||||
font-style: %s;
|
font-style: %s;
|
||||||
font-display: swap;
|
font-display: swap;
|
||||||
}
|
|
||||||
`, ff.Family, ff.URL, ff.Format, ff.Weight, ff.Style)
|
`, ff.Family, ff.URL, ff.Format, ff.Weight, ff.Style)
|
||||||
|
if ff.UnicodeRange != "" {
|
||||||
|
fmt.Fprintf(&css, " unicode-range: %s;\n", ff.UnicodeRange)
|
||||||
|
}
|
||||||
|
css.WriteString("}\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(googleFontURLs) > 0 || len(fontFaces) > 0 {
|
if len(googleFontURLs) > 0 || len(fontFaces) > 0 {
|
||||||
@ -63,6 +67,8 @@ func GenerateCSS(theme *Theme, googleFontURLs []string, fontFaces []FontFile) st
|
|||||||
// Button class definitions
|
// Button class definitions
|
||||||
WriteButtonClasses(&css, theme.Buttons)
|
WriteButtonClasses(&css, theme.Buttons)
|
||||||
|
|
||||||
|
writeFontSizeOverrideRules(&css, theme.Typography)
|
||||||
|
|
||||||
return css.String()
|
return css.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -124,15 +130,67 @@ func writeTypographyVars(css *strings.Builder, typography *ThemeTypography) {
|
|||||||
writeVar(css, "font-sans", bodyStack)
|
writeVar(css, "font-sans", bodyStack)
|
||||||
writeVar(css, "font-mono", monoStack)
|
writeVar(css, "font-mono", monoStack)
|
||||||
|
|
||||||
// Font size base as rem
|
if v := NormalizeFontSizeBase(typography.FontSizeBase); v != "" {
|
||||||
if typography.FontSizeBase != "" {
|
writeVar(css, "font-size-base", v)
|
||||||
writeVar(css, "font-size-base", typography.FontSizeBase+"px")
|
|
||||||
}
|
}
|
||||||
if typography.LineHeightBase != "" {
|
if typography.LineHeightBase != "" {
|
||||||
writeVar(css, "line-height-base", typography.LineHeightBase)
|
writeVar(css, "line-height-base", typography.LineHeightBase)
|
||||||
}
|
}
|
||||||
// Font weight base
|
|
||||||
writeVar(css, "font-weight-base", getFontWeight(typography.FontWeightBase))
|
writeVar(css, "font-weight-base", getFontWeight(typography.FontWeightBase))
|
||||||
|
for _, k := range FontSizeOverrideKeys() {
|
||||||
|
v := typography.FontSizeOverrides[k]
|
||||||
|
if v == "" || ValidateFontSize(v) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
writeVar(css, "fs-"+k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var fontSizeOverrideSelectors = map[string]string{
|
||||||
|
"h1": "h1", "h2": "h2", "h3": "h3", "h4": "h4", "h5": "h5", "h6": "h6",
|
||||||
|
"nav-link": ".bn-nav-link",
|
||||||
|
"hero-title": ".bn-hero-title",
|
||||||
|
"hero-subtitle": ".bn-hero-subtitle",
|
||||||
|
"index-card-title": ".bn-post-card-title",
|
||||||
|
"post-title": ".bn-post-title",
|
||||||
|
"post-lede": ".bn-post-lede",
|
||||||
|
}
|
||||||
|
|
||||||
|
var headingOverrideKeys = map[string]bool{"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true}
|
||||||
|
|
||||||
|
// writeFontSizeOverrideRules emits element rules for overrides that cannot be
|
||||||
|
// consumed via var() fallbacks. Headings go in @layer base so Tailwind
|
||||||
|
// utilities on blocks still win; chrome rules stay unlayered so they beat the
|
||||||
|
// utility classes baked into builtin templates. Emitted last so they win
|
||||||
|
// same-specificity cascade inside this sheet.
|
||||||
|
func writeFontSizeOverrideRules(css *strings.Builder, typography *ThemeTypography) {
|
||||||
|
if typography == nil || len(typography.FontSizeOverrides) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var headings, chrome []string
|
||||||
|
for _, k := range FontSizeOverrideKeys() {
|
||||||
|
v := typography.FontSizeOverrides[k]
|
||||||
|
sel, ok := fontSizeOverrideSelectors[k]
|
||||||
|
if !ok || v == "" || ValidateFontSize(v) != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rule := fmt.Sprintf("%s { font-size: var(--fs-%s); }", sel, k)
|
||||||
|
if headingOverrideKeys[k] {
|
||||||
|
headings = append(headings, " "+rule)
|
||||||
|
} else {
|
||||||
|
chrome = append(chrome, rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(headings) > 0 {
|
||||||
|
css.WriteString("\n@layer base {\n")
|
||||||
|
css.WriteString(strings.Join(headings, "\n"))
|
||||||
|
css.WriteString("\n}\n")
|
||||||
|
}
|
||||||
|
if len(chrome) > 0 {
|
||||||
|
css.WriteString("\n")
|
||||||
|
css.WriteString(strings.Join(chrome, "\n"))
|
||||||
|
css.WriteString("\n")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func writeSpacingVars(css *strings.Builder, spacing *ThemeSpacing) {
|
func writeSpacingVars(css *strings.Builder, spacing *ThemeSpacing) {
|
||||||
@ -361,7 +419,7 @@ func WriteButtonClasses(css *strings.Builder, buttons *ThemeButtons) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
font-size: 1rem;
|
font-size: var(--fs-button, 1rem);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
border-radius: var(--button-radius, 0.375rem);
|
border-radius: var(--button-radius, 0.375rem);
|
||||||
transition: all 150ms ease;
|
transition: all 150ms ease;
|
||||||
@ -370,9 +428,9 @@ func WriteButtonClasses(css *strings.Builder, buttons *ThemeButtons) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Button sizes */
|
/* Button sizes */
|
||||||
.btn-sm { padding: 0.375rem 0.75rem; font-size: 0.875rem; }
|
.btn-sm { padding: 0.375rem 0.75rem; font-size: calc(var(--fs-button, 1rem) * 0.875); }
|
||||||
.btn-md { padding: 0.5rem 1rem; font-size: 1rem; }
|
.btn-md { padding: 0.5rem 1rem; font-size: var(--fs-button, 1rem); }
|
||||||
.btn-lg { padding: 0.75rem 1.5rem; font-size: 1.125rem; }
|
.btn-lg { padding: 0.75rem 1.5rem; font-size: calc(var(--fs-button, 1rem) * 1.125); }
|
||||||
|
|
||||||
/* Theme-aware button types */
|
/* Theme-aware button types */
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
|
|||||||
@ -49,7 +49,7 @@ func DefaultTheme() *Theme {
|
|||||||
FontHeading: "system",
|
FontHeading: "system",
|
||||||
FontBody: "system",
|
FontBody: "system",
|
||||||
FontMono: "system",
|
FontMono: "system",
|
||||||
FontSizeBase: "16",
|
FontSizeBase: "1rem",
|
||||||
LineHeightBase: "1.5",
|
LineHeightBase: "1.5",
|
||||||
FontWeightBase: "normal",
|
FontWeightBase: "normal",
|
||||||
},
|
},
|
||||||
@ -235,6 +235,7 @@ type ThemeTypography struct {
|
|||||||
FontSizeBase string `json:"fontSizeBase"`
|
FontSizeBase string `json:"fontSizeBase"`
|
||||||
LineHeightBase string `json:"lineHeightBase"`
|
LineHeightBase string `json:"lineHeightBase"`
|
||||||
FontWeightBase string `json:"fontWeightBase"`
|
FontWeightBase string `json:"fontWeightBase"`
|
||||||
|
FontSizeOverrides map[string]string `json:"fontSizeOverrides,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ThemeSpacing represents spacing settings
|
// ThemeSpacing represents spacing settings
|
||||||
|
|||||||
104
internal/theme/fontsize.go
Normal file
104
internal/theme/fontsize.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package theme
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fontSizeOverrideKeys = map[string]bool{
|
||||||
|
"h1": true, "h2": true, "h3": true, "h4": true, "h5": true, "h6": true,
|
||||||
|
"post-title": true, "post-lede": true, "post-body": true,
|
||||||
|
"post-h2": true, "post-h3": true, "post-meta": true,
|
||||||
|
"page-title": true, "page-lede": true, "index-card-title": true,
|
||||||
|
"hero-title": true, "hero-subtitle": true,
|
||||||
|
"button": true, "nav-link": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var fontSizePattern = regexp.MustCompile(`^(\d+(?:\.\d+)?|\.\d+)(rem|px)$`)
|
||||||
|
|
||||||
|
// FontSizeOverrideKeys returns the canonical override keys, sorted.
|
||||||
|
func FontSizeOverrideKeys() []string {
|
||||||
|
keys := make([]string, 0, len(fontSizeOverrideKeys))
|
||||||
|
for k := range fontSizeOverrideKeys {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidateFontSize accepts unit-suffixed CSS lengths within sane bounds.
|
||||||
|
// Values are emitted into a stylesheet, so this is a security gate.
|
||||||
|
func ValidateFontSize(v string) error {
|
||||||
|
m := fontSizePattern.FindStringSubmatch(v)
|
||||||
|
if m == nil {
|
||||||
|
return fmt.Errorf("font size %q must be a number with rem or px unit", v)
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseFloat(m[1], 64)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("font size %q is not a number", v)
|
||||||
|
}
|
||||||
|
switch m[2] {
|
||||||
|
case "rem":
|
||||||
|
if n < 0.25 || n > 10 {
|
||||||
|
return fmt.Errorf("font size %q out of range (0.25rem to 10rem)", v)
|
||||||
|
}
|
||||||
|
case "px":
|
||||||
|
if n < 4 || n > 160 {
|
||||||
|
return fmt.Errorf("font size %q out of range (4px to 160px)", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseFontSizeBase normalizes a stored base font size and validates
|
||||||
|
// unit-suffixed values.
|
||||||
|
func ParseFontSizeBase(v string) (string, error) {
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if v == "" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
// Legacy bare numbers were never consumed by any stylesheet, so they are
|
||||||
|
// normalized to the 1rem default rather than resurrected as a real size.
|
||||||
|
if _, err := strconv.ParseFloat(v, 64); err == nil {
|
||||||
|
v = "1rem"
|
||||||
|
}
|
||||||
|
if err := ValidateFontSize(v); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeFontSizeBase returns "" for anything invalid so bad stored data
|
||||||
|
// degrades to the CSS fallback instead of breaking the sheet.
|
||||||
|
func NormalizeFontSizeBase(v string) string {
|
||||||
|
out, _ := ParseFontSizeBase(v)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// SanitizeFontSizeOverrides drops empty values, rejects unknown keys and
|
||||||
|
// invalid values, and returns a fresh map safe to persist and emit.
|
||||||
|
func SanitizeFontSizeOverrides(m map[string]string) (map[string]string, error) {
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
out := make(map[string]string, len(m))
|
||||||
|
for k, v := range m {
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !fontSizeOverrideKeys[k] {
|
||||||
|
return nil, fmt.Errorf("unknown font size element %q", k)
|
||||||
|
}
|
||||||
|
if err := ValidateFontSize(v); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out[k] = v
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
59
lint_test.go
59
lint_test.go
@ -11,12 +11,9 @@ import (
|
|||||||
|
|
||||||
func TestCheckSafetySkipsRPCCheckForPluginWithoutRPCs(t *testing.T) {
|
func TestCheckSafetySkipsRPCCheckForPluginWithoutRPCs(t *testing.T) {
|
||||||
pluginRoot := t.TempDir()
|
pluginRoot := t.TempDir()
|
||||||
sdkVersion, err := currentCMSCoreSDKVersion()
|
sdkVersion := scaffoldPluginSDKVersion(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
|
|
||||||
}
|
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/pluginsdk "+sdkVersion+"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
@ -43,10 +40,7 @@ func TestCheckSafetySkipsRPCCheckForPluginWithoutRPCs(t *testing.T) {
|
|||||||
func TestCheckSafetyTypechecksExternalPluginFrontendWithWorkspaceDeps(t *testing.T) {
|
func TestCheckSafetyTypechecksExternalPluginFrontendWithWorkspaceDeps(t *testing.T) {
|
||||||
pluginRoot := t.TempDir()
|
pluginRoot := t.TempDir()
|
||||||
pluginWebDir := filepath.Join(pluginRoot, "web")
|
pluginWebDir := filepath.Join(pluginRoot, "web")
|
||||||
sdkVersion, err := currentCMSCoreSDKVersion()
|
sdkVersion := scaffoldPluginSDKVersion(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
|
|
||||||
}
|
|
||||||
realRepoRoot := blockNinjaRepoRoot()
|
realRepoRoot := blockNinjaRepoRoot()
|
||||||
relESLintConfig, err := filepath.Rel(pluginWebDir, filepath.Join(realRepoRoot, "web", "eslint.config.js"))
|
relESLintConfig, err := filepath.Rel(pluginWebDir, filepath.Join(realRepoRoot, "web", "eslint.config.js"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -54,7 +48,7 @@ func TestCheckSafetyTypechecksExternalPluginFrontendWithWorkspaceDeps(t *testing
|
|||||||
}
|
}
|
||||||
|
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/pluginsdk "+sdkVersion+"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginWebDir, "package.json"), "{\n \"name\": \"external-plugin-test\",\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": {\n \"@block-ninja/ui\": \"workspace:*\"\n }\n}\n", 0644)
|
writeTestFile(t, filepath.Join(pluginWebDir, "package.json"), "{\n \"name\": \"external-plugin-test\",\n \"private\": true,\n \"type\": \"module\",\n \"dependencies\": {\n \"@block-ninja/ui\": \"workspace:*\"\n }\n}\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginWebDir, "tsconfig.json"), "{\n \"compilerOptions\": {\n \"target\": \"ES2020\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n \"jsx\": \"react-jsx\",\n \"strict\": true,\n \"skipLibCheck\": true,\n \"noEmit\": true\n },\n \"include\": [\"*.tsx\", \"*.ts\"]\n}\n", 0644)
|
writeTestFile(t, filepath.Join(pluginWebDir, "tsconfig.json"), "{\n \"compilerOptions\": {\n \"target\": \"ES2020\",\n \"module\": \"ESNext\",\n \"moduleResolution\": \"bundler\",\n \"lib\": [\"ES2020\", \"DOM\", \"DOM.Iterable\"],\n \"jsx\": \"react-jsx\",\n \"strict\": true,\n \"skipLibCheck\": true,\n \"noEmit\": true\n },\n \"include\": [\"*.tsx\", \"*.ts\"]\n}\n", 0644)
|
||||||
@ -110,19 +104,16 @@ func Example() string {
|
|||||||
if !strings.Contains(got, "FAIL 2c ") {
|
if !strings.Contains(got, "FAIL 2c ") {
|
||||||
t.Fatalf("output missing Check 2c fail line:\n%s", got)
|
t.Fatalf("output missing Check 2c fail line:\n%s", got)
|
||||||
}
|
}
|
||||||
if !strings.Contains(got, "imports BlockNinja CMS package") {
|
if !strings.Contains(got, "imports first-party BlockNinja package") {
|
||||||
t.Fatalf("output missing forbidden BlockNinja import message:\n%s", got)
|
t.Fatalf("output missing forbidden BlockNinja import message:\n%s", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckSafetyFailsStandalonePluginReplaceDirective(t *testing.T) {
|
func TestCheckSafetyFailsStandalonePluginReplaceDirective(t *testing.T) {
|
||||||
pluginRoot := t.TempDir()
|
pluginRoot := t.TempDir()
|
||||||
sdkVersion, err := currentCMSCoreSDKVersion()
|
sdkVersion := scaffoldPluginSDKVersion(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
|
|
||||||
}
|
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n\nreplace git.dev.alexdunmow.com/block/core => ../block-core\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/pluginsdk "+sdkVersion+"\n\nreplace git.dev.alexdunmow.com/block/pluginsdk => ../block-pluginsdk\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
||||||
|
|
||||||
cwd, err := os.Getwd()
|
cwd, err := os.Getwd()
|
||||||
@ -145,12 +136,9 @@ func TestCheckSafetyFailsStandalonePluginReplaceDirective(t *testing.T) {
|
|||||||
|
|
||||||
func TestCheckSafetyFailsStandalonePluginSQLCUUIDStringOverrides(t *testing.T) {
|
func TestCheckSafetyFailsStandalonePluginSQLCUUIDStringOverrides(t *testing.T) {
|
||||||
pluginRoot := t.TempDir()
|
pluginRoot := t.TempDir()
|
||||||
sdkVersion, err := currentCMSCoreSDKVersion()
|
sdkVersion := scaffoldPluginSDKVersion(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
|
|
||||||
}
|
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/pluginsdk "+sdkVersion+"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "sqlc.yaml"), `version: "2"
|
writeTestFile(t, filepath.Join(pluginRoot, "sqlc.yaml"), `version: "2"
|
||||||
sql:
|
sql:
|
||||||
@ -193,12 +181,9 @@ sql:
|
|||||||
|
|
||||||
func TestCheckSafetyWarnsOnStandalonePluginGoAnyUsage(t *testing.T) {
|
func TestCheckSafetyWarnsOnStandalonePluginGoAnyUsage(t *testing.T) {
|
||||||
pluginRoot := t.TempDir()
|
pluginRoot := t.TempDir()
|
||||||
sdkVersion, err := currentCMSCoreSDKVersion()
|
sdkVersion := scaffoldPluginSDKVersion(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
|
|
||||||
}
|
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/pluginsdk "+sdkVersion+"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), `package example
|
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), `package example
|
||||||
|
|
||||||
type Payload struct {
|
type Payload struct {
|
||||||
@ -231,12 +216,9 @@ type Payload struct {
|
|||||||
|
|
||||||
func TestCheckSafetyFailsStandalonePluginSQLCCompile(t *testing.T) {
|
func TestCheckSafetyFailsStandalonePluginSQLCCompile(t *testing.T) {
|
||||||
pluginRoot := t.TempDir()
|
pluginRoot := t.TempDir()
|
||||||
sdkVersion, err := currentCMSCoreSDKVersion()
|
sdkVersion := scaffoldPluginSDKVersion(t)
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("currentCMSCoreSDKVersion: %v", err)
|
|
||||||
}
|
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/core "+sdkVersion+"\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "go.mod"), "module example.com/plugin\n\ngo 1.26.2\n\nrequire git.dev.alexdunmow.com/block/pluginsdk "+sdkVersion+"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
writeTestFile(t, filepath.Join(pluginRoot, "main.go"), "package example\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(pluginRoot, "sqlc.yaml"), `version: "2"
|
writeTestFile(t, filepath.Join(pluginRoot, "sqlc.yaml"), `version: "2"
|
||||||
sql:
|
sql:
|
||||||
@ -695,6 +677,21 @@ func writeExecutableFile(t *testing.T, path string, content string) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// scaffoldPluginSDKVersion resolves the pluginsdk pin the same way check 2c's
|
||||||
|
// version anchor does (currentCMSPluginSDKVersion), so scaffolded go.mod fixtures
|
||||||
|
// track the CMS backend's pin instead of drifting against a hardcoded number.
|
||||||
|
func scaffoldPluginSDKVersion(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
v, err := currentCMSPluginSDKVersion()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("currentCMSPluginSDKVersion: %v", err)
|
||||||
|
}
|
||||||
|
if v == "" {
|
||||||
|
return "v0.0.0"
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
func writeTestFile(t *testing.T, path string, content string, mode os.FileMode) {
|
func writeTestFile(t *testing.T, path string, content string, mode os.FileMode) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||||
|
|||||||
20
main_test.go
20
main_test.go
@ -20,6 +20,26 @@ func TestDefaultScanTargetDirUsesCWDPlugin(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestDefaultScanTargetDirUsesPluginRootWhenOnlyPluginDirGiven(t *testing.T) {
|
||||||
|
cwd := t.TempDir() // no plugin.mod — simulates running from the check-safety repo itself
|
||||||
|
pluginRoot := t.TempDir()
|
||||||
|
|
||||||
|
got := defaultScanTargetDir(".", []string{pluginRoot}, cwd)
|
||||||
|
if got != pluginRoot {
|
||||||
|
t.Fatalf("defaultScanTargetDir() = %q, want plugin root %q", got, pluginRoot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultScanTargetDirKeepsExplicitTargetOverPluginRoots(t *testing.T) {
|
||||||
|
cwd := t.TempDir()
|
||||||
|
pluginRoot := t.TempDir()
|
||||||
|
|
||||||
|
got := defaultScanTargetDir("/some/backend", []string{pluginRoot}, cwd)
|
||||||
|
if got != "/some/backend" {
|
||||||
|
t.Fatalf("defaultScanTargetDir() = %q, want explicit target /some/backend", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultScanTargetDirFallsBackToCoreBackend(t *testing.T) {
|
func TestDefaultScanTargetDirFallsBackToCoreBackend(t *testing.T) {
|
||||||
cwd := t.TempDir()
|
cwd := t.TempDir()
|
||||||
|
|
||||||
|
|||||||
@ -12,9 +12,27 @@ import (
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
blockCoreImportPrefix = "git.dev.alexdunmow.com/block/core"
|
blockCoreImportPrefix = "git.dev.alexdunmow.com/block/core"
|
||||||
|
pluginSDKImportPrefix = "git.dev.alexdunmow.com/block/pluginsdk"
|
||||||
blockNinjaImportPrefix = "git.dev.alexdunmow.com/block/cms"
|
blockNinjaImportPrefix = "git.dev.alexdunmow.com/block/cms"
|
||||||
|
orchestratorImportPrefix = "git.dev.alexdunmow.com/block/orchestrator"
|
||||||
|
firstPartyImportPrefix = "git.dev.alexdunmow.com/block/"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func hasImportPrefix(importPath, prefix string) bool {
|
||||||
|
return importPath == prefix || strings.HasPrefix(importPath, prefix+"/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// isForbiddenPluginImport reports whether a standalone plugin may not import
|
||||||
|
// the package: ALL BlockNinja first-party Go code (cms, orchestrator, core) is
|
||||||
|
// off-limits — block/pluginsdk is the one plugin-facing module. (The former
|
||||||
|
// core/captcha carve-out ended when captcha moved to the host-stamped
|
||||||
|
// X-Bn-Verified-Captcha trusted header, pluginsdk v0.2.2.)
|
||||||
|
func isForbiddenPluginImport(importPath string) bool {
|
||||||
|
return hasImportPrefix(importPath, blockNinjaImportPrefix) ||
|
||||||
|
hasImportPrefix(importPath, orchestratorImportPrefix) ||
|
||||||
|
hasImportPrefix(importPath, blockCoreImportPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
type pluginImportViolation struct {
|
type pluginImportViolation struct {
|
||||||
file string
|
file string
|
||||||
line int
|
line int
|
||||||
@ -73,13 +91,13 @@ func checkStandalonePluginImports(root string) []pluginImportViolation {
|
|||||||
}
|
}
|
||||||
lines := strings.Split(string(data), "\n")
|
lines := strings.Split(string(data), "\n")
|
||||||
for i, line := range lines {
|
for i, line := range lines {
|
||||||
if !strings.Contains(line, blockNinjaImportPrefix) {
|
if !strings.Contains(line, firstPartyImportPrefix) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
matches := templImportPattern.FindAllStringSubmatch(line, -1)
|
matches := templImportPattern.FindAllStringSubmatch(line, -1)
|
||||||
for _, match := range matches {
|
for _, match := range matches {
|
||||||
importPath := match[1]
|
importPath := match[1]
|
||||||
if !strings.HasPrefix(importPath, blockNinjaImportPrefix) {
|
if !isForbiddenPluginImport(importPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
violations = append(violations, pluginImportViolation{
|
violations = append(violations, pluginImportViolation{
|
||||||
@ -107,7 +125,7 @@ func checkStandalonePluginImports(root string) []pluginImportViolation {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(importPath, blockNinjaImportPrefix) {
|
if !isForbiddenPluginImport(importPath) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -11,7 +11,7 @@ func TestCheckStandalonePluginImportsFlagsBlockNinjaCMSImports(t *testing.T) {
|
|||||||
writeTestFile(t, filepath.Join(root, "main.go"), `package example
|
writeTestFile(t, filepath.Join(root, "main.go"), `package example
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"git.dev.alexdunmow.com/block/core/plugin"
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
"git.dev.alexdunmow.com/block/cms/internal/helpers"
|
"git.dev.alexdunmow.com/block/cms/internal/helpers"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ func Example() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckStandalonePluginImportsAllowsCoreSDKImports(t *testing.T) {
|
func TestCheckStandalonePluginImportsFlagsCoreImports(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
writeTestFile(t, filepath.Join(root, "main.go"), `package example
|
writeTestFile(t, filepath.Join(root, "main.go"), `package example
|
||||||
@ -40,6 +40,33 @@ import "git.dev.alexdunmow.com/block/core/plugin"
|
|||||||
func Example() {
|
func Example() {
|
||||||
_ = plugin.PluginRegistration{}
|
_ = plugin.PluginRegistration{}
|
||||||
}
|
}
|
||||||
|
`, 0644)
|
||||||
|
|
||||||
|
violations := checkStandalonePluginImports(root)
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 1: %#v", len(violations), violations)
|
||||||
|
}
|
||||||
|
if violations[0].importPath != "git.dev.alexdunmow.com/block/core/plugin" {
|
||||||
|
t.Fatalf("importPath = %q, want forbidden core import", violations[0].importPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckStandalonePluginImportsAllowsSDKImports(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
|
writeTestFile(t, filepath.Join(root, "main.go"), `package example
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.dev.alexdunmow.com/block/pluginsdk/auth"
|
||||||
|
"git.dev.alexdunmow.com/block/pluginsdk/plugin"
|
||||||
|
"git.dev.alexdunmow.com/block/pluginsdk/render"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example() {
|
||||||
|
_ = plugin.PluginRegistration{}
|
||||||
|
_ = auth.CaptchaVerified
|
||||||
|
_ = render.BlockNoteToHTML
|
||||||
|
}
|
||||||
`, 0644)
|
`, 0644)
|
||||||
|
|
||||||
violations := checkStandalonePluginImports(root)
|
violations := checkStandalonePluginImports(root)
|
||||||
@ -48,6 +75,51 @@ func Example() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Captcha moved to the host-stamped X-Bn-Verified-Captcha trusted header
|
||||||
|
// (pluginsdk/auth), so the former core/captcha carve-out is gone: NO block/core
|
||||||
|
// package is plugin-importable anymore.
|
||||||
|
func TestCheckStandalonePluginImportsFlagsFormerlyKeptCoreImports(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
|
writeTestFile(t, filepath.Join(root, "main.go"), `package example
|
||||||
|
|
||||||
|
import "git.dev.alexdunmow.com/block/core/captcha"
|
||||||
|
|
||||||
|
func Example() {
|
||||||
|
_ = captcha.New
|
||||||
|
}
|
||||||
|
`, 0644)
|
||||||
|
|
||||||
|
violations := checkStandalonePluginImports(root)
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 1: %#v", len(violations), violations)
|
||||||
|
}
|
||||||
|
if violations[0].importPath != "git.dev.alexdunmow.com/block/core/captcha" {
|
||||||
|
t.Fatalf("importPath = %q, want forbidden core/captcha import", violations[0].importPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckStandalonePluginImportsFlagsOrchestratorImports(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
|
writeTestFile(t, filepath.Join(root, "main.go"), `package example
|
||||||
|
|
||||||
|
import "git.dev.alexdunmow.com/block/orchestrator/backend/internal/services"
|
||||||
|
|
||||||
|
func Example() {
|
||||||
|
_ = services.InstanceService{}
|
||||||
|
}
|
||||||
|
`, 0644)
|
||||||
|
|
||||||
|
violations := checkStandalonePluginImports(root)
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 1: %#v", len(violations), violations)
|
||||||
|
}
|
||||||
|
if violations[0].importPath != "git.dev.alexdunmow.com/block/orchestrator/backend/internal/services" {
|
||||||
|
t.Fatalf("importPath = %q, want forbidden orchestrator import", violations[0].importPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckStandalonePluginImportsFlagsBlockNinjaCMSImportsInTemplFiles(t *testing.T) {
|
func TestCheckStandalonePluginImportsFlagsBlockNinjaCMSImportsInTemplFiles(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"example\"\nversion = \"1.0.0\"\n", 0644)
|
||||||
@ -64,13 +136,16 @@ templ Page() {
|
|||||||
`, 0644)
|
`, 0644)
|
||||||
|
|
||||||
violations := checkStandalonePluginImports(root)
|
violations := checkStandalonePluginImports(root)
|
||||||
if len(violations) != 1 {
|
if len(violations) != 2 {
|
||||||
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 1: %#v", len(violations), violations)
|
t.Fatalf("checkStandalonePluginImports() returned %d violations, want 2: %#v", len(violations), violations)
|
||||||
}
|
}
|
||||||
if violations[0].importPath != "git.dev.alexdunmow.com/block/cms/internal/templates" {
|
if violations[0].importPath != "git.dev.alexdunmow.com/block/core/templates/bn" {
|
||||||
t.Fatalf("importPath = %q, want forbidden BlockNinja templ import", violations[0].importPath)
|
t.Fatalf("importPath[0] = %q, want forbidden core templ import", violations[0].importPath)
|
||||||
}
|
}
|
||||||
if violations[0].line != 5 {
|
if violations[1].importPath != "git.dev.alexdunmow.com/block/cms/internal/templates" {
|
||||||
t.Fatalf("line = %d, want 5", violations[0].line)
|
t.Fatalf("importPath[1] = %q, want forbidden BlockNinja templ import", violations[1].importPath)
|
||||||
|
}
|
||||||
|
if violations[1].line != 5 {
|
||||||
|
t.Fatalf("line = %d, want 5", violations[1].line)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,17 @@ func currentCMSCoreSDKVersion() (string, error) {
|
|||||||
return readRequiredModuleVersion(filepath.Join(blockNinjaRepoRoot(), "backend", "go.mod"), blockCoreImportPrefix)
|
return readRequiredModuleVersion(filepath.Join(blockNinjaRepoRoot(), "backend", "go.mod"), blockCoreImportPrefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// currentCMSPluginSDKVersion anchors the fleet's pluginsdk pin to the CMS
|
||||||
|
// backend's. Empty (no error) while the CMS itself hasn't migrated to
|
||||||
|
// pluginsdk yet — version enforcement is skipped during that transition.
|
||||||
|
func currentCMSPluginSDKVersion() (string, error) {
|
||||||
|
v, err := readRequiredModuleVersion(filepath.Join(blockNinjaRepoRoot(), "backend", "go.mod"), pluginSDKImportPrefix)
|
||||||
|
if err != nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
return v, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseGoModForSafety(goModPath string) (*modfile.File, []pluginGoModViolation) {
|
func parseGoModForSafety(goModPath string) (*modfile.File, []pluginGoModViolation) {
|
||||||
if !fileExists(goModPath) {
|
if !fileExists(goModPath) {
|
||||||
return nil, []pluginGoModViolation{{
|
return nil, []pluginGoModViolation{{
|
||||||
@ -157,18 +168,33 @@ func checkStandalonePluginGoMod(root, requiredSDKVersion string) []pluginGoModVi
|
|||||||
|
|
||||||
violations = append(violations, checkGoModForAnyReplaceDirectives(goModPath)...)
|
violations = append(violations, checkGoModForAnyReplaceDirectives(goModPath)...)
|
||||||
|
|
||||||
|
// The standalone-plugin SDK is block/pluginsdk (2026-07-07 extraction).
|
||||||
|
// block/core is host-only: no plugin package imports survive (check 2c
|
||||||
|
// imports), so a core require is always vestigial and fails the gate.
|
||||||
currentSDKVersion := ""
|
currentSDKVersion := ""
|
||||||
for _, req := range parsed.Require {
|
for _, req := range parsed.Require {
|
||||||
if req.Mod.Path == blockCoreImportPrefix {
|
if req.Mod.Path == pluginSDKImportPrefix {
|
||||||
currentSDKVersion = req.Mod.Version
|
currentSDKVersion = req.Mod.Version
|
||||||
break
|
continue
|
||||||
|
}
|
||||||
|
if req.Mod.Path == blockCoreImportPrefix {
|
||||||
|
line := 0
|
||||||
|
if req.Syntax != nil {
|
||||||
|
line = req.Syntax.Start.Line
|
||||||
|
}
|
||||||
|
violations = append(violations, pluginGoModViolation{
|
||||||
|
file: "go.mod",
|
||||||
|
line: line,
|
||||||
|
rule: "no-block-core-require",
|
||||||
|
detail: fmt.Sprintf("requires %s — standalone plugins build against %s only", blockCoreImportPrefix, pluginSDKImportPrefix),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if currentSDKVersion == "" {
|
if currentSDKVersion == "" {
|
||||||
violations = append(violations, pluginGoModViolation{
|
violations = append(violations, pluginGoModViolation{
|
||||||
file: "go.mod",
|
file: "go.mod",
|
||||||
rule: "missing-block-core-require",
|
rule: "missing-pluginsdk-require",
|
||||||
detail: fmt.Sprintf("missing required module %s", blockCoreImportPrefix),
|
detail: fmt.Sprintf("missing required module %s (standalone plugins build against the plugin SDK, not block/core)", pluginSDKImportPrefix),
|
||||||
})
|
})
|
||||||
return violations
|
return violations
|
||||||
}
|
}
|
||||||
@ -176,7 +202,7 @@ func checkStandalonePluginGoMod(root, requiredSDKVersion string) []pluginGoModVi
|
|||||||
if requiredSDKVersion != "" && currentSDKVersion != requiredSDKVersion {
|
if requiredSDKVersion != "" && currentSDKVersion != requiredSDKVersion {
|
||||||
violations = append(violations, pluginGoModViolation{
|
violations = append(violations, pluginGoModViolation{
|
||||||
file: "go.mod",
|
file: "go.mod",
|
||||||
rule: "block-core-version-mismatch",
|
rule: "pluginsdk-version-mismatch",
|
||||||
detail: fmt.Sprintf("requires %s, want %s", currentSDKVersion, requiredSDKVersion),
|
detail: fmt.Sprintf("requires %s, want %s", currentSDKVersion, requiredSDKVersion),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,9 +50,9 @@ func TestCheckStandalonePluginGoModFlagsReplaceDirectives(t *testing.T) {
|
|||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require git.dev.alexdunmow.com/block/core v0.2.1
|
require git.dev.alexdunmow.com/block/pluginsdk v0.2.1
|
||||||
|
|
||||||
replace git.dev.alexdunmow.com/block/core => ../block-core
|
replace git.dev.alexdunmow.com/block/pluginsdk => ../block-pluginsdk
|
||||||
`, 0644)
|
`, 0644)
|
||||||
|
|
||||||
violations := checkStandalonePluginGoMod(root, "v0.2.1")
|
violations := checkStandalonePluginGoMod(root, "v0.2.1")
|
||||||
@ -67,34 +67,74 @@ replace git.dev.alexdunmow.com/block/core => ../block-core
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestCheckStandalonePluginGoModFlagsOutdatedSDKVersion(t *testing.T) {
|
// A replace directive for the coexisting block/core module still fails —
|
||||||
|
// replace directives of any module are forbidden in standalone plugin repos.
|
||||||
|
func TestCheckStandalonePluginGoModFlagsCoreReplaceDirective(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
|
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require git.dev.alexdunmow.com/block/core v0.2.0
|
require git.dev.alexdunmow.com/block/pluginsdk v0.2.1
|
||||||
|
|
||||||
|
replace git.dev.alexdunmow.com/block/core => ../block-core
|
||||||
`, 0644)
|
`, 0644)
|
||||||
|
|
||||||
violations := checkStandalonePluginGoMod(root, "v0.2.1")
|
violations := checkStandalonePluginGoMod(root, "v0.2.1")
|
||||||
if len(violations) != 1 {
|
if len(violations) != 1 {
|
||||||
t.Fatalf("checkStandalonePluginGoMod() returned %d violations, want 1: %#v", len(violations), violations)
|
t.Fatalf("checkStandalonePluginGoMod() returned %d violations, want 1: %#v", len(violations), violations)
|
||||||
}
|
}
|
||||||
if violations[0].rule != "block-core-version-mismatch" {
|
if violations[0].rule != "no-replace-directives" {
|
||||||
t.Fatalf("rule = %q, want block-core-version-mismatch", violations[0].rule)
|
t.Fatalf("rule = %q, want no-replace-directives", violations[0].rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCheckStandalonePluginGoModFlagsOutdatedSDKVersion(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require git.dev.alexdunmow.com/block/pluginsdk v0.2.0
|
||||||
|
`, 0644)
|
||||||
|
|
||||||
|
violations := checkStandalonePluginGoMod(root, "v0.2.1")
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("checkStandalonePluginGoMod() returned %d violations, want 1: %#v", len(violations), violations)
|
||||||
|
}
|
||||||
|
if violations[0].rule != "pluginsdk-version-mismatch" {
|
||||||
|
t.Fatalf("rule = %q, want pluginsdk-version-mismatch", violations[0].rule)
|
||||||
}
|
}
|
||||||
if !strings.Contains(violations[0].detail, "want v0.2.1") {
|
if !strings.Contains(violations[0].detail, "want v0.2.1") {
|
||||||
t.Fatalf("detail = %q, want target version", violations[0].detail)
|
t.Fatalf("detail = %q, want target version", violations[0].detail)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCheckStandalonePluginGoModFlagsMissingPluginSDKRequire(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require github.com/google/uuid v1.6.0
|
||||||
|
`, 0644)
|
||||||
|
|
||||||
|
violations := checkStandalonePluginGoMod(root, "v0.2.1")
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("checkStandalonePluginGoMod() returned %d violations, want 1: %#v", len(violations), violations)
|
||||||
|
}
|
||||||
|
if violations[0].rule != "missing-pluginsdk-require" {
|
||||||
|
t.Fatalf("rule = %q, want missing-pluginsdk-require", violations[0].rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckStandalonePluginGoModAllowsMatchingSDKVersionWithoutReplace(t *testing.T) {
|
func TestCheckStandalonePluginGoModAllowsMatchingSDKVersionWithoutReplace(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
|
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
|
||||||
|
|
||||||
go 1.26.2
|
go 1.26.2
|
||||||
|
|
||||||
require git.dev.alexdunmow.com/block/core v0.2.1
|
require git.dev.alexdunmow.com/block/pluginsdk v0.2.1
|
||||||
`, 0644)
|
`, 0644)
|
||||||
|
|
||||||
violations := checkStandalonePluginGoMod(root, "v0.2.1")
|
violations := checkStandalonePluginGoMod(root, "v0.2.1")
|
||||||
@ -103,6 +143,30 @@ require git.dev.alexdunmow.com/block/core v0.2.1
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The former captcha/backup coexistence is over (captcha = host-stamped
|
||||||
|
// trusted header since pluginsdk v0.2.2): a block/core require in a
|
||||||
|
// standalone plugin go.mod is always vestigial and now fails the gate.
|
||||||
|
func TestCheckStandalonePluginGoModFlagsCoreRequire(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
writeTestFile(t, filepath.Join(root, "go.mod"), `module example.com/plugin
|
||||||
|
|
||||||
|
go 1.26.2
|
||||||
|
|
||||||
|
require (
|
||||||
|
git.dev.alexdunmow.com/block/core v0.5.0
|
||||||
|
git.dev.alexdunmow.com/block/pluginsdk v0.2.1
|
||||||
|
)
|
||||||
|
`, 0644)
|
||||||
|
|
||||||
|
violations := checkStandalonePluginGoMod(root, "v0.2.1")
|
||||||
|
if len(violations) != 1 {
|
||||||
|
t.Fatalf("checkStandalonePluginGoMod() returned %d violations, want 1: %#v", len(violations), violations)
|
||||||
|
}
|
||||||
|
if violations[0].rule != "no-block-core-require" {
|
||||||
|
t.Fatalf("rule = %q, want no-block-core-require", violations[0].rule)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestCheckStandalonePluginGoModSkipsCodelessRepo(t *testing.T) {
|
func TestCheckStandalonePluginGoModSkipsCodelessRepo(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"fixture\"\nversion = \"0.1.0\"\n", 0644)
|
writeTestFile(t, filepath.Join(root, "plugin.mod"), "[plugin]\nname = \"fixture\"\nversion = \"0.1.0\"\n", 0644)
|
||||||
|
|||||||
@ -18,7 +18,7 @@ func TestRegistryOrder(t *testing.T) {
|
|||||||
"1", "2", "2b", "2c", "2d", "2e", "2f", "3", "3b", "4",
|
"1", "2", "2b", "2c", "2d", "2e", "2f", "3", "3b", "4",
|
||||||
"5", "6", "7", "8", "9", "10", "10b", "10disc", "11", "12",
|
"5", "6", "7", "8", "9", "10", "10b", "10disc", "11", "12",
|
||||||
"13", "14", "15", "16", "17", "18", "19", "20", "21", "22",
|
"13", "14", "15", "16", "17", "18", "19", "20", "21", "22",
|
||||||
"28", "29",
|
"28", "29", "30",
|
||||||
}
|
}
|
||||||
|
|
||||||
ordered := make([]Check, len(registry))
|
ordered := make([]Check, len(registry))
|
||||||
|
|||||||
138
renderperf.go
Normal file
138
renderperf.go
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Render-performance regression gate (WO-RP-013).
|
||||||
|
//
|
||||||
|
// Compares the latest e2e render benchmark result (cms
|
||||||
|
// e2e/render-bench/results/latest.json, written by the dojo suite
|
||||||
|
// `render-bench`) against the committed baseline
|
||||||
|
// (e2e/render-bench/baseline.json) and FAILS when the home page — the gating
|
||||||
|
// page class — is markedly slower.
|
||||||
|
//
|
||||||
|
// Staleness rule: this check must never block unrelated work when no live
|
||||||
|
// stack is running. It SKIPs (never fails) when:
|
||||||
|
// - baseline.json is absent (repo is not the cms repo, or gate not set up),
|
||||||
|
// - results/latest.json is absent (bench not run on this machine),
|
||||||
|
// - latest.json is older than renderPerfMaxAge (the bench predates recent
|
||||||
|
// work and proves nothing about it),
|
||||||
|
// - latest.json predates the committed baseline (stale result from before
|
||||||
|
// the current baseline was captured).
|
||||||
|
// In every SKIP case it says how to produce a fresh result (run the
|
||||||
|
// `render-bench` suite from dojo).
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// renderPerfP50Threshold fails the gate when home-page p50 exceeds the
|
||||||
|
// baseline p50 by more than this fraction. 25% sits above observed
|
||||||
|
// run-to-run variance (~7%) but well below any real regression worth a
|
||||||
|
// human look (WO-RP-013 mandated default).
|
||||||
|
renderPerfP50Threshold = 0.25
|
||||||
|
// renderPerfP95Threshold is the secondary tail gate. It WARNs rather than
|
||||||
|
// FAILs: observed p95 on an otherwise-idle dev instance varies >100%
|
||||||
|
// between runs minutes apart (36ms → 76ms) while p50 stays within ~7%,
|
||||||
|
// so a hard p95 gate would block commits on ambient dev-stack noise.
|
||||||
|
// p50 is the blocking metric.
|
||||||
|
renderPerfP95Threshold = 0.40
|
||||||
|
// renderPerfMaxAge is how old a latest.json may be and still count as
|
||||||
|
// evidence about the current tree.
|
||||||
|
renderPerfMaxAge = 7 * 24 * time.Hour
|
||||||
|
)
|
||||||
|
|
||||||
|
type renderBenchPage struct {
|
||||||
|
Page string `json:"page"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Gating bool `json:"gating"`
|
||||||
|
P50Ms float64 `json:"p50_ms"`
|
||||||
|
P95Ms float64 `json:"p95_ms"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type renderBenchResults struct {
|
||||||
|
Timestamp string `json:"timestamp"`
|
||||||
|
Instance string `json:"instance"`
|
||||||
|
Pages []renderBenchPage `json:"pages"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadRenderBench(path string) (*renderBenchResults, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var r renderBenchResults
|
||||||
|
if err := json.Unmarshal(raw, &r); err != nil {
|
||||||
|
return nil, fmt.Errorf("%s: %w", path, err)
|
||||||
|
}
|
||||||
|
return &r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *renderBenchResults) gatingPage() *renderBenchPage {
|
||||||
|
for i := range r.Pages {
|
||||||
|
if r.Pages[i].Gating {
|
||||||
|
return &r.Pages[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *renderBenchResults) parsedTime() (time.Time, error) {
|
||||||
|
return time.Parse(time.RFC3339, r.Timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// renderPerfVerdict is the outcome of one baseline/latest comparison,
|
||||||
|
// separated from the Reporter so it can be unit-tested.
|
||||||
|
type renderPerfVerdict struct {
|
||||||
|
skip bool
|
||||||
|
warn bool
|
||||||
|
fail bool
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareRenderPerf(baseline, latest *renderBenchResults, now time.Time) renderPerfVerdict {
|
||||||
|
base := baseline.gatingPage()
|
||||||
|
if base == nil {
|
||||||
|
return renderPerfVerdict{skip: true, message: "baseline.json has no gating page — regenerate it from the render-bench suite"}
|
||||||
|
}
|
||||||
|
cur := latest.gatingPage()
|
||||||
|
if cur == nil {
|
||||||
|
return renderPerfVerdict{skip: true, message: "latest.json has no gating page — re-run the render-bench suite from dojo"}
|
||||||
|
}
|
||||||
|
|
||||||
|
latestAt, err := latest.parsedTime()
|
||||||
|
if err != nil {
|
||||||
|
return renderPerfVerdict{skip: true, message: fmt.Sprintf("latest.json has an unparseable timestamp (%v) — re-run the render-bench suite from dojo", err)}
|
||||||
|
}
|
||||||
|
if now.Sub(latestAt) > renderPerfMaxAge {
|
||||||
|
return renderPerfVerdict{skip: true, message: fmt.Sprintf("latest bench result is %.0fh old (max %.0fh) — run the render-bench suite from dojo for a fresh result", now.Sub(latestAt).Hours(), renderPerfMaxAge.Hours())}
|
||||||
|
}
|
||||||
|
if baseAt, err := baseline.parsedTime(); err == nil && latestAt.Before(baseAt) {
|
||||||
|
return renderPerfVerdict{skip: true, message: "latest bench result predates the committed baseline — run the render-bench suite from dojo"}
|
||||||
|
}
|
||||||
|
|
||||||
|
if base.P50Ms <= 0 {
|
||||||
|
return renderPerfVerdict{skip: true, message: "baseline gating p50 is zero — regenerate baseline.json from the render-bench suite"}
|
||||||
|
}
|
||||||
|
|
||||||
|
p50Delta := (cur.P50Ms - base.P50Ms) / base.P50Ms
|
||||||
|
p95Delta := 0.0
|
||||||
|
if base.P95Ms > 0 {
|
||||||
|
p95Delta = (cur.P95Ms - base.P95Ms) / base.P95Ms
|
||||||
|
}
|
||||||
|
|
||||||
|
if p50Delta > renderPerfP50Threshold {
|
||||||
|
return renderPerfVerdict{fail: true, message: fmt.Sprintf(
|
||||||
|
"Home page render is %.0f%% slower than baseline (p50 %.1fms vs %.1fms). Investigate before proceeding — profile the regression (see docs/works/WO-RP-001 methodology); do NOT raise the baseline to make this pass without Captain approval.",
|
||||||
|
p50Delta*100, cur.P50Ms, base.P50Ms)}
|
||||||
|
}
|
||||||
|
if p95Delta > renderPerfP95Threshold {
|
||||||
|
return renderPerfVerdict{warn: true, message: fmt.Sprintf(
|
||||||
|
"Home page render tail is %.0f%% slower than baseline (p95 %.1fms vs %.1fms; p50 healthy at %.1fms vs %.1fms). p95 is noisy on the shared dev stack — re-run the render-bench suite; investigate if it persists.",
|
||||||
|
p95Delta*100, cur.P95Ms, base.P95Ms, cur.P50Ms, base.P50Ms)}
|
||||||
|
}
|
||||||
|
|
||||||
|
return renderPerfVerdict{message: fmt.Sprintf("home p50 %.1fms vs baseline %.1fms (%+.0f%%), p95 %.1fms vs %.1fms (%+.0f%%)",
|
||||||
|
cur.P50Ms, base.P50Ms, p50Delta*100, cur.P95Ms, base.P95Ms, p95Delta*100)}
|
||||||
|
}
|
||||||
76
renderperf_test.go
Normal file
76
renderperf_test.go
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func benchFixture(ts string, p50, p95 float64) *renderBenchResults {
|
||||||
|
return &renderBenchResults{
|
||||||
|
Timestamp: ts,
|
||||||
|
Instance: "blockninjacms.blockninja.dev",
|
||||||
|
Pages: []renderBenchPage{
|
||||||
|
{Page: "home", Path: "/", Gating: true, P50Ms: p50, P95Ms: p95},
|
||||||
|
{Page: "blog-post", Path: "/blog/x", P50Ms: 18, P95Ms: 25},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCompareRenderPerf(t *testing.T) {
|
||||||
|
now := time.Date(2026, 7, 7, 12, 0, 0, 0, time.UTC)
|
||||||
|
baseline := benchFixture("2026-07-06T16:47:00Z", 27.3, 37.0)
|
||||||
|
|
||||||
|
t.Run("within threshold passes", func(t *testing.T) {
|
||||||
|
v := compareRenderPerf(baseline, benchFixture("2026-07-07T10:00:00Z", 30.0, 40.0), now)
|
||||||
|
if v.skip || v.fail {
|
||||||
|
t.Fatalf("expected pass, got %+v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("p50 regression fails with investigate message", func(t *testing.T) {
|
||||||
|
v := compareRenderPerf(baseline, benchFixture("2026-07-07T10:00:00Z", 40.0, 41.0), now)
|
||||||
|
if !v.fail {
|
||||||
|
t.Fatalf("expected fail, got %+v", v)
|
||||||
|
}
|
||||||
|
for _, want := range []string{"slower than baseline", "Investigate before proceeding", "do NOT raise the baseline", "Captain approval"} {
|
||||||
|
if !strings.Contains(v.message, want) {
|
||||||
|
t.Errorf("fail message missing %q: %s", want, v.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("p95-only regression warns via secondary gate", func(t *testing.T) {
|
||||||
|
v := compareRenderPerf(baseline, benchFixture("2026-07-07T10:00:00Z", 28.0, 60.0), now)
|
||||||
|
if !v.warn || v.fail {
|
||||||
|
t.Fatalf("expected warn (not fail) for p95-only breach, got %+v", v)
|
||||||
|
}
|
||||||
|
if !strings.Contains(v.message, "p95") {
|
||||||
|
t.Errorf("expected p95 metric in message: %s", v.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("stale latest skips", func(t *testing.T) {
|
||||||
|
v := compareRenderPerf(baseline, benchFixture("2026-06-20T10:00:00Z", 40.0, 60.0), now)
|
||||||
|
if !v.skip {
|
||||||
|
t.Fatalf("expected skip for stale result, got %+v", v)
|
||||||
|
}
|
||||||
|
if !strings.Contains(v.message, "dojo") {
|
||||||
|
t.Errorf("skip message should say how to refresh: %s", v.message)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("latest predating baseline skips", func(t *testing.T) {
|
||||||
|
v := compareRenderPerf(baseline, benchFixture("2026-07-06T10:00:00Z", 40.0, 60.0), now)
|
||||||
|
if !v.skip {
|
||||||
|
t.Fatalf("expected skip for pre-baseline result, got %+v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("boundary: exactly 25 percent passes", func(t *testing.T) {
|
||||||
|
v := compareRenderPerf(baseline, benchFixture("2026-07-07T10:00:00Z", 27.3*1.25, 37.0), now)
|
||||||
|
if v.fail {
|
||||||
|
t.Fatalf("exactly-threshold should not fail: %+v", v)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
10
targets.go
10
targets.go
@ -99,9 +99,17 @@ func resolveScanRoots(target string) (backendDir string, repoRoot string, err er
|
|||||||
}
|
}
|
||||||
|
|
||||||
func defaultScanTargetDir(targetDir string, pluginRoots []string, cwd string) string {
|
func defaultScanTargetDir(targetDir string, pluginRoots []string, cwd string) string {
|
||||||
if len(pluginRoots) > 0 || targetDir != "." {
|
if targetDir != "." {
|
||||||
return targetDir
|
return targetDir
|
||||||
}
|
}
|
||||||
|
// --plugin-dir with no positional target means "scan that plugin", not
|
||||||
|
// "scan the cwd" (which is usually the check-safety repo itself).
|
||||||
|
if len(pluginRoots) > 0 {
|
||||||
|
if expanded, err := expandPath(pluginRoots[0]); err == nil {
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
return pluginRoots[0]
|
||||||
|
}
|
||||||
if fileExists(filepath.Join(cwd, "plugin.mod")) {
|
if fileExists(filepath.Join(cwd, "plugin.mod")) {
|
||||||
return cwd
|
return cwd
|
||||||
}
|
}
|
||||||
|
|||||||
2
testdata/golden/clean/expected.stdout
vendored
2
testdata/golden/clean/expected.stdout
vendored
@ -1,2 +1,2 @@
|
|||||||
check-safety FIXTURE_DIR
|
check-safety FIXTURE_DIR
|
||||||
32 checks: 20 ok 12 skip -> OK
|
33 checks: 20 ok 13 skip -> OK
|
||||||
|
|||||||
2
testdata/golden/nomod/expected.stdout
vendored
2
testdata/golden/nomod/expected.stdout
vendored
@ -10,4 +10,4 @@ FAIL 15 1 err.Error() leak(s) to HTTP clients — log via slog.Error() and retur
|
|||||||
FAIL 17 1 TODO marker(s) found — ship explicit behavior, not placeholders
|
FAIL 17 1 TODO marker(s) found — ship explicit behavior, not placeholders
|
||||||
internal/service/handler.go:14 // TODO: add proper initialisation
|
internal/service/handler.go:14 // TODO: add proper initialisation
|
||||||
|
|
||||||
32 checks: 14 ok 13 skip 5 fail -> FAIL
|
33 checks: 14 ok 14 skip 5 fail -> FAIL
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user