Compare commits

...

7 Commits

Author SHA1 Message Date
Alex Dunmow
ce81cce76c chore: checkpoint all working changes 2026-09-05 21:14:15 +08:00
Alex Dunmow
68e9fa6f6c fix: ignore proto output permission drift 2026-08-25 22:56:49 +08:00
Alex Dunmow
5aca8722c8 fix: isolate proto freshness generation 2026-08-25 21:27:37 +08:00
Alex Dunmow
d970b6da3d fix(frontend): scope non-proto fetch allowances 2026-08-19 18:10:27 +08:00
Alex Dunmow
a159bddf0b fix(frontend): allow certificate renewal stream client 2026-08-19 16:16:48 +08:00
Alex Dunmow
30be5aaca8 fix: discover active BlockNinja workspace 2026-08-09 10:16:13 +08:00
Alex Dunmow
08da6a4723 chore(theme): re-vendor cms theme copies for font size steps
Picks up FontSizeStepKeys/DefaultFontSizeSteps/ValidateFontSizeSteps and
the FontSizeSteps field on ThemeTypography, so the preset gate's
DisallowUnknownFields parse accepts presets shipping fontSizeSteps.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-04 00:54:07 +08:00
23 changed files with 1112 additions and 105 deletions

View File

@ -19,7 +19,7 @@ check-safety/
## Invariants when editing
- This is **standalone** — no imports from sibling repos (CMS, orchestrator, plugins). The two vendored packages above are the only CMS surfaces it depends on, and they are intentional copies.
- `blockNinjaRepoRoot()` and `orchestratorRepoRoot()` in `lint_pipeline.go` hardcode the consolidated layout (`~/src/blockninja/{cms,orchestrator}`). Update them if the tree layout changes.
- `blockNinjaRepoRoot()` and `orchestratorRepoRoot()` discover the consolidated workspace from the current directory, executable, or source location, with `~/src/blockninja` retained only as a legacy fallback. Keep discovery location-independent.
- Golden tests are characterisation tests — if you intentionally change a check's output, run `make test-update` and commit the new fixture.
- The CMS Makefile's `safety-check` and `install-safety-checker` targets shell out into this directory (`cd ../check-safety`). Keep the CLI contract stable: `check-safety <target-dir> [--flags]`.

View File

