Compare commits
7 Commits
6fb2519cfd
...
ce81cce76c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce81cce76c | ||
|
|
68e9fa6f6c | ||
|
|
5aca8722c8 | ||
|
|
d970b6da3d | ||
|
|
a159bddf0b | ||
|
|
30be5aaca8 | ||
|
|
08da6a4723 |
@ -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]`.
|
||||
|
||||
|
||||
19
README.md
19
README.md
@ -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
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
36
docs/adr/0001-location-independent-workspace-discovery.md
Normal file
36
docs/adr/0001-location-independent-workspace-discovery.md
Normal 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
|
||||
44
docs/adr/0002-scope-non-proto-fetch-allowances.md
Normal file
44
docs/adr/0002-scope-non-proto-fetch-allowances.md
Normal 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
|
||||
49
docs/adr/0003-isolate-proto-freshness-generation.md
Normal file
49
docs/adr/0003-isolate-proto-freshness-generation.md
Normal 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
|
||||
15
docs/adr/0004-ignore-generated-file-permissions.md
Normal file
15
docs/adr/0004-ignore-generated-file-permissions.md
Normal 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
|
||||
136
frontend.go
136
frontend.go
@ -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
|
||||
|
||||
153
frontend_test.go
153
frontend_test.go
@ -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")
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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 {
|
||||
|
||||
2
main.go
2
main.go
@ -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 (
|
||||
|
||||
72
pathutil.go
72
pathutil.go
@ -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 {
|
||||
|
||||
@ -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
311
proto_freshness.go
Normal 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
192
proto_freshness_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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...)
|
||||
}
|
||||
|
||||
@ -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))
|
||||
|
||||
2
testdata/golden/clean/expected.stdout
vendored
2
testdata/golden/clean/expected.stdout
vendored
@ -1,2 +1,2 @@
|
||||
check-safety FIXTURE_DIR
|
||||
33 checks: 20 ok 13 skip -> OK
|
||||
34 checks: 20 ok 14 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
|
||||
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
|
||||
|
||||
@ -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
64
workspace_test.go
Normal 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
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user