fix: discover active BlockNinja workspace
This commit is contained in:
parent
08da6a4723
commit
30be5aaca8
@ -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
|
||||
|
||||
|
||||
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
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
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