@ -1,9 +1,9 @@
# check-safety
Static safety checker for the BlockNinja codebase. Walks a target tree, runs 33 invariant
Static safety checker for the BlockNinja codebase. Walks a target tree, runs 34 invariant
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 in the consolidated BlockNinja workspace as a standalone Go module, alongside `cms/`,
`orchestrator/`, `core/`, the plugins, and the sites. It is intentionally standalone — no
imports from sibling repos except two vendored packages (see [Vendored packages](#how-it-stays-in-sync-with-cms)).
@ -29,7 +29,7 @@ If you run it against the top of the consolidated BlockNinja repo, it prints a h
| Flag | Meaning |
|------|---------|
| `<target-dir>` (positional) | Repo/subtree to scan. Defaults to `.`. If it contains `plugin.mod`, it is auto-registered as a plugin root so plugin checks run. |
| `--orchestrator` | Scan the orchestrator backend only (resolved from the hardcoded consolidated layout), regardless of the positional target. |
| `--orchestrator` | Scan the orchestrator backend only (resolved from the discovered consolidated workspace), regardless of the positional target. |
| `--plugin-dir <path> [more…]` | Register one or more plugin roots (dirs with `plugin.mod`). Alias: `--plugin-dirs`. Consumes args until the next `--flag`. |
| `--plugin-pages <dir> [more…]` | Register frontend source dirs directly as plugin page targets (for the frontend checks). Consumes args until the next `--flag`. |
| `--verbose` / `-v` | Print every check (including `OK`/`SKIP`), not just failures and warnings. |
@ -46,7 +46,7 @@ line with their findings indented beneath, followed by one tally line. A clean r
```
check-safety /home/alex/src/blockninja/cms
32 checks: 20 ok 12 skip -> OK
34 checks: 20 ok 14 skip -> OK
```
A run with problems:
@ -58,7 +58,7 @@ FAIL 15 1 err.Error() leak(s) to HTTP clients — log via slog.Error() and retur
WARN 2e 1 any usage in changed lines (showing 1, --all-any to scan all)
web/src/api.ts:12 [go] data: any
32 checks: 27 ok 3 skip 1 warn 1 fail -> FAIL
34 checks: 29 ok 3 skip 1 warn 1 fail -> FAIL
```
Pass `--verbose` to see every check's status. Exit code is `1` on any `FAIL`, `2` on a hard
@ -124,6 +124,9 @@ tool prints in each check header):
| 21 | Preset validation | Plugin `presets.json` type-safe-unmarshals against `theme.Theme`. |
| 22 | HTML sanitization | No hand-rolled HTML sanitization — use bluemonday. |
| 28 | Admin toolbar | Public page handlers inject the admin toolbar data. |
| 29 | No legacy Go plugins | No `-buildmode=plugin` targets remain after the wasm migration gate is enabled. |
| 30 | Render performance | Home-page render p50 remains within the recorded baseline. |
| 32 | Schema purity | Every table in `schema.sql` originates from a core migration (31 is retired). |
> The canonical numbered list also lives in the comment block at the top of `main.go`; keep
> the two in sync when adding or renumbering a check.
@ -136,9 +139,9 @@ modules, so they are copied in rather than imported. When CMS changes the theme
`LogDeferredError`, re-copy them — that drift is precisely what the preset-validation check
(21) surfaces.
`blockNinjaRepoRoot()` and `orchestratorRepoRoot()` in `lint_pipeline.go` hardcode the
consolidated layout (`~/src/blockninja/{cms,orchestrator}`). Update them if the tree layout
changes.
`blockNinjaRepoRoot()` and `orchestratorRepoRoot()` discover the consolidated workspace by
walking upward from the current directory, executable, and compiled source location. The
historical `~/src/blockninja` layout remains a last-resort fallback for installed binaries.
## Adding a new check

View File

@ -73,7 +73,7 @@ func (d *diffIndex) repoDiff(repo string) *repoDiff {
parseUnifiedDiff(string(out), rd)
}
if out, err := exec.Command("git", "-C", repo, "ls-files", "--others", "--exclude-standard").Output(); err == nil {
for _, f := range strings.Split(strings.TrimSpace(string(out)), "\n") {
for f := range strings.SplitSeq(strings.TrimSpace(string(out)), "\n") {
if f != "" {
rd.untracked[filepath.ToSlash(f)] = true
}
@ -106,7 +106,7 @@ func parseUnifiedDiff(diff string, rd *repoDiff) {
if rd.changed[cur] == nil {
rd.changed[cur] = map[int]bool{}
}
for i := 0; i < count; i++ {
for i := range count {
rd.changed[cur][start+i] = true
}
}
@ -116,18 +116,18 @@ func parseUnifiedDiff(diff string, rd *repoDiff) {
// parseHunkNewRange extracts (start, count) from the `+c,d` part of a hunk
// header like `@@ -a,b +c,d @@`. A missing `,d` means count 1.
func parseHunkNewRange(header string) (int, int) {
plus := strings.IndexByte(header, '+')
if plus < 0 {
_, after, ok := strings.Cut(header, "+")
if !ok {
return 0, 0
}
rest := header[plus+1:]
rest := after
if sp := strings.IndexByte(rest, ' '); sp >= 0 {
rest = rest[:sp]
}
start, count := 0, 1
if comma := strings.IndexByte(rest, ','); comma >= 0 {
start, _ = strconv.Atoi(rest[:comma])
count, _ = strconv.Atoi(rest[comma+1:])
if before, after, ok := strings.Cut(rest, ","); ok {
start, _ = strconv.Atoi(before)
count, _ = strconv.Atoi(after)
} else {
start, _ = strconv.Atoi(rest)
}

View File

@ -0,0 +1,36 @@
# Location-independent workspace discovery
Decided 2026-08-09. The safety runner located the CMS and orchestrator through
hard-coded `$HOME/src/blockninja` paths and compared repositories as raw
absolute strings. The shared workspace can also be mounted at paths such as
`/srv/agent-work/blockninja`. Even when both paths address the same files, the
string mismatch caused an explicit orchestrator scan to be classified as CMS;
the CMS protobuf namespace filter then removed every orchestrator RPC.
## Decision
- Discover the consolidated workspace by walking upward from the current
directory, executable directory, and compiled source location.
- Recognize a workspace only when the independent `cms`, `orchestrator`, and
`check-safety` Go modules are present.
- Retain `$HOME/src/blockninja` only as the final compatibility fallback for an
installed binary launched outside the workspace.
- Compare existing paths with filesystem identity and resolved symlinks before
falling back to cleaned absolute strings.
- Cover non-home workspaces and aliased paths with regression tests.
- Reconcile the registry assertion, golden snapshots, and check catalog with
the already-shipped 34th schema-purity check so the runner's own suite is a
reliable validation gate again.
- Remove the obsolete core-SDK version helper left behind when check 2c moved
to the published plugin SDK, restoring strict unused-code linting.
## Consequences
- `check-safety ../orchestrator` selects the `orchestrator.*` RPC namespace from
any consolidated workspace location.
- Default CMS scans and `--orchestrator` resolve siblings from the active
checkout instead of silently crossing into another checkout.
- Symlink, bind-mount, and alternate mount-path aliases are treated as the same
repository when the operating system reports the same underlying directory.
Keywords: check-safety, workspace discovery, /srv/agent-work, $HOME/src/blockninja, orchestrator, CMS, RPC namespace, samePath, symlink, bind mount

View File

@ -0,0 +1,44 @@
# Scope non-proto fetch allowances by file and exact target
Decided 2026-08-19. Check 5 enforces generated ConnectRPC hooks for frontend
API access. Its `knownNonProtoFetches` map matched broad URL prefixes and
reported every recognized exception as a warning. The CMS and orchestrator
therefore produced eleven permanent warnings for transport contracts that
cannot use generated unary hooks: multipart uploads, an SSE response stream,
the authenticated CMS support proxy, and the browser leg of MCP device
authorization. The warnings added no actionable signal, while prefixes such as
`/api/support/` and `/api/mcp/` could classify unrelated future calls as known.
## Decision
- Replace URL-prefix recognition with `allowedNonProtoFetches`, whose entries
bind one exact literal fetch target to one source file.
- Require every allowance to carry a rationale explaining why generated
ConnectRPC hooks cannot express the transport or protocol.
- Permit documented matches without a warning. Continue failing every
undocumented `/api/` fetch, including an allowed endpoint copied to another
file or an unreviewed sibling endpoint added to an allowed file.
- Parse the literal first argument to `fetch()` before matching so an exact
`/api/support/tickets` allowance cannot also admit a longer route by prefix.
- Cover all eleven current exceptions and both scope boundaries with unit
tests.
Keeping permanent warnings was rejected because a clean run could never reach
zero warnings and new actionable warnings were hidden in expected noise.
Allowlisting entire files was rejected because it would also bypass the manual
client, transport, and unrelated fetch checks. Retaining broad endpoint
prefixes without warnings was rejected because future REST calls could evade
review merely by sharing a namespace.
## Consequences
- Check 5 reports cleanly for the reviewed backup, helpdesk, AI streaming,
support, MCP device, and plugin upload calls.
- New non-proto calls require an explicit file-and-target decision with a
written rationale; otherwise the safety run fails.
- Renaming a route expression or moving a caller deliberately invalidates its
allowance and forces review.
- `frontend.go` owns the allowance policy and exact-target matcher;
`frontend_test.go` is the executable inventory and boundary regression suite.
Keywords: check-safety, check 5, frontend.go, frontend_test.go, knownNonProtoFetches, allowedNonProtoFetches, nonProtoFetchAllowed, literalFetchTarget, fetch-non-proto-api, no-fetch-api, ConnectRPC, multipart, FormData, SSE, /api/push/upload, /api/helpdesk/upload, /api/ai/chat/stream, /api/support, /api/mcp/device, /api/plugins/upload

View File

@ -0,0 +1,49 @@
# Isolate proto freshness generation from the scanned repository
Decided 2026-08-25. Check 2f ran `make proto` in the scanned CMS working tree
and compared `git status` before and after. That made a read-oriented safety
check destructive: concurrent checker processes generated into the same files,
an interrupted run could leave a partially rewritten tree, and a generated
file that was already dirty could change bytes without changing its porcelain
status.
## Decision
- Copy the proto generation inputs and existing generated outputs into a unique
temporary sandbox for every freshness invocation.
- Run `make proto` only in that sandbox. Link only the installed package-local
generator executables and their package entrypoints needed by Buf and the
export script; generated output paths never point back into the scanned
repository.
- Snapshot generated regular files and symlinks by type, mode, link target, and
SHA-256 content digest before and after generation. Sort paths before
reporting added, modified, and removed outputs in the existing porcelain-like
CLI format.
- Remove the owned sandbox on success, generator failure, and all ordinary
return paths. Abrupt process interruption can affect only disposable system
temporary state, never the scanned working tree.
- Exercise the boundary with real `make` recipes, including independent
concurrent checker processes and a generator that writes partial output
before failing.
A repository-wide lock was rejected because it serializes independent safety
runs and still leaves the live working tree vulnerable to interrupted
generation. Comparing only Git status in a temporary checkout was rejected
because status is not a content comparison and can conceal a second rewrite of
an already-modified file. Copying the entire repository, including dependency
trees and build artifacts, was rejected because proto generation needs only a
small, explicit input and output surface.
## Consequences
- Parallel check-safety processes can run proto freshness checks without
contending on generated files or corrupting the CMS checkout.
- Check 2f retains its `make proto` success, failure, and before/after finding
messages while its freshness verdict now follows deterministic file content.
- Generator stderr is still returned through `protoFreshnessResult.output`, and
sandbox preparation, snapshot, generation, and cleanup failures retain their
causal error chains.
- `proto_freshness.go` owns isolation and comparison; focused regression
coverage lives in `proto_freshness_test.go`.
Keywords: check-safety, check 2f, proto freshness, make proto, concurrency, interruption safety, sandbox, checkProtoGeneratedFreshness, checkProtoGeneratedFreshnessInSandbox, protoFreshnessResult, proto_freshness.go, proto_freshness_test.go, backend/internal/api, backend/internal/mcpserver/generated, packages/api/src, buf generate, pnpm generate-exports

View File

@ -0,0 +1,15 @@
# Ignore permission bits when checking generated protobuf freshness
The protobuf freshness checker introduced by ADR 0003 copies generated outputs into an isolated temporary sandbox, regenerates them, and compares the resulting state. The CMS workspace inherits a default ACL that creates ordinary generated files with group-write permission, while the system temporary directory creates the same files without that permission. Comparing complete file modes therefore reported every generated Go and TypeScript file as stale even when its content was byte-identical.
The checker now compares generated output type, content digest, and symlink target, but deliberately ignores ordinary permission bits. Generated protobuf artifacts are source files rather than executables, and Git does not preserve the group-write distinction that caused the false positive. File additions, removals, content changes, and regular-file-to-symlink changes remain visible.
Rejecting the sandbox isolation was not acceptable because it would restore concurrent mutation races in the scanned repository. Reproducing workspace ACLs inside every sandbox was also rejected because ACL support and inheritance vary by filesystem and host.
Consequences:
- `proto_freshness.go` no longer treats `0660` versus `0644` as generated drift.
- `proto_freshness_test.go` locks the permission-independent comparison behavior.
- Content, type, symlink-target, addition, and removal checks remain unchanged.
Keywords: proto freshness, generated protobufs, permission bits, file mode, default ACL, 0660, 0644, sameProtoOutputState, proto_freshness.go, proto_freshness_test.go

View File

@ -45,6 +45,10 @@ var allowedFrontendFiles = map[string]bool{
// with `for await` — generated Connect Query hooks are unary-only and
// cannot express a streaming turn.
"components/site-agent/use-site-agent-stream.ts": true,
// InfrastructureService.RenewCertificate is a Connect server-streaming RPC;
// Connect Query omits server-streaming methods, so the networking terminal
// must consume the generated client's AsyncIterable directly.
"routes/dashboard/admin/infrastructure/$nodeId/networking.tsx": true,
// Registry browse/detail call the ORCHESTRATOR's public Connect endpoints
// (PluginRegistryService/PluginReviewService) on a different origin. The
@ -67,19 +71,72 @@ var allowedPluginRESTFiles = map[string]bool{
"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)
// These get a WARN, not a FAIL
var knownNonProtoFetches = map[string]bool{
"/api/plugins/": true, // Plugin REST APIs
"/api/mcp/": true, // MCP device auth flow
"/api/lists/subscribe": true, // Public subscribe endpoint
"/api/helpdesk/": true, // Helpdesk attachment multipart upload/download (ConnectRPC doesn't support multipart)
"/preview/": true, // Preview HTML endpoints
"/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/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
type nonProtoFetchAllowance struct {
file string
target string
reason string
}
// allowedNonProtoFetches is intentionally scoped by both source file and exact
// fetch target. A broad path prefix would let unrelated API calls bypass the
// generated-hook rule. Add an entry only after confirming that no generated
// ConnectRPC hook can carry the endpoint's transport contract.
var allowedNonProtoFetches = []nonProtoFetchAllowance{
{
file: "components/admin/backups/restore-new-instance-dialog.tsx",
target: "/api/push/upload?account_id=${encodeURIComponent(accountId)}",
reason: "multipart backup ZIP upload; ConnectRPC messages cannot carry FormData",
},
{
file: "components/helpdesk/helpers.ts",
target: "/api/helpdesk/upload",
reason: "multipart helpdesk attachment upload; ConnectRPC messages cannot carry FormData",
},
{
file: "components/ai-agents/chat-page.tsx",
target: "/api/ai/chat/stream",
reason: "SSE response stream; generated Connect Query hooks are unary",
},
{
file: "components/ai-agents/chat-widget.tsx",
target: "/api/ai/chat/stream",
reason: "SSE response stream; generated Connect Query hooks are unary",
},
{
file: "components/bug-report/bug-report-dialog.tsx",
target: "/api/support/bug-reports",
reason: "multipart bug report forwarded by the authenticated CMS support proxy",
},
{
file: "components/support/help-widget.tsx",
target: "/api/support/tickets/${ticketId}/messages/${messageId}/attachments",
reason: "multipart attachment forwarded by the authenticated CMS support proxy",
},
{
file: "lib/support/availability.ts",
target: "/api/support/tickets",
reason: "CMS support availability probe targets the authenticated orchestrator proxy, not a CMS RPC",
},
{
file: "routes/admin/authorize-device.tsx",
target: "/api/mcp/device/status",
reason: "browser leg of the MCP device authorization protocol, outside the admin RPC surface",
},
{
file: "routes/admin/authorize-device.tsx",
target: "/api/mcp/device/authorize",
reason: "browser leg of the MCP device authorization protocol, outside the admin RPC surface",
},
{
file: "routes/admin/authorize-device.tsx",
target: "/api/mcp/device/deny",
reason: "browser leg of the MCP device authorization protocol, outside the admin RPC surface",
},
{
file: "routes/admin/plugins.tsx",
target: "/api/plugins/upload?token=${encodeURIComponent(token)}",
reason: "multipart BNP upload authorized by a one-time token minted through PluginsService",
},
}
var (
@ -95,7 +152,8 @@ var (
// Anti-pattern: createClient from connectrpc (should use generated hooks)
reCreateClient = regexp.MustCompile(`createClient\s*\(`)
// Anti-pattern: fetch() to /api/ or proto paths (should use ConnectRPC)
// Anti-pattern: fetch() to /api/ or proto paths (should use ConnectRPC).
// The literal first argument is parsed separately for narrow REST allowances.
reFetchAPI = regexp.MustCompile(`fetch\s*\(\s*['\x60"/]`)
// Anti-pattern: createConnectTransport (only allowed in transport.ts)
@ -253,20 +311,8 @@ func checkFrontend(webSrcDir string) (violations []frontendViolation, warnings [
snippet: strings.TrimSpace(line),
})
} else if isAPIFetch {
// Check if this is a known non-proto endpoint (warn, not fail)
isKnownNonProto := false
for prefix := range knownNonProtoFetches {
if strings.Contains(line, prefix) {
isKnownNonProto = true
break
}
}
if isKnownNonProto {
warnings = append(warnings, frontendViolation{
file: relPath, line: lineNum, rule: "fetch-non-proto-api",
snippet: strings.TrimSpace(line),
})
} else {
target, literal := literalFetchTarget(line)
if !literal || !nonProtoFetchAllowed(relPath, target) {
violations = append(violations, frontendViolation{
file: relPath, line: lineNum, rule: "no-fetch-api",
snippet: strings.TrimSpace(line),
@ -289,6 +335,40 @@ func checkFrontend(webSrcDir string) (violations []frontendViolation, warnings [
return violations, warnings
}
func literalFetchTarget(line string) (string, bool) {
match := reFetchAPI.FindStringIndex(line)
if match == nil {
return "", false
}
call := line[match[0]:]
open := strings.IndexByte(call, '(')
if open < 0 {
return "", false
}
rest := strings.TrimLeft(call[open+1:], " \t")
if len(rest) < 2 || (rest[0] != '\'' && rest[0] != '"' && rest[0] != '`') {
return "", false
}
quote := rest[0]
end := strings.IndexByte(rest[1:], quote)
if end < 0 {
return "", false
}
return rest[1 : end+1], true
}
func nonProtoFetchAllowed(relPath, target string) bool {
relPath = filepath.ToSlash(relPath)
for _, allowance := range allowedNonProtoFetches {
if relPath == allowance.file && target == allowance.target {
return true
}
}
return false
}
// checkPluginPages scans plugin web page directories for plugin-specific frontend
// anti-patterns. Plugins must use useQuery/useMutation with { transport } options
// (AGENT-GUIDE §7.2c) — createClient+useMemo is forbidden. Plugins must also

View File

@ -3,9 +3,162 @@ package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestCheckFrontendAllowsDocumentedNonProtoFetches(t *testing.T) {
t.Parallel()
tests := []struct {
name string
file string
target string
}{
{
name: "backup multipart upload",
file: "components/admin/backups/restore-new-instance-dialog.tsx",
target: "/api/push/upload?account_id=${encodeURIComponent(accountId)}",
},
{
name: "helpdesk multipart upload",
file: "components/helpdesk/helpers.ts",
target: "/api/helpdesk/upload",
},
{
name: "AI chat page stream",
file: "components/ai-agents/chat-page.tsx",
target: "/api/ai/chat/stream",
},
{
name: "AI chat widget stream",
file: "components/ai-agents/chat-widget.tsx",
target: "/api/ai/chat/stream",
},
{
name: "support bug report multipart upload",
file: "components/bug-report/bug-report-dialog.tsx",
target: "/api/support/bug-reports",
},
{
name: "support attachment multipart upload",
file: "components/support/help-widget.tsx",
target: "/api/support/tickets/${ticketId}/messages/${messageId}/attachments",
},
{
name: "support availability probe",
file: "lib/support/availability.ts",
target: "/api/support/tickets",
},
{
name: "MCP device status",
file: "routes/admin/authorize-device.tsx",
target: "/api/mcp/device/status",
},
{
name: "MCP device authorization",
file: "routes/admin/authorize-device.tsx",
target: "/api/mcp/device/authorize",
},
{
name: "MCP device denial",
file: "routes/admin/authorize-device.tsx",
target: "/api/mcp/device/deny",
},
{
name: "plugin multipart upload",
file: "routes/admin/plugins.tsx",
target: "/api/plugins/upload?token=${encodeURIComponent(token)}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, filepath.FromSlash(tt.file))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
source := "const response = await fetch(`" + tt.target + "`, { method: \"POST\" })\n"
if err := os.WriteFile(path, []byte(source), 0o644); err != nil {
t.Fatal(err)
}
violations, warnings := checkFrontend(dir)
if len(violations) != 0 || len(warnings) != 0 {
t.Fatalf("documented REST fetch returned violations=%#v warnings=%#v", violations, warnings)
}
})
}
}
func TestCheckFrontendRejectsUndocumentedNonProtoFetches(t *testing.T) {
t.Parallel()
tests := []struct {
name string
file string
target string
}{
{
name: "unknown endpoint in an allowed file",
file: "routes/admin/authorize-device.tsx",
target: "/api/mcp/device/revoke-all",
},
{
name: "allowed endpoint in an unknown file",
file: "routes/admin/unknown.tsx",
target: "/api/mcp/device/status",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, filepath.FromSlash(tt.file))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
source := "const response = await fetch(`" + tt.target + "`)\n"
if err := os.WriteFile(path, []byte(source), 0o644); err != nil {
t.Fatal(err)
}
violations, warnings := checkFrontend(dir)
if len(warnings) != 0 {
t.Fatalf("unexpected warning for undocumented REST fetch: %#v", warnings)
}
if len(violations) != 1 || violations[0].rule != "no-fetch-api" ||
!strings.Contains(violations[0].snippet, tt.target) {
t.Fatalf("violations = %#v, want one no-fetch-api finding for %q", violations, tt.target)
}
})
}
}
func TestAllowedNonProtoFetchesAreNarrowAndDocumented(t *testing.T) {
t.Parallel()
seen := make(map[string]bool, len(allowedNonProtoFetches))
for _, allowance := range allowedNonProtoFetches {
if allowance.file == "" || allowance.target == "" || allowance.reason == "" {
t.Fatalf("incomplete non-proto fetch allowance: %#v", allowance)
}
if strings.HasSuffix(allowance.target, "/") {
t.Fatalf("non-proto fetch allowance must name an exact target, got %q", allowance.target)
}
key := allowance.file + "\x00" + allowance.target
if seen[key] {
t.Fatalf("duplicate non-proto fetch allowance for %s %s", allowance.file, allowance.target)
}
seen[key] = true
}
}
func TestCheckPluginPagesFlagsQueryClientProvider(t *testing.T) {
dir := t.TempDir()
file := filepath.Join(dir, "editor.tsx")

View File

@ -236,6 +236,7 @@ type ThemeTypography struct {
LineHeightBase string `json:"lineHeightBase"`
FontWeightBase string `json:"fontWeightBase"`
FontSizeOverrides map[string]string `json:"fontSizeOverrides,omitempty"`
FontSizeSteps map[string]string `json:"fontSizeSteps,omitempty"`
}
// ThemeSpacing represents spacing settings

View File

@ -3,6 +3,7 @@ package theme
import (
"fmt"
"regexp"
"slices"
"sort"
"strconv"
"strings"
@ -19,6 +20,41 @@ var fontSizeOverrideKeys = map[string]bool{
var fontSizePattern = regexp.MustCompile(`^(\d+(?:\.\d+)?|\.\d+)(rem|px)$`)
var fontSizeStepKeys = []string{"small", "normal", "large", "huge"}
// FontSizeStepKeys returns the canonical step keys in display order.
func FontSizeStepKeys() []string {
return slices.Clone(fontSizeStepKeys)
}
// DefaultFontSizeSteps returns the fallback rem value for each step.
func DefaultFontSizeSteps() map[string]string {
return map[string]string{
"small": "0.875rem",
"normal": "1rem",
"large": "1.125rem",
"huge": "1.25rem",
}
}
// ValidateFontSizeSteps rejects unknown keys and non-rem values. Steps feed the
// base-size UI chips only and are never emitted to CSS; rem-only so chips always
// scale with visitor preferences.
func ValidateFontSizeSteps(steps map[string]string) error {
for k, v := range steps {
if !slices.Contains(fontSizeStepKeys, k) {
return fmt.Errorf("unknown font size step %q (valid: %s)", k, strings.Join(fontSizeStepKeys, ", "))
}
if err := ValidateFontSize(v); err != nil {
return err
}
if !strings.HasSuffix(v, "rem") {
return fmt.Errorf("font size step %q must be a rem value, got %q", k, v)
}
}
return nil
}
// FontSizeOverrideKeys returns the canonical override keys, sorted.
func FontSizeOverrideKeys() []string {
keys := make([]string, 0, len(fontSizeOverrideKeys))

View File

@ -427,19 +427,19 @@ func copyFile(srcPath, dstPath string, mode os.FileMode) error {
}
func blockNinjaRepoRoot() string {
home, err := os.UserHomeDir()
if err != nil {
workspace := consolidatedWorkspaceRoot()
if workspace == "" {
return ""
}
return filepath.Join(home, "src", "blockninja", "cms")
return filepath.Join(workspace, "cms")
}
func orchestratorRepoRoot() string {
home, err := os.UserHomeDir()
if err != nil {
workspace := consolidatedWorkspaceRoot()
if workspace == "" {
return ""
}
return filepath.Join(home, "src", "blockninja", "orchestrator")
return filepath.Join(workspace, "orchestrator")
}
func ensureDefaultFrontendConfigs(repoRoot, packageRoot string) error {

View File

@ -35,6 +35,8 @@
// 27. No hand-rolled HTML sanitization — use bluemonday
// 28. Public page handlers inject admin toolbar data
// 29. No -buildmode=plugin targets in ported plugin repos (disabled until WO-WZ-017)
// 30. Home-page render performance stays within its recorded baseline
// 32. schema.sql contains only tables created by core migrations (31 retired)
package main
import (

View File

@ -3,6 +3,7 @@ package main
import (
"os"
"path/filepath"
"runtime"
"slices"
"strings"
)
@ -66,7 +67,76 @@ func samePath(a, b string) bool {
return false
}
return absA == absB
infoA, errA := os.Stat(absA)
infoB, errB := os.Stat(absB)
if errA == nil && errB == nil && os.SameFile(infoA, infoB) {
return true
}
realA, errA := filepath.EvalSymlinks(absA)
realB, errB := filepath.EvalSymlinks(absB)
if errA == nil && errB == nil {
return realA == realB
}
return filepath.Clean(absA) == filepath.Clean(absB)
}
// consolidatedWorkspaceRoot locates the parent that owns the independent CMS,
// orchestrator, and check-safety repositories. The checkout may live anywhere
// (for example /srv/agent-work/blockninja), so discovery starts from runtime
// locations before retaining the historical ~/src/blockninja fallback.
func consolidatedWorkspaceRoot() string {
var candidates []string
if cwd, err := os.Getwd(); err == nil {
candidates = append(candidates, cwd)
}
if executable, err := os.Executable(); err == nil {
candidates = append(candidates, filepath.Dir(executable))
}
if _, sourceFile, _, ok := runtime.Caller(0); ok {
candidates = append(candidates, filepath.Dir(sourceFile))
}
if home, err := os.UserHomeDir(); err == nil {
candidates = append(candidates, filepath.Join(home, "src", "blockninja"))
}
seen := make(map[string]bool, len(candidates))
for _, candidate := range candidates {
root := findConsolidatedWorkspaceRoot(candidate)
if root == "" || seen[root] {
continue
}
seen[root] = true
return root
}
if home, err := os.UserHomeDir(); err == nil {
return filepath.Join(home, "src", "blockninja")
}
return ""
}
func findConsolidatedWorkspaceRoot(start string) string {
current, err := filepath.Abs(start)
if err != nil {
return ""
}
if info, statErr := os.Stat(current); statErr == nil && !info.IsDir() {
current = filepath.Dir(current)
}
for {
if fileExists(filepath.Join(current, "cms", "backend", "go.mod")) &&
fileExists(filepath.Join(current, "orchestrator", "backend", "go.mod")) &&
fileExists(filepath.Join(current, "check-safety", "go.mod")) {
return current
}
parent := filepath.Dir(current)
if parent == current {
return ""
}
current = parent
}
}
func normalizeDisplayLabel(label string) string {

View File

@ -16,10 +16,6 @@ type pluginGoModViolation struct {
detail string
}
func currentCMSCoreSDKVersion() (string, error) {
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.

311
proto_freshness.go Normal file
View File

@ -0,0 +1,311 @@
package main
import (
"crypto/sha256"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
)
var protoGeneratedPaths = []string{
"backend/internal/api",
"backend/internal/mcpserver/generated",
"packages/api/src",
}
var protoSandboxPaths = []string{
"Makefile",
"buf.yaml",
"buf.gen.yaml",
"buf.lock",
"package.json",
"pnpm-lock.yaml",
"pnpm-workspace.yaml",
"proto",
"backend/internal/api",
"backend/internal/mcpserver/generated",
"packages/api",
}
var protoSandboxTools = []struct {
name string
packagePath string
}{
{name: "protoc-gen-connect-query", packagePath: "@connectrpc/protoc-gen-connect-query"},
{name: "protoc-gen-es", packagePath: "@bufbuild/protoc-gen-es"},
{name: "tsx", packagePath: "tsx"},
}
type protoOutputState struct {
mode fs.FileMode
digest [sha256.Size]byte
linkTarget string
}
func checkProtoGeneratedFreshness(repoRoot string) (protoFreshnessResult, error) {
result := protoFreshnessResult{}
if !samePath(repoRoot, blockNinjaRepoRoot()) {
return result, nil
}
if !fileExists(filepath.Join(repoRoot, "Makefile")) || !fileExists(filepath.Join(repoRoot, "buf.gen.yaml")) {
return result, nil
}
return checkProtoGeneratedFreshnessInSandbox(repoRoot, "")
}
// checkProtoGeneratedFreshnessInSandbox runs generation against a private copy
// of its inputs and outputs. Each invocation owns its sandbox, so parallel
// checker processes never serialize on or mutate the scanned repository. An
// interrupted generator can leave only disposable state beneath the system
// temporary directory.
func checkProtoGeneratedFreshnessInSandbox(repoRoot, tempParent string) (result protoFreshnessResult, returnErr error) {
result.checked = true
sandboxRoot, err := os.MkdirTemp(tempParent, "check-safety-proto-*")
if err != nil {
return result, fmt.Errorf("create proto freshness sandbox: %w", err)
}
defer func() {
if cleanupErr := os.RemoveAll(sandboxRoot); cleanupErr != nil {
returnErr = errors.Join(returnErr, fmt.Errorf("remove proto freshness sandbox: %w", cleanupErr))
}
}()
if err := prepareProtoSandbox(repoRoot, sandboxRoot); err != nil {
return result, fmt.Errorf("prepare proto freshness sandbox: %w", err)
}
before, err := snapshotProtoOutputs(sandboxRoot)
if err != nil {
return result, fmt.Errorf("snapshot generated proto outputs before generation: %w", err)
}
output, runErr := runCommand(sandboxRoot, "make", "proto")
result.output = output
after, snapshotErr := snapshotProtoOutputs(sandboxRoot)
if snapshotErr == nil {
result.afterStatus = strings.Join(diffProtoOutputs(before, after), "\n")
}
var generationErrs []error
if runErr != nil {
generationErrs = append(generationErrs, fmt.Errorf("make proto failed: %w", runErr))
}
if snapshotErr != nil {
generationErrs = append(generationErrs, fmt.Errorf("snapshot generated proto outputs after generation: %w", snapshotErr))
}
if len(generationErrs) > 0 {
return result, errors.Join(generationErrs...)
}
return result, nil
}
func prepareProtoSandbox(repoRoot, sandboxRoot string) error {
for _, relPath := range protoSandboxPaths {
if err := copyProtoSandboxPath(repoRoot, sandboxRoot, relPath); err != nil {
return fmt.Errorf("copy %s: %w", relPath, err)
}
}
return linkProtoSandboxTools(repoRoot, sandboxRoot)
}
func copyProtoSandboxPath(repoRoot, sandboxRoot, relPath string) error {
sourcePath := filepath.Join(repoRoot, relPath)
if _, err := os.Lstat(sourcePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
return filepath.WalkDir(sourcePath, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(repoRoot, path)
if err != nil {
return fmt.Errorf("resolve relative path for %s: %w", path, err)
}
if entry.IsDir() && (rel == filepath.Join("packages", "api", "node_modules") || rel == filepath.Join("packages", "api", "dist")) {
return filepath.SkipDir
}
destinationPath := filepath.Join(sandboxRoot, rel)
info, err := entry.Info()
if err != nil {
return fmt.Errorf("inspect %s: %w", path, err)
}
switch {
case info.IsDir():
if err := os.MkdirAll(destinationPath, info.Mode().Perm()); err != nil {
return fmt.Errorf("create directory %s: %w", destinationPath, err)
}
return nil
case info.Mode().IsRegular():
if err := copyFile(path, destinationPath, info.Mode()); err != nil {
return fmt.Errorf("copy file %s: %w", path, err)
}
return nil
case info.Mode()&fs.ModeSymlink != 0:
target, err := os.Readlink(path)
if err != nil {
return fmt.Errorf("read symlink %s: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(destinationPath), 0o755); err != nil {
return fmt.Errorf("create symlink parent for %s: %w", destinationPath, err)
}
if err := os.Symlink(target, destinationPath); err != nil {
return fmt.Errorf("copy symlink %s: %w", path, err)
}
return nil
default:
return fmt.Errorf("unsupported file type %s at %s", info.Mode().Type(), path)
}
})
}
func linkProtoSandboxTools(repoRoot, sandboxRoot string) error {
sourceBinDir := filepath.Join(repoRoot, "packages", "api", "node_modules", ".bin")
destinationBinDir := filepath.Join(sandboxRoot, "packages", "api", "node_modules", ".bin")
for _, tool := range protoSandboxTools {
sourcePath := filepath.Join(sourceBinDir, tool.name)
if _, err := os.Lstat(sourcePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return fmt.Errorf("inspect proto tool %s: %w", tool.name, err)
}
if err := os.MkdirAll(destinationBinDir, 0o755); err != nil {
return fmt.Errorf("create proto tool directory: %w", err)
}
if err := os.Symlink(sourcePath, filepath.Join(destinationBinDir, tool.name)); err != nil {
return fmt.Errorf("link proto tool %s: %w", tool.name, err)
}
sourcePackagePath := filepath.Join(repoRoot, "packages", "api", "node_modules", filepath.FromSlash(tool.packagePath))
if _, err := os.Lstat(sourcePackagePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return fmt.Errorf("inspect proto tool package %s: %w", tool.packagePath, err)
}
destinationPackagePath := filepath.Join(sandboxRoot, "packages", "api", "node_modules", filepath.FromSlash(tool.packagePath))
if err := os.MkdirAll(filepath.Dir(destinationPackagePath), 0o755); err != nil {
return fmt.Errorf("create proto tool package directory for %s: %w", tool.packagePath, err)
}
if err := os.Symlink(sourcePackagePath, destinationPackagePath); err != nil {
return fmt.Errorf("link proto tool package %s: %w", tool.packagePath, err)
}
}
return nil
}
func snapshotProtoOutputs(root string) (map[string]protoOutputState, error) {
states := make(map[string]protoOutputState)
for _, generatedPath := range protoGeneratedPaths {
absolutePath := filepath.Join(root, generatedPath)
if _, err := os.Lstat(absolutePath); err != nil {
if errors.Is(err, os.ErrNotExist) {
continue
}
return nil, fmt.Errorf("inspect %s: %w", generatedPath, err)
}
err := filepath.WalkDir(absolutePath, func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
return fmt.Errorf("resolve generated output path %s: %w", path, err)
}
rel = filepath.ToSlash(rel)
info, err := entry.Info()
if err != nil {
return fmt.Errorf("inspect generated output %s: %w", rel, err)
}
state := protoOutputState{mode: info.Mode().Type() | info.Mode().Perm()}
switch {
case info.Mode().IsRegular():
content, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read generated output %s: %w", rel, err)
}
state.digest = sha256.Sum256(content)
case info.Mode()&fs.ModeSymlink != 0:
target, err := os.Readlink(path)
if err != nil {
return fmt.Errorf("read generated output symlink %s: %w", rel, err)
}
state.linkTarget = target
default:
return fmt.Errorf("unsupported generated output type %s at %s", info.Mode().Type(), rel)
}
states[rel] = state
return nil
})
if err != nil {
return nil, fmt.Errorf("walk generated outputs under %s: %w", generatedPath, err)
}
}
return states, nil
}
func diffProtoOutputs(before, after map[string]protoOutputState) []string {
pathSet := make(map[string]struct{}, len(before)+len(after))
for path := range before {
pathSet[path] = struct{}{}
}
for path := range after {
pathSet[path] = struct{}{}
}
paths := make([]string, 0, len(pathSet))
for path := range pathSet {
paths = append(paths, path)
}
sort.Strings(paths)
changes := make([]string, 0)
for _, path := range paths {
beforeState, existedBefore := before[path]
afterState, existsAfter := after[path]
switch {
case !existedBefore:
changes = append(changes, "?? "+path)
case !existsAfter:
changes = append(changes, " D "+path)
case !sameProtoOutputState(beforeState, afterState):
changes = append(changes, " M "+path)
}
}
return changes
}
// sameProtoOutputState intentionally ignores permission bits. Generated files
// can inherit different default ACLs in the source workspace and the isolated
// sandbox even when their type and content are identical.
func sameProtoOutputState(left, right protoOutputState) bool {
return left.mode.Type() == right.mode.Type() &&
left.digest == right.digest &&
left.linkTarget == right.linkTarget
}

192
proto_freshness_test.go Normal file
View File

@ -0,0 +1,192 @@
package main
import (
"crypto/sha256"
"os"
"os/exec"
"path/filepath"
"strings"
"sync"
"testing"
)
const protoFreshnessHelperEnv = "CHECK_SAFETY_PROTO_FRESHNESS_HELPER"
func TestDiffProtoOutputsIsDeterministic(t *testing.T) {
before := map[string]protoOutputState{
"packages/api/src/unchanged.ts": {digest: [sha256.Size]byte{1}},
"packages/api/src/modified.ts": {digest: [sha256.Size]byte{2}},
"packages/api/src/removed.ts": {digest: [sha256.Size]byte{3}},
}
after := map[string]protoOutputState{
"packages/api/src/unchanged.ts": {digest: [sha256.Size]byte{1}},
"packages/api/src/modified.ts": {digest: [sha256.Size]byte{4}},
"packages/api/src/added.ts": {digest: [sha256.Size]byte{5}},
}
want := strings.Join([]string{
"?? packages/api/src/added.ts",
" M packages/api/src/modified.ts",
" D packages/api/src/removed.ts",
}, "\n")
if got := strings.Join(diffProtoOutputs(before, after), "\n"); got != want {
t.Fatalf("diffProtoOutputs() = %q, want %q", got, want)
}
}
func TestDiffProtoOutputsIgnoresPermissionDifferences(t *testing.T) {
digest := [sha256.Size]byte{1}
before := map[string]protoOutputState{
"packages/api/src/example.ts": {mode: 0o660, digest: digest},
}
after := map[string]protoOutputState{
"packages/api/src/example.ts": {mode: 0o644, digest: digest},
}
if got := diffProtoOutputs(before, after); len(got) != 0 {
t.Fatalf("diffProtoOutputs() = %v, want permissions-only change ignored", got)
}
}
func TestCheckProtoGeneratedFreshnessInSandboxLeavesSourceUntouched(t *testing.T) {
repoRoot := newProtoFreshnessTestRepo(t, "@printf 'generated\\n' > packages/api/src/example.ts")
tempParent := t.TempDir()
result, err := checkProtoGeneratedFreshnessInSandbox(repoRoot, tempParent)
if err != nil {
t.Fatalf("checkProtoGeneratedFreshnessInSandbox() error = %v\n%s", err, result.output)
}
if !result.checked {
t.Fatal("checkProtoGeneratedFreshnessInSandbox() checked = false, want true")
}
if !result.changed() {
t.Fatal("checkProtoGeneratedFreshnessInSandbox() changed = false, want true")
}
if result.beforeStatus != "" {
t.Fatalf("beforeStatus = %q, want clean", result.beforeStatus)
}
if result.afterStatus != " M packages/api/src/example.ts" {
t.Fatalf("afterStatus = %q, want modified generated file", result.afterStatus)
}
assertProtoTestSourceAndSandboxesClean(t, repoRoot, tempParent)
}
func TestCheckProtoGeneratedFreshnessInSandboxReportsClean(t *testing.T) {
repoRoot := newProtoFreshnessTestRepo(t, "@:")
tempParent := t.TempDir()
result, err := checkProtoGeneratedFreshnessInSandbox(repoRoot, tempParent)
if err != nil {
t.Fatalf("checkProtoGeneratedFreshnessInSandbox() error = %v\n%s", err, result.output)
}
if result.changed() {
t.Fatalf("checkProtoGeneratedFreshnessInSandbox() changed = true, status = %q", result.afterStatus)
}
assertProtoTestSourceAndSandboxesClean(t, repoRoot, tempParent)
}
func TestCheckProtoGeneratedFreshnessInSandboxCleansUpAfterFailure(t *testing.T) {
repoRoot := newProtoFreshnessTestRepo(t, "@printf 'partial\\n' > packages/api/src/example.ts; printf 'generator failed\\n' >&2; exit 7")
tempParent := t.TempDir()
result, err := checkProtoGeneratedFreshnessInSandbox(repoRoot, tempParent)
if err == nil {
t.Fatal("checkProtoGeneratedFreshnessInSandbox() error = nil, want generator failure")
}
if !strings.Contains(err.Error(), "make proto failed") {
t.Fatalf("error = %q, want make proto context", err)
}
if !strings.Contains(result.output, "generator failed") {
t.Fatalf("output = %q, want generator stderr", result.output)
}
assertProtoTestSourceAndSandboxesClean(t, repoRoot, tempParent)
}
func TestCheckProtoGeneratedFreshnessConcurrentProcesses(t *testing.T) {
repoRoot := newProtoFreshnessTestRepo(t, "@mkdir generation-exclusive; sleep 0.05; printf 'generated in %s\\n' \"$$PWD\" > packages/api/src/example.ts")
tempParent := t.TempDir()
testBinary, err := os.Executable()
if err != nil {
t.Fatalf("resolve test executable: %v", err)
}
const processCount = 6
type processResult struct {
index int
output string
err error
}
results := make(chan processResult, processCount)
var workers sync.WaitGroup
for index := range processCount {
workers.Go(func() {
cmd := exec.CommandContext(t.Context(), testBinary, "-test.run=^TestProtoFreshnessHelperProcess$")
cmd.Env = append(os.Environ(),
protoFreshnessHelperEnv+"=1",
"CHECK_SAFETY_PROTO_REPO="+repoRoot,
"CHECK_SAFETY_PROTO_TEMP_PARENT="+tempParent,
)
output, runErr := cmd.CombinedOutput()
results <- processResult{index: index, output: string(output), err: runErr}
})
}
workers.Wait()
close(results)
for result := range results {
if result.err != nil {
t.Errorf("checker process %d failed: %v\n%s", result.index, result.err, result.output)
}
}
assertProtoTestSourceAndSandboxesClean(t, repoRoot, tempParent)
}
func TestProtoFreshnessHelperProcess(t *testing.T) {
if os.Getenv(protoFreshnessHelperEnv) != "1" {
return
}
repoRoot := os.Getenv("CHECK_SAFETY_PROTO_REPO")
tempParent := os.Getenv("CHECK_SAFETY_PROTO_TEMP_PARENT")
result, err := checkProtoGeneratedFreshnessInSandbox(repoRoot, tempParent)
if err != nil {
t.Fatalf("checkProtoGeneratedFreshnessInSandbox() error = %v\n%s", err, result.output)
}
if !result.changed() {
t.Fatal("checkProtoGeneratedFreshnessInSandbox() changed = false, want true")
}
}
func newProtoFreshnessTestRepo(t *testing.T, recipe string) string {
t.Helper()
if _, err := exec.LookPath("make"); err != nil {
t.Skip("make is required for proto freshness integration coverage")
}
repoRoot := t.TempDir()
writeTestFile(t, filepath.Join(repoRoot, "Makefile"), ".PHONY: proto\nproto:\n\t"+recipe+"\n", 0o644)
writeTestFile(t, filepath.Join(repoRoot, "buf.gen.yaml"), "version: v2\n", 0o644)
writeTestFile(t, filepath.Join(repoRoot, "packages", "api", "src", "example.ts"), "source\n", 0o644)
return repoRoot
}
func assertProtoTestSourceAndSandboxesClean(t *testing.T, repoRoot, tempParent string) {
t.Helper()
content, err := os.ReadFile(filepath.Join(repoRoot, "packages", "api", "src", "example.ts"))
if err != nil {
t.Fatalf("read source generated file: %v", err)
}
if string(content) != "source\n" {
t.Fatalf("source generated file = %q, want unchanged", content)
}
entries, err := os.ReadDir(tempParent)
if err != nil {
t.Fatalf("read sandbox parent: %v", err)
}
if len(entries) != 0 {
t.Fatalf("sandbox parent contains residual entries: %v", entries)
}
}

View File

@ -833,48 +833,6 @@ func isExcludedServiceMethod(method string) bool {
return excludedServices[svcName]
}
func checkProtoGeneratedFreshness(repoRoot string) (protoFreshnessResult, error) {
result := protoFreshnessResult{}
if !samePath(repoRoot, blockNinjaRepoRoot()) {
return result, nil
}
if !fileExists(filepath.Join(repoRoot, "Makefile")) || !fileExists(filepath.Join(repoRoot, "buf.gen.yaml")) {
return result, nil
}
result.checked = true
before, err := protoGeneratedStatus(repoRoot)
if err != nil {
return result, err
}
result.beforeStatus = before
output, runErr := runCommand(repoRoot, "make", "proto")
result.output = output
after, statusErr := protoGeneratedStatus(repoRoot)
if statusErr != nil {
return result, statusErr
}
result.afterStatus = after
if runErr != nil {
return result, fmt.Errorf("make proto failed: %w", runErr)
}
return result, nil
}
func (r protoFreshnessResult) changed() bool {
return r.checked && r.beforeStatus != r.afterStatus
}
func protoGeneratedStatus(repoRoot string) (string, error) {
paths := []string{
"backend/internal/api",
"backend/internal/mcpserver/generated",
"packages/api/src",
}
args := []string{"status", "--porcelain=v1", "--untracked-files=all", "--"}
args = append(args, paths...)
return runCommand(repoRoot, "git", args...)
}

View File

@ -18,7 +18,7 @@ func TestRegistryOrder(t *testing.T) {
"1", "2", "2b", "2c", "2d", "2e", "2f", "3", "3b", "4",
"5", "6", "7", "8", "9", "10", "10b", "10disc", "11", "12",
"13", "14", "15", "16", "17", "18", "19", "20", "21", "22",
"28", "29", "30",
"28", "29", "30", "32",
}
ordered := make([]Check, len(registry))

View File

@ -1,2 +1,2 @@
check-safety FIXTURE_DIR
33 checks: 20 ok 13 skip -> OK
34 checks: 20 ok 14 skip -> OK

View File

@ -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
internal/service/handler.go:14 // TODO: add proper initialisation
33 checks: 14 ok 14 skip 5 fail -> FAIL
34 checks: 14 ok 15 skip 5 fail -> FAIL

View File

@ -68,10 +68,7 @@ func checkToolbarInjection(root string) []toolbarViolation {
}
bodyStart := fset.Position(fn.Body.Pos()).Offset
bodyEnd := fset.Position(fn.Body.End()).Offset
if bodyEnd > len(srcText) {
bodyEnd = len(srcText)
}
bodyEnd := min(fset.Position(fn.Body.End()).Offset, len(srcText))
body := srcText[bodyStart:bodyEnd]
hasSiteSettings := strings.Contains(body, "getSiteSettings")

64
workspace_test.go Normal file
View File

@ -0,0 +1,64 @@
package main
import (
"os"
"path/filepath"
"slices"
"testing"
)
func TestRepoRootsFollowConsolidatedWorkspaceContainingCWD(t *testing.T) {
workspace := scaffoldConsolidatedWorkspace(t)
t.Chdir(filepath.Join(workspace, "check-safety"))
if got, want := blockNinjaRepoRoot(), filepath.Join(workspace, "cms"); got != want {
t.Fatalf("blockNinjaRepoRoot() = %q, want %q", got, want)
}
if got, want := orchestratorRepoRoot(), filepath.Join(workspace, "orchestrator"); got != want {
t.Fatalf("orchestratorRepoRoot() = %q, want %q", got, want)
}
orchestratorBackend := filepath.Join(workspace, "orchestrator", "backend")
if got := inferAllowedPackagePrefixes(filepath.Join(workspace, "orchestrator"), orchestratorBackend); !slices.Equal(got, []string{"orchestrator."}) {
t.Fatalf("orchestrator prefixes = %v, want [orchestrator.]", got)
}
}
func TestSamePathRecognizesFilesystemAliases(t *testing.T) {
realDir := filepath.Join(t.TempDir(), "real")
if err := os.Mkdir(realDir, 0755); err != nil {
t.Fatalf("mkdir real directory: %v", err)
}
aliasDir := filepath.Join(t.TempDir(), "alias")
if err := os.Symlink(realDir, aliasDir); err != nil {
t.Fatalf("symlink alias: %v", err)
}
if !samePath(realDir, aliasDir) {
t.Fatalf("samePath(%q, %q) = false, want true", realDir, aliasDir)
}
}
func scaffoldConsolidatedWorkspace(t *testing.T) string {
t.Helper()
workspace := t.TempDir()
for _, dir := range []string{
filepath.Join(workspace, "cms", "backend"),
filepath.Join(workspace, "orchestrator", "backend"),
filepath.Join(workspace, "check-safety"),
} {
if err := os.MkdirAll(dir, 0755); err != nil {
t.Fatalf("mkdir %s: %v", dir, err)
}
}
for _, path := range []string{
filepath.Join(workspace, "cms", "backend", "go.mod"),
filepath.Join(workspace, "orchestrator", "backend", "go.mod"),
filepath.Join(workspace, "check-safety", "go.mod"),
} {
if err := os.WriteFile(path, []byte("module example.com/test\n"), 0644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
return workspace
}