From e8ca0d856f3db1c3822b6bbac8d0d021974de162 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 22 Mar 2026 07:19:20 +0000 Subject: [PATCH] feat(lib): embed Core documentation in workspace template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 doc files from core/go/docs — getting-started, primitives, services, commands, configuration, errors, lifecycle, messaging, subsystems, testing. Agents can read full Core documentation without network access. Co-Authored-By: Virgil --- .../default/.core/reference/docs/commands.md | 177 ++ .../.core/reference/docs/configuration.md | 96 + .../default/.core/reference/docs/errors.md | 120 ++ .../.core/reference/docs/getting-started.md | 208 ++ .../default/.core/reference/docs/index.md | 112 ++ .../default/.core/reference/docs/lifecycle.md | 111 ++ .../default/.core/reference/docs/messaging.md | 171 ++ .../reference/docs/pkg/PACKAGE_STANDARDS.md | 138 ++ .../default/.core/reference/docs/pkg/core.md | 81 + .../default/.core/reference/docs/pkg/log.md | 83 + .../2026-03-09-lint-pattern-catalog-design.md | 261 +++ .../2026-03-09-lint-pattern-catalog-plan.md | 1668 +++++++++++++++++ .../2026-03-12-altum-update-checker-design.md | 160 ++ .../2026-03-12-altum-update-checker-plan.md | 799 ++++++++ .../.core/reference/docs/primitives.md | 169 ++ .../default/.core/reference/docs/services.md | 152 ++ .../.core/reference/docs/subsystems.md | 158 ++ .../default/.core/reference/docs/testing.md | 118 ++ 18 files changed, 4782 insertions(+) create mode 100644 pkg/lib/workspace/default/.core/reference/docs/commands.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/configuration.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/errors.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/getting-started.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/index.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/lifecycle.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/messaging.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/pkg/PACKAGE_STANDARDS.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/pkg/core.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/pkg/log.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-09-lint-pattern-catalog-design.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-09-lint-pattern-catalog-plan.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-12-altum-update-checker-design.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-12-altum-update-checker-plan.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/primitives.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/services.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/subsystems.md create mode 100644 pkg/lib/workspace/default/.core/reference/docs/testing.md diff --git a/pkg/lib/workspace/default/.core/reference/docs/commands.md b/pkg/lib/workspace/default/.core/reference/docs/commands.md new file mode 100644 index 0000000..46e2022 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/commands.md @@ -0,0 +1,177 @@ +--- +title: Commands +description: Path-based command registration and CLI execution. +--- + +# Commands + +Commands are one of the most AX-native parts of CoreGO. The path is the identity. + +## Register a Command + +```go +c.Command("deploy/to/homelab", core.Command{ + Action: func(opts core.Options) core.Result { + target := opts.String("target") + return core.Result{Value: "deploying to " + target, OK: true} + }, +}) +``` + +## Command Paths + +Paths must be clean: + +- no empty path +- no leading slash +- no trailing slash +- no double slash + +These paths are valid: + +```text +deploy +deploy/to/homelab +workspace/create +``` + +These are rejected: + +```text +/deploy +deploy/ +deploy//to +``` + +## Parent Commands Are Auto-Created + +When you register `deploy/to/homelab`, CoreGO also creates placeholder parents if they do not already exist: + +- `deploy` +- `deploy/to` + +This makes the path tree navigable without extra setup. + +## Read a Command Back + +```go +r := c.Command("deploy/to/homelab") +if r.OK { + cmd := r.Value.(*core.Command) + _ = cmd +} +``` + +## Run a Command Directly + +```go +cmd := c.Command("deploy/to/homelab").Value.(*core.Command) + +r := cmd.Run(core.Options{ + {Key: "target", Value: "uk-prod"}, +}) +``` + +If `Action` is nil, `Run` returns `Result{OK:false}` with a structured error. + +## Run Through the CLI Surface + +```go +r := c.Cli().Run("deploy", "to", "homelab", "--target=uk-prod", "--debug") +``` + +`Cli.Run` resolves the longest matching command path from the arguments, then converts the remaining args into `core.Options`. + +## Flag Parsing Rules + +### Double Dash + +```text +--target=uk-prod -> key "target", value "uk-prod" +--debug -> key "debug", value true +``` + +### Single Dash + +```text +-v -> key "v", value true +-n=4 -> key "n", value "4" +``` + +### Positional Arguments + +Non-flag arguments after the command path are stored as repeated `_arg` options. + +```go +r := c.Cli().Run("workspace", "open", "alpha") +``` + +That produces an option like: + +```go +core.Option{Key: "_arg", Value: "alpha"} +``` + +### Important Details + +- flag values stay as strings +- `opts.Int("port")` only works if some code stored an actual `int` +- invalid flags such as `-verbose` and `--v` are ignored + +## Help Output + +`Cli.PrintHelp()` prints executable commands: + +```go +c.Cli().PrintHelp() +``` + +It skips: + +- hidden commands +- placeholder parents with no `Action` and no `Lifecycle` + +Descriptions are resolved through `cmd.I18nKey()`. + +## I18n Description Keys + +If `Description` is empty, CoreGO derives a key from the path. + +```text +deploy -> cmd.deploy.description +deploy/to/homelab -> cmd.deploy.to.homelab.description +workspace/create -> cmd.workspace.create.description +``` + +If `Description` is already set, CoreGO uses it as-is. + +## Lifecycle Commands + +Commands can also delegate to a lifecycle implementation. + +```go +type daemonCommand struct{} + +func (d *daemonCommand) Start(opts core.Options) core.Result { return core.Result{OK: true} } +func (d *daemonCommand) Stop() core.Result { return core.Result{OK: true} } +func (d *daemonCommand) Restart() core.Result { return core.Result{OK: true} } +func (d *daemonCommand) Reload() core.Result { return core.Result{OK: true} } +func (d *daemonCommand) Signal(sig string) core.Result { return core.Result{Value: sig, OK: true} } + +c.Command("agent/serve", core.Command{ + Lifecycle: &daemonCommand{}, +}) +``` + +Important behavior: + +- `Start` falls back to `Run` when `Lifecycle` is nil +- `Stop`, `Restart`, `Reload`, and `Signal` return an empty `Result` when `Lifecycle` is nil + +## List Command Paths + +```go +paths := c.Commands() +``` + +Like the service registry, the command registry is map-backed, so iteration order is not guaranteed. diff --git a/pkg/lib/workspace/default/.core/reference/docs/configuration.md b/pkg/lib/workspace/default/.core/reference/docs/configuration.md new file mode 100644 index 0000000..0a0cf11 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/configuration.md @@ -0,0 +1,96 @@ +--- +title: Configuration +description: Constructor options, runtime settings, and feature flags. +--- + +# Configuration + +CoreGO uses two different configuration layers: + +- constructor-time `core.Options` +- runtime `c.Config()` + +## Constructor-Time Options + +```go +c := core.New(core.Options{ + {Key: "name", Value: "agent-workbench"}, +}) +``` + +### Current Behavior + +- `New` accepts `opts ...Options` +- the current implementation copies only the first `Options` slice +- the `name` key is applied to `c.App().Name` + +If you need more constructor data, put it in the first `core.Options` slice. + +## Runtime Settings with `Config` + +Use `c.Config()` for mutable process settings. + +```go +c.Config().Set("workspace.root", "/srv/workspaces") +c.Config().Set("max_agents", 8) +c.Config().Set("debug", true) +``` + +Read them back with: + +```go +root := c.Config().String("workspace.root") +maxAgents := c.Config().Int("max_agents") +debug := c.Config().Bool("debug") +raw := c.Config().Get("workspace.root") +``` + +### Important Details + +- missing keys return zero values +- typed accessors do not coerce strings into ints or bools +- `Get` returns `core.Result` + +## Feature Flags + +`Config` also tracks named feature flags. + +```go +c.Config().Enable("workspace.templates") +c.Config().Enable("agent.review") +c.Config().Disable("agent.review") +``` + +Read them with: + +```go +enabled := c.Config().Enabled("workspace.templates") +features := c.Config().EnabledFeatures() +``` + +Feature names are case-sensitive. + +## `ConfigVar[T]` + +Use `ConfigVar[T]` when you need a typed value that can also represent “set versus unset”. + +```go +theme := core.NewConfigVar("amber") + +if theme.IsSet() { + fmt.Println(theme.Get()) +} + +theme.Unset() +``` + +This is useful for package-local state where zero values are not enough to describe configuration presence. + +## Recommended Pattern + +Use the two layers for different jobs: + +- put startup identity such as `name` into `core.Options` +- put mutable runtime values and feature switches into `c.Config()` + +That keeps constructor intent separate from live process state. diff --git a/pkg/lib/workspace/default/.core/reference/docs/errors.md b/pkg/lib/workspace/default/.core/reference/docs/errors.md new file mode 100644 index 0000000..9b7d3f3 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/errors.md @@ -0,0 +1,120 @@ +--- +title: Errors +description: Structured errors, logging helpers, and panic recovery. +--- + +# Errors + +CoreGO treats failures as structured operational data. + +Repository convention: use `E()` instead of `fmt.Errorf` for framework and service errors. + +## `Err` + +The structured error type is: + +```go +type Err struct { + Operation string + Message string + Cause error + Code string +} +``` + +## Create Errors + +### `E` + +```go +err := core.E("workspace.Load", "failed to read workspace manifest", cause) +``` + +### `Wrap` + +```go +err := core.Wrap(cause, "workspace.Load", "manifest parse failed") +``` + +### `WrapCode` + +```go +err := core.WrapCode(cause, "WORKSPACE_INVALID", "workspace.Load", "manifest parse failed") +``` + +### `NewCode` + +```go +err := core.NewCode("NOT_FOUND", "workspace not found") +``` + +## Inspect Errors + +```go +op := core.Operation(err) +code := core.ErrorCode(err) +msg := core.ErrorMessage(err) +root := core.Root(err) +stack := core.StackTrace(err) +pretty := core.FormatStackTrace(err) +``` + +These helpers keep the operational chain visible without extra type assertions. + +## Join and Standard Wrappers + +```go +combined := core.ErrorJoin(err1, err2) +same := core.Is(combined, err1) +``` + +`core.As` and `core.NewError` mirror the standard library for convenience. + +## Log-and-Return Helpers + +`Core` exposes two convenience wrappers: + +```go +r1 := c.LogError(err, "workspace.Load", "workspace load failed") +r2 := c.LogWarn(err, "workspace.Load", "workspace load degraded") +``` + +These log through the default logger and return `core.Result`. + +You can also use the underlying `ErrorLog` directly: + +```go +r := c.Log().Error(err, "workspace.Load", "workspace load failed") +``` + +`Must` logs and then panics when the error is non-nil: + +```go +c.Must(err, "workspace.Load", "workspace load failed") +``` + +## Panic Recovery + +`ErrorPanic` handles process-safe panic capture. + +```go +defer c.Error().Recover() +``` + +Run background work with recovery: + +```go +c.Error().SafeGo(func() { + panic("captured") +}) +``` + +If `ErrorPanic` has a configured crash file path, it appends JSON crash reports and `Reports(n)` reads them back. + +That crash file path is currently internal state on `ErrorPanic`, not a public constructor option on `Core.New()`. + +## Logging and Error Context + +The logging subsystem automatically extracts `op` and logical stack information from structured errors when those values are present in the key-value list. + +That makes errors created with `E`, `Wrap`, or `WrapCode` much easier to follow in logs. diff --git a/pkg/lib/workspace/default/.core/reference/docs/getting-started.md b/pkg/lib/workspace/default/.core/reference/docs/getting-started.md new file mode 100644 index 0000000..d2d8166 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/getting-started.md @@ -0,0 +1,208 @@ +--- +title: Getting Started +description: Build a first CoreGO application with the current API. +--- + +# Getting Started + +This page shows the shortest path to a useful CoreGO application using the API that exists in this repository today. + +## Install + +```bash +go get dappco.re/go/core +``` + +## Create a Core + +`New` takes zero or more `core.Options` slices, but the current implementation only reads the first one. In practice, treat the constructor as `core.New(core.Options{...})`. + +```go +package main + +import "dappco.re/go/core" + +func main() { + c := core.New(core.Options{ + {Key: "name", Value: "agent-workbench"}, + }) + + _ = c +} +``` + +The `name` option is copied into `c.App().Name`. + +## Register a Service + +Services are registered explicitly with a name and a `core.Service` DTO. + +```go +c.Service("audit", core.Service{ + OnStart: func() core.Result { + core.Info("audit service started", "app", c.App().Name) + return core.Result{OK: true} + }, + OnStop: func() core.Result { + core.Info("audit service stopped", "app", c.App().Name) + return core.Result{OK: true} + }, +}) +``` + +This registry stores `core.Service` values. It is a lifecycle registry, not a typed object container. + +## Register a Query, Task, and Command + +```go +type workspaceCountQuery struct{} + +type createWorkspaceTask struct { + Name string +} + +c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case workspaceCountQuery: + return core.Result{Value: 1, OK: true} + } + return core.Result{} +}) + +c.RegisterTask(func(_ *core.Core, t core.Task) core.Result { + switch task := t.(type) { + case createWorkspaceTask: + path := "/tmp/agent-workbench/" + task.Name + return core.Result{Value: path, OK: true} + } + return core.Result{} +}) + +c.Command("workspace/create", core.Command{ + Action: func(opts core.Options) core.Result { + return c.PERFORM(createWorkspaceTask{ + Name: opts.String("name"), + }) + }, +}) +``` + +## Start the Runtime + +```go +if !c.ServiceStartup(context.Background(), nil).OK { + panic("startup failed") +} +``` + +`ServiceStartup` returns `core.Result`, not `error`. + +## Run Through the CLI Surface + +```go +r := c.Cli().Run("workspace", "create", "--name=alpha") +if r.OK { + fmt.Println("created:", r.Value) +} +``` + +For flags with values, the CLI stores the value as a string. `--name=alpha` becomes `opts.String("name") == "alpha"`. + +## Query the System + +```go +count := c.QUERY(workspaceCountQuery{}) +if count.OK { + fmt.Println("workspace count:", count.Value) +} +``` + +## Shut Down Cleanly + +```go +_ = c.ServiceShutdown(context.Background()) +``` + +Shutdown cancels `c.Context()`, broadcasts `ActionServiceShutdown{}`, waits for background tasks to finish, and then runs service stop hooks. + +## Full Example + +```go +package main + +import ( + "context" + "fmt" + + "dappco.re/go/core" +) + +type workspaceCountQuery struct{} + +type createWorkspaceTask struct { + Name string +} + +func main() { + c := core.New(core.Options{ + {Key: "name", Value: "agent-workbench"}, + }) + + c.Config().Set("workspace.root", "/tmp/agent-workbench") + c.Config().Enable("workspace.templates") + + c.Service("audit", core.Service{ + OnStart: func() core.Result { + core.Info("service started", "service", "audit") + return core.Result{OK: true} + }, + OnStop: func() core.Result { + core.Info("service stopped", "service", "audit") + return core.Result{OK: true} + }, + }) + + c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case workspaceCountQuery: + return core.Result{Value: 1, OK: true} + } + return core.Result{} + }) + + c.RegisterTask(func(_ *core.Core, t core.Task) core.Result { + switch task := t.(type) { + case createWorkspaceTask: + path := c.Config().String("workspace.root") + "/" + task.Name + return core.Result{Value: path, OK: true} + } + return core.Result{} + }) + + c.Command("workspace/create", core.Command{ + Action: func(opts core.Options) core.Result { + return c.PERFORM(createWorkspaceTask{ + Name: opts.String("name"), + }) + }, + }) + + if !c.ServiceStartup(context.Background(), nil).OK { + panic("startup failed") + } + + created := c.Cli().Run("workspace", "create", "--name=alpha") + fmt.Println("created:", created.Value) + + count := c.QUERY(workspaceCountQuery{}) + fmt.Println("workspace count:", count.Value) + + _ = c.ServiceShutdown(context.Background()) +} +``` + +## Next Steps + +- Read [primitives.md](primitives.md) next so the repeated shapes are clear. +- Read [commands.md](commands.md) if you are building a CLI-first system. +- Read [messaging.md](messaging.md) if services need to collaborate without direct imports. diff --git a/pkg/lib/workspace/default/.core/reference/docs/index.md b/pkg/lib/workspace/default/.core/reference/docs/index.md new file mode 100644 index 0000000..0ec8647 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/index.md @@ -0,0 +1,112 @@ +--- +title: CoreGO +description: AX-first documentation for the CoreGO framework. +--- + +# CoreGO + +CoreGO is the foundation layer for the Core ecosystem. It gives you one container, one command tree, one message bus, and a small set of shared primitives that repeat across the whole framework. + +The current module path is `dappco.re/go/core`. + +## AX View + +CoreGO already follows the main AX ideas from RFC-025: + +- predictable names such as `Core`, `Service`, `Command`, `Options`, `Result`, `Message` +- path-shaped command registration such as `deploy/to/homelab` +- one repeated input shape (`Options`) and one repeated return shape (`Result`) +- comments and examples that show real usage instead of restating the type signature + +## What CoreGO Owns + +| Surface | Purpose | +|---------|---------| +| `Core` | Central container and access point | +| `Service` | Managed lifecycle component | +| `Command` | Path-based command tree node | +| `ACTION`, `QUERY`, `PERFORM` | Decoupled communication between components | +| `Data`, `Drive`, `Fs`, `Config`, `I18n`, `Cli` | Built-in subsystems for common runtime work | +| `E`, `Wrap`, `ErrorLog`, `ErrorPanic` | Structured failures and panic recovery | + +## Quick Example + +```go +package main + +import ( + "context" + "fmt" + + "dappco.re/go/core" +) + +type flushCacheTask struct { + Name string +} + +func main() { + c := core.New(core.Options{ + {Key: "name", Value: "agent-workbench"}, + }) + + c.Service("cache", core.Service{ + OnStart: func() core.Result { + core.Info("cache ready", "app", c.App().Name) + return core.Result{OK: true} + }, + OnStop: func() core.Result { + core.Info("cache stopped", "app", c.App().Name) + return core.Result{OK: true} + }, + }) + + c.RegisterTask(func(_ *core.Core, task core.Task) core.Result { + switch task.(type) { + case flushCacheTask: + return core.Result{Value: "cache flushed", OK: true} + } + return core.Result{} + }) + + c.Command("cache/flush", core.Command{ + Action: func(opts core.Options) core.Result { + return c.PERFORM(flushCacheTask{Name: opts.String("name")}) + }, + }) + + if !c.ServiceStartup(context.Background(), nil).OK { + panic("startup failed") + } + + r := c.Cli().Run("cache", "flush", "--name=session-store") + fmt.Println(r.Value) + + _ = c.ServiceShutdown(context.Background()) +} +``` + +## Documentation Paths + +| Path | Covers | +|------|--------| +| [getting-started.md](getting-started.md) | First runnable CoreGO app | +| [primitives.md](primitives.md) | `Options`, `Result`, `Service`, `Message`, `Query`, `Task` | +| [services.md](services.md) | Service registry, service locks, runtime helpers | +| [commands.md](commands.md) | Path-based commands and CLI execution | +| [messaging.md](messaging.md) | `ACTION`, `QUERY`, `QUERYALL`, `PERFORM`, `PerformAsync` | +| [lifecycle.md](lifecycle.md) | Startup, shutdown, context, background task draining | +| [configuration.md](configuration.md) | Constructor options, config state, feature flags | +| [subsystems.md](subsystems.md) | `App`, `Data`, `Drive`, `Fs`, `I18n`, `Cli` | +| [errors.md](errors.md) | Structured errors, logging helpers, panic recovery | +| [testing.md](testing.md) | Test naming and framework-level testing patterns | +| [pkg/core.md](pkg/core.md) | Package-level reference summary | +| [pkg/log.md](pkg/log.md) | Logging reference for the root package | +| [pkg/PACKAGE_STANDARDS.md](pkg/PACKAGE_STANDARDS.md) | AX package-authoring guidance | + +## Good Reading Order + +1. Start with [getting-started.md](getting-started.md). +2. Learn the repeated shapes in [primitives.md](primitives.md). +3. Pick the integration path you need next: [services.md](services.md), [commands.md](commands.md), or [messaging.md](messaging.md). +4. Use [subsystems.md](subsystems.md), [errors.md](errors.md), and [testing.md](testing.md) as reference pages while building. diff --git a/pkg/lib/workspace/default/.core/reference/docs/lifecycle.md b/pkg/lib/workspace/default/.core/reference/docs/lifecycle.md new file mode 100644 index 0000000..59ba644 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/lifecycle.md @@ -0,0 +1,111 @@ +--- +title: Lifecycle +description: Startup, shutdown, context ownership, and background task draining. +--- + +# Lifecycle + +CoreGO manages lifecycle through `core.Service` callbacks, not through reflection or implicit interfaces. + +## Service Hooks + +```go +c.Service("cache", core.Service{ + OnStart: func() core.Result { + return core.Result{OK: true} + }, + OnStop: func() core.Result { + return core.Result{OK: true} + }, +}) +``` + +Only services with `OnStart` appear in `Startables()`. Only services with `OnStop` appear in `Stoppables()`. + +## `ServiceStartup` + +```go +r := c.ServiceStartup(context.Background(), nil) +``` + +### What It Does + +1. clears the shutdown flag +2. stores a new cancellable context on `c.Context()` +3. runs each `OnStart` +4. broadcasts `ActionServiceStartup{}` + +### Failure Behavior + +- if the input context is already cancelled, startup returns that error +- if any `OnStart` returns `OK:false`, startup stops immediately and returns that result + +## `ServiceShutdown` + +```go +r := c.ServiceShutdown(context.Background()) +``` + +### What It Does + +1. sets the shutdown flag +2. cancels `c.Context()` +3. broadcasts `ActionServiceShutdown{}` +4. waits for background tasks created by `PerformAsync` +5. runs each `OnStop` + +### Failure Behavior + +- if draining background tasks hits the shutdown context deadline, shutdown returns that context error +- when service stop hooks fail, CoreGO returns the first error it sees + +## Ordering + +The current implementation builds `Startables()` and `Stoppables()` by iterating over a map-backed registry. + +That means lifecycle order is not guaranteed today. + +If your application needs strict startup or shutdown ordering, orchestrate it explicitly inside a smaller number of service callbacks instead of relying on registry order. + +## `c.Context()` + +`ServiceStartup` creates the context returned by `c.Context()`. + +Use it for background work that should stop when the application shuts down: + +```go +c.Service("watcher", core.Service{ + OnStart: func() core.Result { + go func(ctx context.Context) { + <-ctx.Done() + }(c.Context()) + return core.Result{OK: true} + }, +}) +``` + +## Built-In Lifecycle Actions + +You can listen for lifecycle state changes through the action bus. + +```go +c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + switch msg.(type) { + case core.ActionServiceStartup: + core.Info("core startup completed") + case core.ActionServiceShutdown: + core.Info("core shutdown started") + } + return core.Result{OK: true} +}) +``` + +## Background Task Draining + +`ServiceShutdown` waits for the internal task waitgroup to finish before calling stop hooks. + +This is what makes `PerformAsync` safe for long-running work that should complete before teardown. + +## `OnReload` + +`Service` includes an `OnReload` callback field, but CoreGO does not currently expose a top-level lifecycle runner for reload operations. diff --git a/pkg/lib/workspace/default/.core/reference/docs/messaging.md b/pkg/lib/workspace/default/.core/reference/docs/messaging.md new file mode 100644 index 0000000..688893a --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/messaging.md @@ -0,0 +1,171 @@ +--- +title: Messaging +description: ACTION, QUERY, QUERYALL, PERFORM, and async task flow. +--- + +# Messaging + +CoreGO uses one message bus for broadcasts, lookups, and work dispatch. + +## Message Types + +```go +type Message any +type Query any +type Task any +``` + +Your own structs define the protocol. + +```go +type repositoryIndexed struct { + Name string +} + +type repositoryCountQuery struct{} + +type syncRepositoryTask struct { + Name string +} +``` + +## `ACTION` + +`ACTION` is a broadcast. + +```go +c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + switch m := msg.(type) { + case repositoryIndexed: + core.Info("repository indexed", "name", m.Name) + return core.Result{OK: true} + } + return core.Result{OK: true} +}) + +r := c.ACTION(repositoryIndexed{Name: "core-go"}) +``` + +### Behavior + +- all registered action handlers are called in their current registration order +- if a handler returns `OK:false`, dispatch stops and that `Result` is returned +- if no handler fails, `ACTION` returns `Result{OK:true}` + +## `QUERY` + +`QUERY` is first-match request-response. + +```go +c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + switch q.(type) { + case repositoryCountQuery: + return core.Result{Value: 42, OK: true} + } + return core.Result{} +}) + +r := c.QUERY(repositoryCountQuery{}) +``` + +### Behavior + +- handlers run until one returns `OK:true` +- the first successful result wins +- if nothing handles the query, CoreGO returns an empty `Result` + +## `QUERYALL` + +`QUERYALL` collects every successful non-nil response. + +```go +r := c.QUERYALL(repositoryCountQuery{}) +results := r.Value.([]any) +``` + +### Behavior + +- every query handler is called +- only `OK:true` results with non-nil `Value` are collected +- the call itself returns `OK:true` even when the result list is empty + +## `PERFORM` + +`PERFORM` dispatches a task to the first handler that accepts it. + +```go +c.RegisterTask(func(_ *core.Core, t core.Task) core.Result { + switch task := t.(type) { + case syncRepositoryTask: + return core.Result{Value: "synced " + task.Name, OK: true} + } + return core.Result{} +}) + +r := c.PERFORM(syncRepositoryTask{Name: "core-go"}) +``` + +### Behavior + +- handlers run until one returns `OK:true` +- the first successful result wins +- if nothing handles the task, CoreGO returns an empty `Result` + +## `PerformAsync` + +`PerformAsync` runs a task in a background goroutine and returns a generated task identifier. + +```go +r := c.PerformAsync(syncRepositoryTask{Name: "core-go"}) +taskID := r.Value.(string) +``` + +### Generated Events + +Async execution emits three action messages: + +| Message | When | +|---------|------| +| `ActionTaskStarted` | just before background execution begins | +| `ActionTaskProgress` | whenever `Progress` is called | +| `ActionTaskCompleted` | after the task finishes or panics | + +Example listener: + +```go +c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + switch m := msg.(type) { + case core.ActionTaskCompleted: + core.Info("task completed", "task", m.TaskIdentifier, "err", m.Error) + } + return core.Result{OK: true} +}) +``` + +## Progress Updates + +```go +c.Progress(taskID, 0.5, "indexing commits", syncRepositoryTask{Name: "core-go"}) +``` + +That broadcasts `ActionTaskProgress`. + +## `TaskWithIdentifier` + +Tasks that implement `TaskWithIdentifier` receive the generated ID before dispatch. + +```go +type trackedTask struct { + ID string + Name string +} + +func (t *trackedTask) SetTaskIdentifier(id string) { t.ID = id } +func (t *trackedTask) GetTaskIdentifier() string { return t.ID } +``` + +## Shutdown Interaction + +When shutdown has started, `PerformAsync` returns an empty `Result` instead of scheduling more work. + +This is why `ServiceShutdown` can safely drain the outstanding background tasks before stopping services. diff --git a/pkg/lib/workspace/default/.core/reference/docs/pkg/PACKAGE_STANDARDS.md b/pkg/lib/workspace/default/.core/reference/docs/pkg/PACKAGE_STANDARDS.md new file mode 100644 index 0000000..398bbf6 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/pkg/PACKAGE_STANDARDS.md @@ -0,0 +1,138 @@ +# AX Package Standards + +This page describes how to build packages on top of CoreGO in the style described by RFC-025. + +## 1. Prefer Predictable Names + +Use names that tell an agent what the thing is without translation. + +Good: + +- `RepositoryService` +- `RepositoryServiceOptions` +- `WorkspaceCountQuery` +- `SyncRepositoryTask` + +Avoid shortening names unless the abbreviation is already universal. + +## 2. Put Real Usage in Comments + +Write comments that show a real call with realistic values. + +Good: + +```go +// Sync a repository into the local workspace cache. +// svc.SyncRepository("core-go", "/srv/repos/core-go") +``` + +Avoid comments that only repeat the signature. + +## 3. Keep Paths Semantic + +If a command or template lives at a path, let the path explain the intent. + +Good: + +```text +deploy/to/homelab +workspace/create +template/workspace/go +``` + +That keeps the CLI, tests, docs, and message vocabulary aligned. + +## 4. Reuse CoreGO Primitives + +At Core boundaries, prefer the shared shapes: + +- `core.Options` for lightweight input +- `core.Result` for output +- `core.Service` for lifecycle registration +- `core.Message`, `core.Query`, `core.Task` for bus protocols + +Inside your package, typed structs are still good. Use `ServiceRuntime[T]` when you want typed package options plus a `Core` reference. + +```go +type repositoryServiceOptions struct { + BaseDirectory string +} + +type repositoryService struct { + *core.ServiceRuntime[repositoryServiceOptions] +} +``` + +## 5. Prefer Explicit Registration + +Register services and commands with names and paths that stay readable in grep results. + +```go +c.Service("repository", core.Service{...}) +c.Command("repository/sync", core.Command{...}) +``` + +## 6. Use the Bus for Decoupling + +When one package needs another package’s behavior, prefer queries and tasks over tight package coupling. + +```go +type repositoryCountQuery struct{} +type syncRepositoryTask struct { + Name string +} +``` + +That keeps the protocol visible in code and easy for agents to follow. + +## 7. Use Structured Errors + +Use `core.E`, `core.Wrap`, and `core.WrapCode`. + +```go +return core.Result{ + Value: core.E("repository.Sync", "git fetch failed", err), + OK: false, +} +``` + +Do not introduce free-form `fmt.Errorf` chains in framework code. + +## 8. Keep Testing Names Predictable + +Follow the repository pattern: + +- `_Good` +- `_Bad` +- `_Ugly` + +Example: + +```go +func TestRepositorySync_Good(t *testing.T) {} +func TestRepositorySync_Bad(t *testing.T) {} +func TestRepositorySync_Ugly(t *testing.T) {} +``` + +## 9. Prefer Stable Shapes Over Clever APIs + +For package APIs, avoid patterns that force an agent to infer too much hidden control flow. + +Prefer: + +- clear structs +- explicit names +- path-based commands +- visible message types + +Avoid: + +- implicit global state unless it is truly a default service +- panic-hiding constructors +- dense option chains when a small explicit struct would do + +## 10. Document the Current Reality + +If the implementation is in transition, document what the code does now, not the API shape you plan to have later. + +That keeps agents correct on first pass, which is the real AX metric. diff --git a/pkg/lib/workspace/default/.core/reference/docs/pkg/core.md b/pkg/lib/workspace/default/.core/reference/docs/pkg/core.md new file mode 100644 index 0000000..88bd18b --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/pkg/core.md @@ -0,0 +1,81 @@ +# Package Reference: `core` + +Import path: + +```go +import "dappco.re/go/core" +``` + +This repository exposes one root package. The main areas are: + +## Constructors and Accessors + +| Name | Purpose | +|------|---------| +| `New` | Create a `*Core` | +| `NewRuntime` | Create an empty runtime wrapper | +| `NewWithFactories` | Create a runtime wrapper from named service factories | +| `Options`, `App`, `Data`, `Drive`, `Fs`, `Config`, `Error`, `Log`, `Cli`, `IPC`, `I18n`, `Context` | Access the built-in subsystems | + +## Core Primitives + +| Name | Purpose | +|------|---------| +| `Option`, `Options` | Input configuration and metadata | +| `Result` | Shared output shape | +| `Service` | Lifecycle DTO | +| `Command` | Command tree node | +| `Message`, `Query`, `Task` | Message bus payload types | + +## Service and Runtime APIs + +| Name | Purpose | +|------|---------| +| `Service` | Register or read a named service | +| `Services` | List registered service names | +| `Startables`, `Stoppables` | Snapshot lifecycle-capable services | +| `LockEnable`, `LockApply` | Activate the service registry lock | +| `ServiceRuntime[T]` | Helper for package authors | + +## Command and CLI APIs + +| Name | Purpose | +|------|---------| +| `Command` | Register or read a command by path | +| `Commands` | List command paths | +| `Cli().Run` | Resolve arguments to a command and execute it | +| `Cli().PrintHelp` | Show executable commands | + +## Messaging APIs + +| Name | Purpose | +|------|---------| +| `ACTION`, `Action` | Broadcast a message | +| `QUERY`, `Query` | Return the first successful query result | +| `QUERYALL`, `QueryAll` | Collect all successful query results | +| `PERFORM`, `Perform` | Run the first task handler that accepts the task | +| `PerformAsync` | Run a task in the background | +| `Progress` | Broadcast async task progress | +| `RegisterAction`, `RegisterActions`, `RegisterQuery`, `RegisterTask` | Register bus handlers | + +## Subsystems + +| Name | Purpose | +|------|---------| +| `Config` | Runtime settings and feature flags | +| `Data` | Embedded filesystem mounts | +| `Drive` | Named transport handles | +| `Fs` | Local filesystem operations | +| `I18n` | Locale collection and translation delegation | +| `App`, `Find` | Application identity and executable lookup | + +## Errors and Logging + +| Name | Purpose | +|------|---------| +| `E`, `Wrap`, `WrapCode`, `NewCode` | Structured error creation | +| `Operation`, `ErrorCode`, `ErrorMessage`, `Root`, `StackTrace`, `FormatStackTrace` | Error inspection | +| `NewLog`, `Default`, `SetDefault`, `SetLevel`, `SetRedactKeys` | Logger creation and defaults | +| `LogErr`, `LogPanic`, `ErrorLog`, `ErrorPanic` | Error-aware logging and panic recovery | + +Use the top-level docs in `docs/` for task-oriented guidance, then use this page as a compact reference. diff --git a/pkg/lib/workspace/default/.core/reference/docs/pkg/log.md b/pkg/lib/workspace/default/.core/reference/docs/pkg/log.md new file mode 100644 index 0000000..15e9db1 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/pkg/log.md @@ -0,0 +1,83 @@ +# Logging Reference + +Logging lives in the root `core` package in this repository. There is no separate `pkg/log` import path here. + +## Create a Logger + +```go +logger := core.NewLog(core.LogOptions{ + Level: core.LevelInfo, +}) +``` + +## Levels + +| Level | Meaning | +|-------|---------| +| `LevelQuiet` | no output | +| `LevelError` | errors and security events | +| `LevelWarn` | warnings, errors, security events | +| `LevelInfo` | informational, warnings, errors, security events | +| `LevelDebug` | everything | + +## Log Methods + +```go +logger.Debug("workspace discovered", "path", "/srv/workspaces") +logger.Info("service started", "service", "audit") +logger.Warn("retrying fetch", "attempt", 2) +logger.Error("fetch failed", "err", err) +logger.Security("sandbox escape detected", "path", attemptedPath) +``` + +## Default Logger + +The package owns a default logger. + +```go +core.SetLevel(core.LevelDebug) +core.SetRedactKeys("token", "password") + +core.Info("service started", "service", "audit") +``` + +## Redaction + +Values for keys listed in `RedactKeys` are replaced with `[REDACTED]`. + +```go +logger.SetRedactKeys("token") +logger.Info("login", "user", "cladius", "token", "secret-value") +``` + +## Output and Rotation + +```go +logger := core.NewLog(core.LogOptions{ + Level: core.LevelInfo, + Output: os.Stderr, +}) +``` + +If you provide `Rotation` and set `RotationWriterFactory`, the logger writes to the rotating writer instead of the plain output stream. + +## Error-Aware Logging + +`LogErr` extracts structured error context before logging: + +```go +le := core.NewLogErr(logger) +le.Log(err) +``` + +`ErrorLog` is the log-and-return wrapper exposed through `c.Log()`. + +## Panic-Aware Logging + +`LogPanic` is the lightweight panic logger: + +```go +defer core.NewLogPanic(logger).Recover() +``` + +It logs the recovered panic but does not manage crash files. For crash reports, use `c.Error().Recover()`. diff --git a/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-09-lint-pattern-catalog-design.md b/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-09-lint-pattern-catalog-design.md new file mode 100644 index 0000000..0825791 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-09-lint-pattern-catalog-design.md @@ -0,0 +1,261 @@ +# Lint Pattern Catalog & Polish Skill Design + +> **Partial implementation (14 Mar 2026):** Layer 1 (`core/lint` -- catalog, matcher, scanner, CLI) is fully implemented and documented at `docs/tools/lint/index.md`. Layer 2 (MCP subsystem in `go-ai`) and Layer 3 (Claude Code polish skill in `core/agent`) are NOT implemented. This plan is retained for those remaining layers. + +**Goal:** A structured pattern catalog (`core/lint`) that captures recurring code quality findings as regex rules, exposes them via MCP tools in `go-ai`, and orchestrates multi-AI code review via a Claude Code skill in `core/agent`. + +**Architecture:** Three layers — a standalone catalog+matcher library (`core/lint`), an MCP subsystem in `go-ai` that exposes lint tools to agents, and a Claude Code plugin in `core/agent` that orchestrates the "polish" workflow (deterministic checks + AI reviewers + feedback loop into the catalog). + +**Tech Stack:** Go (catalog, matcher, CLI, MCP subsystem), YAML (rule definitions), JSONL (findings output, compatible with `~/.core/ai/metrics/`), Claude Code plugin format (hooks.json, commands/*.md, plugin.json). + +--- + +## Context + +During a code review sweep of 18 Go repos (March 2026), AI reviewers (Gemini, Claude) found ~20 recurring patterns: SQL injection, path traversal, XSS, missing constant-time comparison, goroutine leaks, Go 1.26 modernisation opportunities, and more. Many of these patterns repeat across repos. + +Currently these findings exist only as commit messages. This design captures them as a reusable, machine-readable catalog that: +1. Deterministic tools can run immediately (regex matching) +2. MCP-connected agents can query and apply +3. LEM models can train on for "does this comply with CoreGo standards?" judgements +4. Grows automatically as AI reviewers find new patterns + +## Layer 1: `core/lint` — Pattern Catalog & Matcher + +### Repository Structure + +``` +core/lint/ +├── go.mod # forge.lthn.ai/core/lint +├── catalog/ +│ ├── go-security.yaml # SQL injection, path traversal, XSS, constant-time +│ ├── go-modernise.yaml # Go 1.26: slices.Clone, wg.Go, maps.Keys, range-over-int +│ ├── go-correctness.yaml # Deadlocks, goroutine leaks, nil guards, error handling +│ ├── php-security.yaml # XSS, CSRF, mass assignment, SQL injection +│ ├── ts-security.yaml # DOM XSS, prototype pollution +│ └── cpp-safety.yaml # Buffer overflow, use-after-free +├── pkg/lint/ +│ ├── catalog.go # Load + parse YAML catalog files +│ ├── rule.go # Rule struct definition +│ ├── matcher.go # Regex matcher against file contents +│ ├── report.go # Structured findings output (JSON/JSONL/text) +│ ├── catalog_test.go +│ ├── matcher_test.go +│ └── report_test.go +├── cmd/core-lint/ +│ └── main.go # `core-lint check ./...` CLI +└── .core/ + └── build.yaml # Produces core-lint binary +``` + +### Rule Schema (YAML) + +```yaml +- id: go-sec-001 + title: "SQL wildcard injection in LIKE clauses" + severity: high # critical, high, medium, low, info + languages: [go] + tags: [security, injection, owasp-a03] + pattern: 'LIKE\s+\?\s*,\s*["\x60]%\s*\+' + exclude_pattern: 'EscapeLike' # suppress if this also matches + fix: "Use parameterised LIKE with explicit escaping of % and _ characters" + found_in: [go-store] # repos where first discovered + example_bad: | + db.Where("name LIKE ?", "%"+input+"%") + example_good: | + db.Where("name LIKE ?", EscapeLike(input)) + first_seen: "2026-03-09" + detection: regex # future: ast, semantic + auto_fixable: false # future: true when we add codemods +``` + +### Rule Struct (Go) + +```go +type Rule struct { + ID string `yaml:"id"` + Title string `yaml:"title"` + Severity string `yaml:"severity"` + Languages []string `yaml:"languages"` + Tags []string `yaml:"tags"` + Pattern string `yaml:"pattern"` + ExcludePattern string `yaml:"exclude_pattern,omitempty"` + Fix string `yaml:"fix"` + FoundIn []string `yaml:"found_in,omitempty"` + ExampleBad string `yaml:"example_bad,omitempty"` + ExampleGood string `yaml:"example_good,omitempty"` + FirstSeen string `yaml:"first_seen"` + Detection string `yaml:"detection"` // regex | ast | semantic + AutoFixable bool `yaml:"auto_fixable"` +} +``` + +### Finding Struct (Go) + +Designed to align with go-ai's `ScanAlert` shape and `~/.core/ai/metrics/` JSONL format: + +```go +type Finding struct { + RuleID string `json:"rule_id"` + Title string `json:"title"` + Severity string `json:"severity"` + File string `json:"file"` + Line int `json:"line"` + Match string `json:"match"` // matched text + Fix string `json:"fix"` + Repo string `json:"repo,omitempty"` +} +``` + +### CLI Interface + +```bash +# Check current directory against all catalogs for detected languages +core-lint check ./... + +# Check specific languages/catalogs +core-lint check --lang go --catalog go-security ./pkg/... + +# Output as JSON (for piping to other tools) +core-lint check --format json ./... + +# List available rules +core-lint catalog list +core-lint catalog list --lang go --severity high + +# Show a specific rule with examples +core-lint catalog show go-sec-001 +``` + +## Layer 2: `go-ai` Lint MCP Subsystem + +New subsystem registered alongside files/rag/ml/brain: + +```go +type LintSubsystem struct { + catalog *lint.Catalog + root string // workspace root for scanning +} + +func (s *LintSubsystem) Name() string { return "lint" } + +func (s *LintSubsystem) RegisterTools(server *mcp.Server) { + // lint_check - run rules against workspace files + // lint_catalog - list/search available rules + // lint_report - get findings summary for a path +} +``` + +### MCP Tools + +| Tool | Input | Output | Group | +|------|-------|--------|-------| +| `lint_check` | `{path: string, lang?: string, severity?: string}` | `{findings: []Finding}` | lint | +| `lint_catalog` | `{lang?: string, tags?: []string, severity?: string}` | `{rules: []Rule}` | lint | +| `lint_report` | `{path: string, format?: "summary" or "detailed"}` | `{summary: ReportSummary}` | lint | + +This means any MCP-connected agent (Claude, LEM, Codex) can call `lint_check` to scan code against the catalog. + +## Layer 3: `core/agent` Polish Skill + +Claude Code plugin at `core/agent/claude/polish/`: + +``` +core/agent/claude/polish/ +├── plugin.json +├── hooks.json # optional: PostToolUse after git commit +├── commands/ +│ └── polish.md # /polish slash command +└── scripts/ + └── run-lint.sh # shells out to core-lint +``` + +### `/polish` Command Flow + +1. Run `core-lint check ./...` for fast deterministic findings +2. Report findings to user +3. Optionally run AI reviewers (Gemini CLI, Codex) for deeper analysis +4. Deduplicate AI findings against catalog (already-known patterns) +5. Propose new patterns as catalog additions (PR to core/lint) + +### Subagent Configuration (`.core/agents/`) + +Repos can configure polish behaviour: + +```yaml +# any-repo/.core/agents/polish.yaml +languages: [go] +catalogs: [go-security, go-modernise, go-correctness] +reviewers: [gemini] # which AI tools to invoke +exclude: [vendor/, testdata/, *_test.go] +severity_threshold: medium # only report medium+ findings +``` + +## Findings to LEM Pipeline + +``` +core-lint check -> findings.json + | + v +~/.core/ai/metrics/YYYY-MM-DD.jsonl (audit trail) + | + v +LEM training data: + - Rule examples (bad/good pairs) -> supervised training signal + - Finding frequency -> pattern importance weighting + - Rule descriptions -> natural language understanding of "why" + | + v +LEM tool: "does this code comply with CoreGo standards?" + -> queries lint_catalog via MCP + -> applies learned pattern recognition + -> reports violations with rule IDs and fixes +``` + +## Initial Catalog Seed + +From the March 2026 ecosystem sweep: + +| ID | Title | Severity | Language | Found In | +|----|-------|----------|----------|----------| +| go-sec-001 | SQL wildcard injection | high | go | go-store | +| go-sec-002 | Path traversal in cache keys | high | go | go-cache | +| go-sec-003 | XSS in HTML output | high | go | go-html | +| go-sec-004 | Non-constant-time auth comparison | high | go | go-crypt | +| go-sec-005 | Log injection via unescaped input | medium | go | go-log | +| go-sec-006 | Key material in log output | high | go | go-log | +| go-cor-001 | Goroutine leak (no WaitGroup) | high | go | core/go | +| go-cor-002 | Shutdown deadlock (wg.Wait no timeout) | high | go | core/go | +| go-cor-003 | Silent error swallowing | medium | go | go-process, go-ratelimit | +| go-cor-004 | Panic in library code | medium | go | go-i18n | +| go-cor-005 | Delete without path validation | high | go | go-io | +| go-mod-001 | Manual slice clone (append nil pattern) | low | go | core/go | +| go-mod-002 | Manual sort instead of slices.Sorted | low | go | core/go | +| go-mod-003 | Manual reverse loop instead of slices.Backward | low | go | core/go | +| go-mod-004 | sync.WaitGroup Add+Done instead of Go() | low | go | core/go | +| go-mod-005 | Manual map key collection instead of maps.Keys | low | go | core/go | +| go-cor-006 | Missing error return from API calls | medium | go | go-forge, go-git | +| go-cor-007 | Signal handler uses wrong type | medium | go | go-process | + +## Dependencies + +``` +core/lint (standalone, zero core deps) + ^ + | +go-ai/mcp/lint/ (imports core/lint for catalog + matcher) + ^ + | +core/agent/claude/polish/ (shells out to core-lint CLI) +``` + +`core/lint` has no dependency on `core/go` or any other framework module. It is a standalone library + CLI, like `core/go-io`. + +## Future Extensions (Not Built Now) + +- **AST-based detection** (layer 2): Parse Go/PHP AST, match structural patterns +- **Semantic detection** (layer 3): LEM judges code against rule descriptions +- **Auto-fix codemods**: `core-lint fix` applies known fixes automatically +- **CI integration**: GitHub Actions workflow runs `core-lint check` on PRs +- **CodeRabbit integration**: Import CodeRabbit findings as catalog entries +- **Cross-repo dashboard**: Aggregate findings across all repos in workspace diff --git a/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-09-lint-pattern-catalog-plan.md b/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-09-lint-pattern-catalog-plan.md new file mode 100644 index 0000000..7f1ddec --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-09-lint-pattern-catalog-plan.md @@ -0,0 +1,1668 @@ +# Lint Pattern Catalog Implementation Plan + +> **Fully implemented (14 Mar 2026).** All tasks in this plan are complete. The `core/lint` module ships 18 rules across 3 catalogs, with a working CLI and embedded YAML. This plan is retained alongside the design doc, which tracks the remaining MCP and polish skill layers. + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Build `core/lint` — a standalone Go library + CLI that loads YAML pattern catalogs and runs regex-based code checks, seeded with 18 patterns from the March 2026 ecosystem sweep. + +**Architecture:** Standalone Go module (`forge.lthn.ai/core/lint`) with zero framework deps. YAML catalog files define rules (id, severity, regex pattern, fix). `pkg/lint` loads catalogs and matches patterns against files. `cmd/core-lint` is a Cobra CLI. Uses `cli.Main()` + `cli.WithCommands()` from `core/cli`. + +**Tech Stack:** Go 1.26, `gopkg.in/yaml.v3` (YAML parsing), `forge.lthn.ai/core/cli` (CLI framework), `github.com/stretchr/testify` (testing), `embed` (catalog embedding). + +--- + +### Task 1: Create repo and Go module + +**Files:** +- Create: `/Users/snider/Code/core/lint/go.mod` +- Create: `/Users/snider/Code/core/lint/.core/build.yaml` +- Create: `/Users/snider/Code/core/lint/CLAUDE.md` + +**Step 1: Create repo on forge** + +```bash +ssh -p 2223 git@forge.lthn.ai +``` + +If SSH repo creation isn't available, create via Forgejo API: +```bash +curl -X POST "https://forge.lthn.ai/api/v1/orgs/core/repos" \ + -H "Authorization: token $FORGE_TOKEN" \ + -H "Content-Type: application/json" \ + -d '{"name":"lint","description":"Pattern catalog & regex matcher for code quality","auto_init":true,"default_branch":"main"}' +``` + +Or manually create on forge.lthn.ai web UI under the `core` org. + +**Step 2: Clone and initialise Go module** + +```bash +cd ~/Code/core +git clone ssh://git@forge.lthn.ai:2223/core/lint.git +cd lint +go mod init forge.lthn.ai/core/lint +``` + +Set Go version in go.mod: +``` +module forge.lthn.ai/core/lint + +go 1.26.0 +``` + +**Step 3: Create `.core/build.yaml`** + +```yaml +version: 1 + +project: + name: core-lint + description: Pattern catalog and regex code checker + main: ./cmd/core-lint + binary: core-lint + +build: + cgo: false + flags: + - -trimpath + ldflags: + - -s + - -w + +targets: + - os: linux + arch: amd64 + - os: linux + arch: arm64 + - os: darwin + arch: arm64 + - os: windows + arch: amd64 +``` + +**Step 4: Create `CLAUDE.md`** + +```markdown +# CLAUDE.md + +## Project Overview + +`core/lint` is a standalone pattern catalog and regex-based code checker. It loads YAML rule definitions and matches them against source files. Zero framework dependencies. + +## Build & Development + +```bash +core go test +core go qa +core build # produces ./bin/core-lint +``` + +## Architecture + +- `catalog/` — YAML rule files (embedded at compile time) +- `pkg/lint/` — Library: Rule, Catalog, Matcher, Report types +- `cmd/core-lint/` — CLI binary using `cli.Main()` + +## Rule Schema + +Each YAML file contains an array of rules with: id, title, severity, languages, tags, pattern (regex), exclude_pattern, fix, example_bad, example_good, detection type. + +## Coding Standards + +- UK English +- `declare(strict_types=1)` equivalent: all functions have typed params/returns +- Tests use testify +- License: EUPL-1.2 +``` + +**Step 5: Add to go.work** + +Add `./core/lint` to `~/Code/go.work` under the Core framework section. + +**Step 6: Commit** + +```bash +git add go.mod .core/ CLAUDE.md +git commit -m "feat: initialise core/lint module" +``` + +--- + +### Task 2: Rule struct and YAML parsing + +**Files:** +- Create: `/Users/snider/Code/core/lint/pkg/lint/rule.go` +- Create: `/Users/snider/Code/core/lint/pkg/lint/rule_test.go` + +**Step 1: Write the failing test** + +```go +package lint + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseRules(t *testing.T) { + yaml := ` +- id: test-001 + title: "Test rule" + severity: high + languages: [go] + tags: [security] + pattern: 'fmt\.Println' + fix: "Use structured logging" + detection: regex +` + rules, err := ParseRules([]byte(yaml)) + require.NoError(t, err) + require.Len(t, rules, 1) + assert.Equal(t, "test-001", rules[0].ID) + assert.Equal(t, "high", rules[0].Severity) + assert.Equal(t, []string{"go"}, rules[0].Languages) + assert.Equal(t, `fmt\.Println`, rules[0].Pattern) +} + +func TestParseRules_Invalid(t *testing.T) { + _, err := ParseRules([]byte("not: valid: yaml: [")) + assert.Error(t, err) +} + +func TestRule_Validate(t *testing.T) { + good := Rule{ID: "x-001", Title: "T", Severity: "high", Languages: []string{"go"}, Pattern: "foo", Detection: "regex"} + assert.NoError(t, good.Validate()) + + bad := Rule{} // missing required fields + assert.Error(t, bad.Validate()) +} + +func TestRule_Validate_BadRegex(t *testing.T) { + r := Rule{ID: "x-001", Title: "T", Severity: "high", Languages: []string{"go"}, Pattern: "[invalid", Detection: "regex"} + assert.Error(t, r.Validate()) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v` +Expected: FAIL — `ParseRules` and `Rule` not defined + +**Step 3: Write minimal implementation** + +```go +package lint + +import ( + "fmt" + "regexp" + + "gopkg.in/yaml.v3" +) + +// Rule defines a single lint pattern check. +type Rule struct { + ID string `yaml:"id" json:"id"` + Title string `yaml:"title" json:"title"` + Severity string `yaml:"severity" json:"severity"` + Languages []string `yaml:"languages" json:"languages"` + Tags []string `yaml:"tags" json:"tags"` + Pattern string `yaml:"pattern" json:"pattern"` + ExcludePattern string `yaml:"exclude_pattern" json:"exclude_pattern,omitempty"` + Fix string `yaml:"fix" json:"fix"` + FoundIn []string `yaml:"found_in" json:"found_in,omitempty"` + ExampleBad string `yaml:"example_bad" json:"example_bad,omitempty"` + ExampleGood string `yaml:"example_good" json:"example_good,omitempty"` + FirstSeen string `yaml:"first_seen" json:"first_seen,omitempty"` + Detection string `yaml:"detection" json:"detection"` + AutoFixable bool `yaml:"auto_fixable" json:"auto_fixable"` +} + +// Validate checks that a Rule has all required fields and a compilable regex pattern. +func (r *Rule) Validate() error { + if r.ID == "" { + return fmt.Errorf("rule missing id") + } + if r.Title == "" { + return fmt.Errorf("rule %s: missing title", r.ID) + } + if r.Severity == "" { + return fmt.Errorf("rule %s: missing severity", r.ID) + } + if len(r.Languages) == 0 { + return fmt.Errorf("rule %s: missing languages", r.ID) + } + if r.Pattern == "" { + return fmt.Errorf("rule %s: missing pattern", r.ID) + } + if r.Detection == "regex" { + if _, err := regexp.Compile(r.Pattern); err != nil { + return fmt.Errorf("rule %s: invalid regex: %w", r.ID, err) + } + } + return nil +} + +// ParseRules parses YAML bytes into a slice of Rules. +func ParseRules(data []byte) ([]Rule, error) { + var rules []Rule + if err := yaml.Unmarshal(data, &rules); err != nil { + return nil, fmt.Errorf("parse rules: %w", err) + } + return rules, nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v` +Expected: PASS (4 tests) + +**Step 5: Add yaml dependency** + +```bash +cd ~/Code/core/lint && go get gopkg.in/yaml.v3 && go get github.com/stretchr/testify +``` + +**Step 6: Commit** + +```bash +git add pkg/lint/rule.go pkg/lint/rule_test.go go.mod go.sum +git commit -m "feat: add Rule struct with YAML parsing and validation" +``` + +--- + +### Task 3: Catalog loader with embed support + +**Files:** +- Create: `/Users/snider/Code/core/lint/pkg/lint/catalog.go` +- Create: `/Users/snider/Code/core/lint/pkg/lint/catalog_test.go` +- Create: `/Users/snider/Code/core/lint/catalog/go-security.yaml` (minimal test file) + +**Step 1: Create a minimal test catalog file** + +Create `/Users/snider/Code/core/lint/catalog/go-security.yaml`: +```yaml +- id: go-sec-001 + title: "SQL wildcard injection in LIKE clauses" + severity: high + languages: [go] + tags: [security, injection] + pattern: 'LIKE\s+\?\s*,\s*["%].*\+' + fix: "Use parameterised LIKE with EscapeLike()" + found_in: [go-store] + first_seen: "2026-03-09" + detection: regex +``` + +**Step 2: Write the failing test** + +```go +package lint + +import ( + "embed" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCatalog_LoadDir(t *testing.T) { + // Find the catalog/ dir relative to the module root + dir := filepath.Join("..", "..", "catalog") + cat, err := LoadDir(dir) + require.NoError(t, err) + assert.Greater(t, len(cat.Rules), 0) + assert.Equal(t, "go-sec-001", cat.Rules[0].ID) +} + +func TestCatalog_LoadDir_NotExist(t *testing.T) { + _, err := LoadDir("/nonexistent") + assert.Error(t, err) +} + +func TestCatalog_Filter_Language(t *testing.T) { + cat := &Catalog{Rules: []Rule{ + {ID: "go-001", Languages: []string{"go"}, Severity: "high"}, + {ID: "php-001", Languages: []string{"php"}, Severity: "high"}, + }} + filtered := cat.ForLanguage("go") + assert.Len(t, filtered, 1) + assert.Equal(t, "go-001", filtered[0].ID) +} + +func TestCatalog_Filter_Severity(t *testing.T) { + cat := &Catalog{Rules: []Rule{ + {ID: "a", Severity: "high"}, + {ID: "b", Severity: "low"}, + {ID: "c", Severity: "medium"}, + }} + filtered := cat.AtSeverity("medium") + assert.Len(t, filtered, 2) // high + medium +} + +func TestCatalog_LoadFS(t *testing.T) { + // Write temp yaml + dir := t.TempDir() + data := []byte(`- id: fs-001 + title: "FS test" + severity: low + languages: [go] + tags: [] + pattern: 'test' + fix: "fix" + detection: regex +`) + require.NoError(t, os.WriteFile(filepath.Join(dir, "test.yaml"), data, 0644)) + + cat, err := LoadDir(dir) + require.NoError(t, err) + assert.Len(t, cat.Rules, 1) +} +``` + +**Step 3: Write minimal implementation** + +```go +package lint + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" +) + +// Catalog holds a collection of lint rules loaded from YAML files. +type Catalog struct { + Rules []Rule +} + +// severityOrder maps severity names to numeric priority (higher = more severe). +var severityOrder = map[string]int{ + "critical": 5, + "high": 4, + "medium": 3, + "low": 2, + "info": 1, +} + +// LoadDir loads all .yaml files from a directory path into a Catalog. +func LoadDir(dir string) (*Catalog, error) { + entries, err := os.ReadDir(dir) + if err != nil { + return nil, fmt.Errorf("load catalog dir: %w", err) + } + + cat := &Catalog{} + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".yaml") { + continue + } + data, err := os.ReadFile(filepath.Join(dir, entry.Name())) + if err != nil { + return nil, fmt.Errorf("read %s: %w", entry.Name(), err) + } + rules, err := ParseRules(data) + if err != nil { + return nil, fmt.Errorf("parse %s: %w", entry.Name(), err) + } + cat.Rules = append(cat.Rules, rules...) + } + return cat, nil +} + +// LoadFS loads all .yaml files from an embed.FS into a Catalog. +func LoadFS(fsys embed.FS, dir string) (*Catalog, error) { + cat := &Catalog{} + err := fs.WalkDir(fsys, dir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || !strings.HasSuffix(path, ".yaml") { + return nil + } + data, err := fsys.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + rules, err := ParseRules(data) + if err != nil { + return fmt.Errorf("parse %s: %w", path, err) + } + cat.Rules = append(cat.Rules, rules...) + return nil + }) + if err != nil { + return nil, err + } + return cat, nil +} + +// ForLanguage returns rules that apply to the given language. +func (c *Catalog) ForLanguage(lang string) []Rule { + var out []Rule + for _, r := range c.Rules { + if slices.Contains(r.Languages, lang) { + out = append(out, r) + } + } + return out +} + +// AtSeverity returns rules at or above the given severity threshold. +func (c *Catalog) AtSeverity(threshold string) []Rule { + minLevel := severityOrder[threshold] + var out []Rule + for _, r := range c.Rules { + if severityOrder[r.Severity] >= minLevel { + out = append(out, r) + } + } + return out +} + +// ByID returns a rule by its ID, or nil if not found. +func (c *Catalog) ByID(id string) *Rule { + for i := range c.Rules { + if c.Rules[i].ID == id { + return &c.Rules[i] + } + } + return nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v` +Expected: PASS (all tests) + +**Step 5: Commit** + +```bash +git add pkg/lint/catalog.go pkg/lint/catalog_test.go catalog/go-security.yaml +git commit -m "feat: add Catalog loader with dir/embed/filter support" +``` + +--- + +### Task 4: Regex matcher + +**Files:** +- Create: `/Users/snider/Code/core/lint/pkg/lint/matcher.go` +- Create: `/Users/snider/Code/core/lint/pkg/lint/matcher_test.go` + +**Step 1: Write the failing test** + +```go +package lint + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMatcher_Match(t *testing.T) { + rules := []Rule{ + { + ID: "test-001", + Title: "fmt.Println usage", + Severity: "low", + Languages: []string{"go"}, + Pattern: `fmt\.Println`, + Fix: "Use structured logging", + Detection: "regex", + }, + } + m, err := NewMatcher(rules) + require.NoError(t, err) + + content := `package main + +import "fmt" + +func main() { + fmt.Println("hello") +} +` + findings := m.Match("main.go", []byte(content)) + require.Len(t, findings, 1) + assert.Equal(t, "test-001", findings[0].RuleID) + assert.Equal(t, "main.go", findings[0].File) + assert.Equal(t, 6, findings[0].Line) + assert.Contains(t, findings[0].Match, "fmt.Println") +} + +func TestMatcher_ExcludePattern(t *testing.T) { + rules := []Rule{ + { + ID: "test-002", + Title: "Println with exclude", + Severity: "low", + Languages: []string{"go"}, + Pattern: `fmt\.Println`, + ExcludePattern: `// lint:ignore`, + Fix: "Use logging", + Detection: "regex", + }, + } + m, err := NewMatcher(rules) + require.NoError(t, err) + + content := `package main +func a() { fmt.Println("bad") } +func b() { fmt.Println("ok") // lint:ignore } +` + findings := m.Match("main.go", []byte(content)) + // Line 2 matches, line 3 is excluded + assert.Len(t, findings, 1) + assert.Equal(t, 2, findings[0].Line) +} + +func TestMatcher_NoMatch(t *testing.T) { + rules := []Rule{ + {ID: "test-003", Title: "T", Severity: "low", Languages: []string{"go"}, Pattern: `NEVER_MATCH_THIS`, Detection: "regex"}, + } + m, err := NewMatcher(rules) + require.NoError(t, err) + + findings := m.Match("main.go", []byte("package main\n")) + assert.Empty(t, findings) +} + +func TestMatcher_InvalidRegex(t *testing.T) { + rules := []Rule{ + {ID: "bad", Title: "T", Severity: "low", Languages: []string{"go"}, Pattern: `[invalid`, Detection: "regex"}, + } + _, err := NewMatcher(rules) + assert.Error(t, err) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v -run TestMatcher` +Expected: FAIL — `NewMatcher` not defined + +**Step 3: Write minimal implementation** + +```go +package lint + +import ( + "fmt" + "regexp" + "strings" +) + +// Finding represents a single match of a rule against source code. +type Finding struct { + RuleID string `json:"rule_id"` + Title string `json:"title"` + Severity string `json:"severity"` + File string `json:"file"` + Line int `json:"line"` + Match string `json:"match"` + Fix string `json:"fix"` + Repo string `json:"repo,omitempty"` +} + +// compiledRule is a rule with its regex pre-compiled. +type compiledRule struct { + rule Rule + pattern *regexp.Regexp + exclude *regexp.Regexp +} + +// Matcher runs compiled rules against file contents. +type Matcher struct { + rules []compiledRule +} + +// NewMatcher compiles all rule patterns and returns a Matcher. +func NewMatcher(rules []Rule) (*Matcher, error) { + compiled := make([]compiledRule, 0, len(rules)) + for _, r := range rules { + if r.Detection != "regex" { + continue // skip non-regex rules + } + p, err := regexp.Compile(r.Pattern) + if err != nil { + return nil, fmt.Errorf("rule %s: invalid pattern: %w", r.ID, err) + } + cr := compiledRule{rule: r, pattern: p} + if r.ExcludePattern != "" { + ex, err := regexp.Compile(r.ExcludePattern) + if err != nil { + return nil, fmt.Errorf("rule %s: invalid exclude_pattern: %w", r.ID, err) + } + cr.exclude = ex + } + compiled = append(compiled, cr) + } + return &Matcher{rules: compiled}, nil +} + +// Match checks file contents against all rules and returns findings. +func (m *Matcher) Match(filename string, content []byte) []Finding { + lines := strings.Split(string(content), "\n") + var findings []Finding + + for _, cr := range m.rules { + for i, line := range lines { + if !cr.pattern.MatchString(line) { + continue + } + if cr.exclude != nil && cr.exclude.MatchString(line) { + continue + } + findings = append(findings, Finding{ + RuleID: cr.rule.ID, + Title: cr.rule.Title, + Severity: cr.rule.Severity, + File: filename, + Line: i + 1, + Match: strings.TrimSpace(line), + Fix: cr.rule.Fix, + }) + } + } + return findings +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v -run TestMatcher` +Expected: PASS (4 tests) + +**Step 5: Commit** + +```bash +git add pkg/lint/matcher.go pkg/lint/matcher_test.go +git commit -m "feat: add regex Matcher with exclude pattern support" +``` + +--- + +### Task 5: Report output (JSON, text, JSONL) + +**Files:** +- Create: `/Users/snider/Code/core/lint/pkg/lint/report.go` +- Create: `/Users/snider/Code/core/lint/pkg/lint/report_test.go` + +**Step 1: Write the failing test** + +```go +package lint + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestReport_JSON(t *testing.T) { + findings := []Finding{ + {RuleID: "x-001", Title: "Test", Severity: "high", File: "a.go", Line: 10, Match: "bad code", Fix: "fix it"}, + } + var buf bytes.Buffer + require.NoError(t, WriteJSON(&buf, findings)) + + var parsed []Finding + require.NoError(t, json.Unmarshal(buf.Bytes(), &parsed)) + assert.Len(t, parsed, 1) + assert.Equal(t, "x-001", parsed[0].RuleID) +} + +func TestReport_JSONL(t *testing.T) { + findings := []Finding{ + {RuleID: "a-001", File: "a.go", Line: 1}, + {RuleID: "b-001", File: "b.go", Line: 2}, + } + var buf bytes.Buffer + require.NoError(t, WriteJSONL(&buf, findings)) + + lines := strings.Split(strings.TrimSpace(buf.String()), "\n") + assert.Len(t, lines, 2) +} + +func TestReport_Text(t *testing.T) { + findings := []Finding{ + {RuleID: "x-001", Title: "Test rule", Severity: "high", File: "main.go", Line: 42, Match: "bad()", Fix: "use good()"}, + } + var buf bytes.Buffer + WriteText(&buf, findings) + + out := buf.String() + assert.Contains(t, out, "main.go:42") + assert.Contains(t, out, "x-001") + assert.Contains(t, out, "high") +} + +func TestReport_Summary(t *testing.T) { + findings := []Finding{ + {Severity: "high"}, + {Severity: "high"}, + {Severity: "low"}, + } + s := Summarise(findings) + assert.Equal(t, 3, s.Total) + assert.Equal(t, 2, s.BySeverity["high"]) + assert.Equal(t, 1, s.BySeverity["low"]) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v -run TestReport` +Expected: FAIL — functions not defined + +**Step 3: Write minimal implementation** + +```go +package lint + +import ( + "encoding/json" + "fmt" + "io" +) + +// Summary holds aggregate stats about findings. +type Summary struct { + Total int `json:"total"` + BySeverity map[string]int `json:"by_severity"` +} + +// Summarise creates a Summary from a list of findings. +func Summarise(findings []Finding) Summary { + s := Summary{ + Total: len(findings), + BySeverity: make(map[string]int), + } + for _, f := range findings { + s.BySeverity[f.Severity]++ + } + return s +} + +// WriteJSON writes findings as a JSON array. +func WriteJSON(w io.Writer, findings []Finding) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + return enc.Encode(findings) +} + +// WriteJSONL writes findings as newline-delimited JSON (one object per line). +// Compatible with ~/.core/ai/metrics/ format. +func WriteJSONL(w io.Writer, findings []Finding) error { + enc := json.NewEncoder(w) + for _, f := range findings { + if err := enc.Encode(f); err != nil { + return err + } + } + return nil +} + +// WriteText writes findings as human-readable text. +func WriteText(w io.Writer, findings []Finding) { + for _, f := range findings { + fmt.Fprintf(w, "%s:%d [%s] %s (%s)\n", f.File, f.Line, f.Severity, f.Title, f.RuleID) + if f.Fix != "" { + fmt.Fprintf(w, " fix: %s\n", f.Fix) + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v -run TestReport` +Expected: PASS (4 tests) + +**Step 5: Commit** + +```bash +git add pkg/lint/report.go pkg/lint/report_test.go +git commit -m "feat: add report output (JSON, JSONL, text, summary)" +``` + +--- + +### Task 6: Scanner (walk files + match) + +**Files:** +- Create: `/Users/snider/Code/core/lint/pkg/lint/scanner.go` +- Create: `/Users/snider/Code/core/lint/pkg/lint/scanner_test.go` + +**Step 1: Write the failing test** + +```go +package lint + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestScanner_ScanDir(t *testing.T) { + // Set up temp dir with a .go file containing a known pattern + dir := t.TempDir() + goFile := filepath.Join(dir, "main.go") + require.NoError(t, os.WriteFile(goFile, []byte(`package main + +import "fmt" + +func main() { + fmt.Println("hello") +} +`), 0644)) + + rules := []Rule{ + {ID: "test-001", Title: "Println", Severity: "low", Languages: []string{"go"}, Pattern: `fmt\.Println`, Fix: "log", Detection: "regex"}, + } + + s, err := NewScanner(rules) + require.NoError(t, err) + + findings, err := s.ScanDir(dir) + require.NoError(t, err) + require.Len(t, findings, 1) + assert.Equal(t, "test-001", findings[0].RuleID) +} + +func TestScanner_ScanDir_ExcludesVendor(t *testing.T) { + dir := t.TempDir() + vendor := filepath.Join(dir, "vendor") + require.NoError(t, os.MkdirAll(vendor, 0755)) + require.NoError(t, os.WriteFile(filepath.Join(vendor, "lib.go"), []byte("package lib\nfunc x() { fmt.Println() }\n"), 0644)) + + rules := []Rule{ + {ID: "test-001", Title: "Println", Severity: "low", Languages: []string{"go"}, Pattern: `fmt\.Println`, Fix: "log", Detection: "regex"}, + } + + s, err := NewScanner(rules) + require.NoError(t, err) + + findings, err := s.ScanDir(dir) + require.NoError(t, err) + assert.Empty(t, findings) +} + +func TestScanner_LanguageDetection(t *testing.T) { + assert.Equal(t, "go", DetectLanguage("main.go")) + assert.Equal(t, "php", DetectLanguage("app.php")) + assert.Equal(t, "ts", DetectLanguage("index.ts")) + assert.Equal(t, "ts", DetectLanguage("index.tsx")) + assert.Equal(t, "cpp", DetectLanguage("engine.cpp")) + assert.Equal(t, "cpp", DetectLanguage("engine.cc")) + assert.Equal(t, "", DetectLanguage("README.md")) +} +``` + +**Step 2: Run test to verify it fails** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v -run TestScanner` +Expected: FAIL — `NewScanner` not defined + +**Step 3: Write minimal implementation** + +```go +package lint + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// defaultExcludes are directories skipped during scanning. +var defaultExcludes = []string{"vendor", "node_modules", ".git", "testdata", ".core"} + +// extToLang maps file extensions to language identifiers. +var extToLang = map[string]string{ + ".go": "go", + ".php": "php", + ".ts": "ts", + ".tsx": "ts", + ".js": "js", + ".jsx": "js", + ".cpp": "cpp", + ".cc": "cpp", + ".cxx": "cpp", + ".c": "cpp", + ".h": "cpp", + ".hpp": "cpp", +} + +// DetectLanguage returns the language identifier for a filename, or "" if unknown. +func DetectLanguage(filename string) string { + ext := filepath.Ext(filename) + return extToLang[ext] +} + +// Scanner walks directories and matches files against rules. +type Scanner struct { + matcher *Matcher + rules []Rule + excludes []string +} + +// NewScanner creates a Scanner from a set of rules. +func NewScanner(rules []Rule) (*Scanner, error) { + m, err := NewMatcher(rules) + if err != nil { + return nil, err + } + return &Scanner{ + matcher: m, + rules: rules, + excludes: defaultExcludes, + }, nil +} + +// ScanDir walks a directory tree and returns all findings. +func (s *Scanner) ScanDir(root string) ([]Finding, error) { + var all []Finding + + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + // Skip excluded directories + if d.IsDir() { + for _, ex := range s.excludes { + if d.Name() == ex { + return filepath.SkipDir + } + } + return nil + } + + // Only scan files with known language extensions + lang := DetectLanguage(path) + if lang == "" { + return nil + } + + content, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read %s: %w", path, err) + } + + // Make path relative to root for cleaner output + rel, err := filepath.Rel(root, path) + if err != nil { + rel = path + } + + findings := s.matcher.Match(rel, content) + all = append(all, findings...) + return nil + }) + + return all, err +} + +// ScanFile scans a single file and returns findings. +func (s *Scanner) ScanFile(path string) ([]Finding, error) { + content, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read %s: %w", path, err) + } + return s.matcher.Match(path, content), nil +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v -run TestScanner` +Expected: PASS (3 tests) + +**Step 5: Commit** + +```bash +git add pkg/lint/scanner.go pkg/lint/scanner_test.go +git commit -m "feat: add Scanner with directory walking and language detection" +``` + +--- + +### Task 7: Seed the catalog YAML files + +**Files:** +- Create: `/Users/snider/Code/core/lint/catalog/go-security.yaml` (expand from task 3) +- Create: `/Users/snider/Code/core/lint/catalog/go-correctness.yaml` +- Create: `/Users/snider/Code/core/lint/catalog/go-modernise.yaml` + +**Step 1: Write `catalog/go-security.yaml`** + +```yaml +- id: go-sec-001 + title: "SQL wildcard injection in LIKE clauses" + severity: high + languages: [go] + tags: [security, injection, owasp-a03] + pattern: 'LIKE\s+\?.*["%`]\s*\%.*\+' + exclude_pattern: 'EscapeLike' + fix: "Use parameterised LIKE with explicit escaping of % and _ characters" + found_in: [go-store] + example_bad: | + db.Where("name LIKE ?", "%"+input+"%") + example_good: | + db.Where("name LIKE ?", EscapeLike(input)) + first_seen: "2026-03-09" + detection: regex + +- id: go-sec-002 + title: "Path traversal in file/cache key operations" + severity: high + languages: [go] + tags: [security, path-traversal, owasp-a01] + pattern: 'filepath\.Join\(.*,\s*\w+\)' + exclude_pattern: 'filepath\.Clean|securejoin|ValidatePath' + fix: "Validate path components do not contain .. before joining" + found_in: [go-cache] + example_bad: | + path := filepath.Join(cacheDir, userInput) + example_good: | + if strings.Contains(key, "..") { return ErrInvalidKey } + path := filepath.Join(cacheDir, key) + first_seen: "2026-03-09" + detection: regex + +- id: go-sec-003 + title: "XSS via unescaped HTML output" + severity: high + languages: [go] + tags: [security, xss, owasp-a03] + pattern: 'fmt\.Sprintf\(.*<.*>.*%s' + exclude_pattern: 'html\.EscapeString|template\.HTMLEscapeString' + fix: "Use html.EscapeString() for user-supplied values in HTML output" + found_in: [go-html] + example_bad: | + out := fmt.Sprintf("
%s
", userInput) + example_good: | + out := fmt.Sprintf("
%s
", html.EscapeString(userInput)) + first_seen: "2026-03-09" + detection: regex + +- id: go-sec-004 + title: "Non-constant-time comparison for authentication" + severity: high + languages: [go] + tags: [security, timing-attack, owasp-a02] + pattern: '==\s*\w*(token|key|secret|password|hash|digest|hmac|mac|sig)' + exclude_pattern: 'subtle\.ConstantTimeCompare|hmac\.Equal' + fix: "Use crypto/subtle.ConstantTimeCompare for security-sensitive comparisons" + found_in: [go-crypt] + example_bad: | + if providedToken == storedToken { + example_good: | + if subtle.ConstantTimeCompare([]byte(provided), []byte(stored)) == 1 { + first_seen: "2026-03-09" + detection: regex + +- id: go-sec-005 + title: "Log injection via unescaped newlines" + severity: medium + languages: [go] + tags: [security, injection, logging] + pattern: 'log\.\w+\(.*\+.*\)' + exclude_pattern: 'strings\.ReplaceAll.*\\n|slog\.' + fix: "Use structured logging (slog) or sanitise newlines from user input" + found_in: [go-log] + example_bad: | + log.Printf("user login: " + username) + example_good: | + slog.Info("user login", "username", username) + first_seen: "2026-03-09" + detection: regex + +- id: go-sec-006 + title: "Sensitive key material in log output" + severity: high + languages: [go] + tags: [security, secrets, logging] + pattern: 'log\.\w+\(.*(?i)(password|secret|token|apikey|private.?key|credential)' + exclude_pattern: 'REDACTED|\*\*\*|redact' + fix: "Redact sensitive fields before logging" + found_in: [go-log] + example_bad: | + log.Printf("config: token=%s", cfg.Token) + example_good: | + log.Printf("config: token=%s", redact(cfg.Token)) + first_seen: "2026-03-09" + detection: regex +``` + +**Step 2: Write `catalog/go-correctness.yaml`** + +```yaml +- id: go-cor-001 + title: "Goroutine without WaitGroup or context" + severity: high + languages: [go] + tags: [correctness, goroutine-leak] + pattern: 'go\s+func\s*\(' + exclude_pattern: 'wg\.|\.Go\(|context\.|done\s*<-|select\s*\{' + fix: "Use sync.WaitGroup.Go() or ensure goroutine has a shutdown signal" + found_in: [core/go] + example_bad: | + go func() { doWork() }() + example_good: | + wg.Go(func() { doWork() }) + first_seen: "2026-03-09" + detection: regex + +- id: go-cor-002 + title: "WaitGroup.Wait without context/timeout" + severity: high + languages: [go] + tags: [correctness, deadlock] + pattern: '\.Wait\(\)' + exclude_pattern: 'select\s*\{|ctx\.Done|context\.With|time\.After' + fix: "Wrap wg.Wait() in a select with context.Done() or timeout" + found_in: [core/go] + example_bad: | + wg.Wait() // blocks forever if goroutine hangs + example_good: | + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-ctx.Done(): + } + first_seen: "2026-03-09" + detection: regex + +- id: go-cor-003 + title: "Silent error swallowing" + severity: medium + languages: [go] + tags: [correctness, error-handling] + pattern: '^\s*_\s*=\s*\w+\.\w+\(' + exclude_pattern: 'defer|Close\(|Flush\(' + fix: "Handle or propagate errors instead of discarding with _" + found_in: [go-process, go-ratelimit] + example_bad: | + _ = db.Save(record) + example_good: | + if err := db.Save(record); err != nil { + return fmt.Errorf("save record: %w", err) + } + first_seen: "2026-03-09" + detection: regex + +- id: go-cor-004 + title: "Panic in library code" + severity: medium + languages: [go] + tags: [correctness, panic] + pattern: '\bpanic\(' + exclude_pattern: '_test\.go|// unreachable|Must\w+\(' + fix: "Return errors instead of panicking in library code" + found_in: [go-i18n] + example_bad: | + func Parse(s string) *Node { panic("not implemented") } + example_good: | + func Parse(s string) (*Node, error) { return nil, fmt.Errorf("not implemented") } + first_seen: "2026-03-09" + detection: regex + +- id: go-cor-005 + title: "File deletion without path validation" + severity: high + languages: [go] + tags: [correctness, safety] + pattern: 'os\.Remove(All)?\(' + exclude_pattern: 'filepath\.Clean|ValidatePath|strings\.Contains.*\.\.' + fix: "Validate path does not escape base directory before deletion" + found_in: [go-io] + example_bad: | + os.RemoveAll(filepath.Join(base, userInput)) + example_good: | + clean := filepath.Clean(filepath.Join(base, userInput)) + if !strings.HasPrefix(clean, base) { return ErrPathTraversal } + os.RemoveAll(clean) + first_seen: "2026-03-09" + detection: regex + +- id: go-cor-006 + title: "Missing error return from API/network calls" + severity: medium + languages: [go] + tags: [correctness, error-handling] + pattern: 'resp,\s*_\s*:=.*\.(Get|Post|Do|Send)\(' + fix: "Check and handle HTTP/API errors" + found_in: [go-forge, go-git] + example_bad: | + resp, _ := client.Get(url) + example_good: | + resp, err := client.Get(url) + if err != nil { return fmt.Errorf("api call: %w", err) } + first_seen: "2026-03-09" + detection: regex + +- id: go-cor-007 + title: "Signal handler uses wrong type" + severity: medium + languages: [go] + tags: [correctness, signals] + pattern: 'syscall\.Signal\b' + exclude_pattern: 'os\.Signal' + fix: "Use os.Signal for portable signal handling" + found_in: [go-process] + example_bad: | + func Handle(sig syscall.Signal) { ... } + example_good: | + func Handle(sig os.Signal) { ... } + first_seen: "2026-03-09" + detection: regex +``` + +**Step 3: Write `catalog/go-modernise.yaml`** + +```yaml +- id: go-mod-001 + title: "Manual slice clone via append([]T(nil)...)" + severity: low + languages: [go] + tags: [modernise, go126] + pattern: 'append\(\[\]\w+\(nil\),\s*\w+\.\.\.\)' + fix: "Use slices.Clone() from Go 1.21+" + found_in: [core/go] + example_bad: | + copy := append([]string(nil), original...) + example_good: | + copy := slices.Clone(original) + first_seen: "2026-03-09" + detection: regex + +- id: go-mod-002 + title: "Manual sort of string/int slices" + severity: low + languages: [go] + tags: [modernise, go126] + pattern: 'sort\.Strings\(|sort\.Ints\(|sort\.Slice\(' + exclude_pattern: 'sort\.SliceStable' + fix: "Use slices.Sort() or slices.Sorted(iter) from Go 1.21+" + found_in: [core/go] + example_bad: | + sort.Strings(names) + example_good: | + slices.Sort(names) + first_seen: "2026-03-09" + detection: regex + +- id: go-mod-003 + title: "Manual reverse iteration loop" + severity: low + languages: [go] + tags: [modernise, go126] + pattern: 'for\s+\w+\s*:=\s*len\(\w+\)\s*-\s*1' + fix: "Use slices.Backward() from Go 1.23+" + found_in: [core/go] + example_bad: | + for i := len(items) - 1; i >= 0; i-- { use(items[i]) } + example_good: | + for _, item := range slices.Backward(items) { use(item) } + first_seen: "2026-03-09" + detection: regex + +- id: go-mod-004 + title: "WaitGroup Add+Done instead of Go()" + severity: low + languages: [go] + tags: [modernise, go126] + pattern: 'wg\.Add\(1\)' + fix: "Use sync.WaitGroup.Go() from Go 1.26" + found_in: [core/go] + example_bad: | + wg.Add(1) + go func() { defer wg.Done(); work() }() + example_good: | + wg.Go(func() { work() }) + first_seen: "2026-03-09" + detection: regex + +- id: go-mod-005 + title: "Manual map key collection" + severity: low + languages: [go] + tags: [modernise, go126] + pattern: 'for\s+\w+\s*:=\s*range\s+\w+\s*\{\s*\n\s*\w+\s*=\s*append' + exclude_pattern: 'maps\.Keys' + fix: "Use maps.Keys() or slices.Sorted(maps.Keys()) from Go 1.23+" + found_in: [core/go] + example_bad: | + var keys []string + for k := range m { keys = append(keys, k) } + example_good: | + keys := slices.Sorted(maps.Keys(m)) + first_seen: "2026-03-09" + detection: regex +``` + +**Step 4: Run all tests to verify catalog loads correctly** + +Run: `cd ~/Code/core/lint && go test ./pkg/lint/ -v` +Expected: PASS (all tests, including TestCatalog_LoadDir which reads the catalog/ dir) + +**Step 5: Commit** + +```bash +git add catalog/ +git commit -m "feat: seed catalog with 18 patterns from ecosystem sweep" +``` + +--- + +### Task 8: CLI binary with `cli.Main()` + +**Files:** +- Create: `/Users/snider/Code/core/lint/cmd/core-lint/main.go` +- Create: `/Users/snider/Code/core/lint/lint.go` (embed catalog + public API) + +**Step 1: Create the embed entry point** + +Create `/Users/snider/Code/core/lint/lint.go`: + +```go +package lint + +import ( + "embed" + + lintpkg "forge.lthn.ai/core/lint/pkg/lint" +) + +//go:embed catalog/*.yaml +var catalogFS embed.FS + +// LoadEmbeddedCatalog loads the built-in catalog from embedded YAML files. +func LoadEmbeddedCatalog() (*lintpkg.Catalog, error) { + return lintpkg.LoadFS(catalogFS, "catalog") +} +``` + +**Step 2: Create the CLI entry point** + +Create `/Users/snider/Code/core/lint/cmd/core-lint/main.go`: + +```go +package main + +import ( + "fmt" + "os" + + "forge.lthn.ai/core/cli/pkg/cli" + lint "forge.lthn.ai/core/lint" + lintpkg "forge.lthn.ai/core/lint/pkg/lint" +) + +func main() { + cli.Main( + cli.WithCommands("lint", addLintCommands), + ) +} + +func addLintCommands(root *cli.Command) { + lintCmd := &cli.Command{ + Use: "lint", + Short: "Pattern-based code checker", + } + root.AddCommand(lintCmd) + + // core-lint lint check [path...] + lintCmd.AddCommand(cli.NewCommand( + "check [path...]", + "Run pattern checks against source files", + "Scans files for known anti-patterns from the catalog", + func(cmd *cli.Command, args []string) error { + format, _ := cmd.Flags().GetString("format") + lang, _ := cmd.Flags().GetString("lang") + severity, _ := cmd.Flags().GetString("severity") + + cat, err := lint.LoadEmbeddedCatalog() + if err != nil { + return fmt.Errorf("load catalog: %w", err) + } + + rules := cat.Rules + if lang != "" { + rules = cat.ForLanguage(lang) + } + if severity != "" { + filtered := (&lintpkg.Catalog{Rules: rules}).AtSeverity(severity) + rules = filtered + } + + scanner, err := lintpkg.NewScanner(rules) + if err != nil { + return fmt.Errorf("create scanner: %w", err) + } + + paths := args + if len(paths) == 0 { + paths = []string{"."} + } + + var allFindings []lintpkg.Finding + for _, p := range paths { + findings, err := scanner.ScanDir(p) + if err != nil { + return fmt.Errorf("scan %s: %w", p, err) + } + allFindings = append(allFindings, findings...) + } + + switch format { + case "json": + return lintpkg.WriteJSON(os.Stdout, allFindings) + case "jsonl": + return lintpkg.WriteJSONL(os.Stdout, allFindings) + default: + lintpkg.WriteText(os.Stdout, allFindings) + } + + if len(allFindings) > 0 { + s := lintpkg.Summarise(allFindings) + fmt.Fprintf(os.Stderr, "\n%d findings", s.Total) + for sev, count := range s.BySeverity { + fmt.Fprintf(os.Stderr, " | %s: %d", sev, count) + } + fmt.Fprintln(os.Stderr) + } + return nil + }, + )) + + // Add flags to check command + checkCmd := lintCmd.Commands()[0] + checkCmd.Flags().StringP("format", "f", "text", "Output format: text, json, jsonl") + checkCmd.Flags().StringP("lang", "l", "", "Filter by language: go, php, ts, cpp") + checkCmd.Flags().StringP("severity", "s", "", "Minimum severity: critical, high, medium, low, info") + + // core-lint lint catalog + catalogCmd := &cli.Command{ + Use: "catalog", + Short: "Browse the pattern catalog", + } + lintCmd.AddCommand(catalogCmd) + + // core-lint lint catalog list + catalogCmd.AddCommand(cli.NewCommand( + "list", + "List available rules", + "", + func(cmd *cli.Command, args []string) error { + lang, _ := cmd.Flags().GetString("lang") + + cat, err := lint.LoadEmbeddedCatalog() + if err != nil { + return err + } + + rules := cat.Rules + if lang != "" { + rules = cat.ForLanguage(lang) + } + + for _, r := range rules { + fmt.Printf("%-12s [%s] %s\n", r.ID, r.Severity, r.Title) + } + fmt.Fprintf(os.Stderr, "\n%d rules\n", len(rules)) + return nil + }, + )) + catalogCmd.Commands()[0].Flags().StringP("lang", "l", "", "Filter by language") + + // core-lint lint catalog show + catalogCmd.AddCommand(cli.NewCommand( + "show [rule-id]", + "Show details for a specific rule", + "", + func(cmd *cli.Command, args []string) error { + if len(args) == 0 { + return fmt.Errorf("rule ID required") + } + cat, err := lint.LoadEmbeddedCatalog() + if err != nil { + return err + } + r := cat.ByID(args[0]) + if r == nil { + return fmt.Errorf("rule %s not found", args[0]) + } + fmt.Printf("ID: %s\n", r.ID) + fmt.Printf("Title: %s\n", r.Title) + fmt.Printf("Severity: %s\n", r.Severity) + fmt.Printf("Languages: %v\n", r.Languages) + fmt.Printf("Tags: %v\n", r.Tags) + fmt.Printf("Pattern: %s\n", r.Pattern) + if r.ExcludePattern != "" { + fmt.Printf("Exclude: %s\n", r.ExcludePattern) + } + fmt.Printf("Fix: %s\n", r.Fix) + if r.ExampleBad != "" { + fmt.Printf("\nBad:\n%s\n", r.ExampleBad) + } + if r.ExampleGood != "" { + fmt.Printf("Good:\n%s\n", r.ExampleGood) + } + return nil + }, + )) +} +``` + +**Step 3: Add cli dependency** + +```bash +cd ~/Code/core/lint +go get forge.lthn.ai/core/cli +go mod tidy +``` + +**Step 4: Build and smoke test** + +```bash +cd ~/Code/core/lint +go build -o ./bin/core-lint ./cmd/core-lint +./bin/core-lint lint catalog list +./bin/core-lint lint catalog show go-sec-001 +./bin/core-lint lint check --lang go --format json ~/Code/host-uk/core/pkg/core/ +``` + +Expected: Binary builds, catalog lists 18 rules, show displays rule details, check scans files. + +**Step 5: Commit** + +```bash +git add lint.go cmd/core-lint/main.go go.mod go.sum +git commit -m "feat: add core-lint CLI with check, catalog list, catalog show" +``` + +--- + +### Task 9: Run all tests, push to forge + +**Step 1: Run full test suite** + +```bash +cd ~/Code/core/lint +go test -race -count=1 ./... +``` + +Expected: PASS with race detector + +**Step 2: Run go vet** + +```bash +go vet ./... +``` + +Expected: No issues + +**Step 3: Build binary** + +```bash +go build -trimpath -o ./bin/core-lint ./cmd/core-lint +``` + +**Step 4: Smoke test against a real repo** + +```bash +./bin/core-lint lint check --lang go ~/Code/host-uk/core/pkg/core/ +./bin/core-lint lint check --lang go --severity high ~/Code/core/go-io/ +``` + +Expected: Any findings are displayed (or no findings if the repos are already clean from our sweep) + +**Step 5: Update go.work** + +```bash +# Add ./core/lint to ~/Code/go.work if not already there +cd ~/Code && go work sync +``` + +**Step 6: Push to forge** + +```bash +cd ~/Code/core/lint +git push -u origin main +``` + +**Step 7: Tag initial release** + +```bash +git tag v0.1.0 +git push origin v0.1.0 +``` diff --git a/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-12-altum-update-checker-design.md b/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-12-altum-update-checker-design.md new file mode 100644 index 0000000..a0bbe0d --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-12-altum-update-checker-design.md @@ -0,0 +1,160 @@ +# AltumCode Update Checker — Design + +> **Note:** Layer 1 (version detection via PHP artisan) is implemented and documented at `docs/docs/php/packages/uptelligence.md`. Layer 2 (browser-automated downloads via Claude Code skill) is NOT yet implemented. + +## Problem + +Host UK runs 4 AltumCode SaaS products and 13 plugins across two marketplaces (CodeCanyon + LemonSqueezy). Checking for updates and downloading them is a manual process: ~50 clicks across two marketplace UIs, moving 16+ zip files, extracting to the right directories. This eats a morning of momentum every update cycle. + +## Solution + +Two-layer system: lightweight version detection (PHP artisan command) + browser-automated download (Claude Code skill). + +## Architecture + +``` +Layer 1: Detection (core/php-uptelligence) + artisan uptelligence:check-updates + 5 HTTP GETs, no auth, schedulable + Compares remote vs deployed versions + +Layer 2: Download (Claude Code skill) + Playwright → LemonSqueezy (16 items) + Claude in Chrome → CodeCanyon (2 items) + Downloads zips to staging folder + Extracts to saas/services/{product}/package/ + +Layer 3: Deploy (existing — manual) + docker build → scp → deploy_saas.yml + Human in the loop +``` + +## Layer 1: Version Detection + +### Public Endpoints (no auth required) + +| Endpoint | Returns | +|----------|---------| +| `GET https://66analytics.com/info.php` | `{"latest_release_version": "66.0.0", "latest_release_version_code": 6600}` | +| `GET https://66biolinks.com/info.php` | Same format | +| `GET https://66pusher.com/info.php` | Same format | +| `GET https://66socialproof.com/info.php` | Same format | +| `GET https://dev.altumcode.com/plugins-versions` | `{"affiliate": {"version": "2.0.1"}, "ultimate-blocks": {"version": "9.1.0"}, ...}` | + +### Deployed Version Sources + +- **Product version**: `PRODUCT_CODE` constant in deployed source `config.php` +- **Plugin versions**: `version` field in each plugin's `config.php` or `config.json` + +### Artisan Command + +`php artisan uptelligence:check-updates` + +Output: +``` +Product Deployed Latest Status +────────────────────────────────────────────── +66analytics 65.0.0 66.0.0 UPDATE AVAILABLE +66biolinks 65.0.0 66.0.0 UPDATE AVAILABLE +66pusher 65.0.0 65.0.0 ✓ current +66socialproof 65.0.0 66.0.0 UPDATE AVAILABLE + +Plugin Deployed Latest Status +────────────────────────────────────────────── +affiliate 2.0.0 2.0.1 UPDATE AVAILABLE +ultimate-blocks 9.1.0 9.1.0 ✓ current +... +``` + +Lives in `core/php-uptelligence` as a scheduled check or on-demand command. + +## Layer 2: Browser-Automated Download + +### Claude Code Skill: `/update-altum` + +Workflow: +1. Run version check (Layer 1) — show what needs updating +2. Ask for confirmation before downloading +3. Download from both marketplaces +4. Extract to staging directories +5. Report what changed + +### Marketplace Access + +**LemonSqueezy (Playwright)** +- Auth: Magic link email to `snider@lt.hn` — user taps on phone +- Flow per item: Navigate to order detail → click "Download" button +- 16 items across 2 pages of orders +- Session persists for the skill invocation + +**CodeCanyon (Claude in Chrome)** +- Auth: Saved browser session cookies (user `snidered`) +- Flow per item: Click "Download" dropdown → "All files & documentation" +- 2 items on downloads page + +### Product-to-Marketplace Mapping + +| Product | CodeCanyon | LemonSqueezy | +|---------|-----------|--------------| +| 66biolinks | Regular licence | Extended licence (66biolinks custom, $359.28) | +| 66socialproof | Regular licence | — | +| 66analytics | — | Regular licence | +| 66pusher | — | Regular licence | + +### Plugin Inventory (all LemonSqueezy) + +| Plugin | Price | Applies To | +|--------|-------|------------| +| Pro Notifications | $58.80 | 66socialproof | +| Teams Plugin | $58.80 | All products | +| Push Notifications Plugin | $46.80 | All products | +| Ultimate Blocks | $32.40 | 66biolinks | +| Pro Blocks | $32.40 | 66biolinks | +| Payment Blocks | $32.40 | 66biolinks | +| Affiliate Plugin | $32.40 | All products | +| PWA Plugin | $25.20 | All products | +| Image Optimizer Plugin | $19.20 | All products | +| Email Shield Plugin | FREE | All products | +| Dynamic OG images plugin | FREE | 66biolinks | +| Offload & CDN Plugin | FREE | All products (gift from Altum) | + +### Staging & Extraction + +- Download to: `~/Code/lthn/saas/updates/YYYY-MM-DD/` +- Products extract to: `~/Code/lthn/saas/services/{product}/package/product/` +- Plugins extract to: `~/Code/lthn/saas/services/{product}/package/product/plugins/{plugin_id}/` + +## LemonSqueezy Order UUIDs + +Stable order URLs for direct navigation: + +| Product | Order URL | +|---------|-----------| +| 66analytics | `/my-orders/2972471f-abac-4165-b78d-541b176de180` | + +(Remaining UUIDs to be captured on first full run of the skill.) + +## Out of Scope + +- No auto-deploy to production (human runs `deploy_saas.yml`) +- No licence key handling or financial transactions +- No AltumCode Club membership management +- No Blesta updates (different vendor) +- No update SQL migration execution (handled by AltumCode's own update scripts) + +## Key Technical Details + +- AltumCode products use Unirest HTTP client for API calls +- Product `info.php` endpoints are public, no rate limiting observed +- Plugin versions endpoint (`dev.altumcode.com`) is also public +- Production Docker images have `/install/` and `/update/` directories stripped +- Updates require full Docker image rebuild and redeployment via Ansible +- CodeCanyon download URLs contain stable purchase UUIDs +- LemonSqueezy uses magic link auth (no password, email-based) +- Playwright can access LemonSqueezy; Claude in Chrome cannot (payment platform safety block) + +## Workflow Summary + +**Before**: Get email from AltumCode → log into 2 marketplaces → click through 18 products/plugins → download 16+ zips → extract to right directories → rebuild Docker images → deploy. Half a morning. + +**After**: Run `artisan uptelligence:check-updates` → see what's behind → invoke `/update-altum` → tap magic link on phone → go make coffee → come back to staged files → `deploy_saas.yml`. 10 minutes of human time. diff --git a/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-12-altum-update-checker-plan.md b/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-12-altum-update-checker-plan.md new file mode 100644 index 0000000..37ecb28 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/plans/2026-03-12-altum-update-checker-plan.md @@ -0,0 +1,799 @@ +# AltumCode Update Checker Implementation Plan + +> **Note:** Layer 1 (Tasks 1-2, 4: version checking + seeder + sync command) is implemented and documented at `docs/docs/php/packages/uptelligence.md`. Task 3 (Claude Code browser skill for Layer 2 downloads) is NOT yet implemented. + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add AltumCode product + plugin version checking to uptelligence, and create a Claude Code skill for browser-automated downloads from LemonSqueezy and CodeCanyon. + +**Architecture:** Extend the existing `VendorUpdateCheckerService` to handle `PLATFORM_ALTUM` vendors via 5 public HTTP endpoints. Seed the vendors table with all 4 products and 13 plugins. Create a Claude Code plugin skill that uses Playwright (LemonSqueezy) and Chrome (CodeCanyon) to download updates. + +**Tech Stack:** PHP 8.4, Laravel, Pest, Claude Code plugins (Playwright MCP + Chrome MCP) + +--- + +### Task 1: Add AltumCode check to VendorUpdateCheckerService + +**Files:** +- Modify: `/Users/snider/Code/core/php-uptelligence/Services/VendorUpdateCheckerService.php` +- Test: `/Users/snider/Code/core/php-uptelligence/tests/Unit/AltumCodeCheckerTest.php` + +**Step 1: Write the failing test** + +Create `/Users/snider/Code/core/php-uptelligence/tests/Unit/AltumCodeCheckerTest.php`: + +```php +service = app(VendorUpdateCheckerService::class); +}); + +it('checks altum product version via info.php', function () { + Http::fake([ + 'https://66analytics.com/info.php' => Http::response([ + 'latest_release_version' => '66.0.0', + 'latest_release_version_code' => 6600, + ]), + ]); + + $vendor = Vendor::factory()->create([ + 'slug' => '66analytics', + 'name' => '66analytics', + 'source_type' => Vendor::SOURCE_LICENSED, + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'current_version' => '65.0.0', + 'is_active' => true, + ]); + + $result = $this->service->checkVendor($vendor); + + expect($result['status'])->toBe('success') + ->and($result['current'])->toBe('65.0.0') + ->and($result['latest'])->toBe('66.0.0') + ->and($result['has_update'])->toBeTrue(); +}); + +it('reports no update when altum product is current', function () { + Http::fake([ + 'https://66analytics.com/info.php' => Http::response([ + 'latest_release_version' => '65.0.0', + 'latest_release_version_code' => 6500, + ]), + ]); + + $vendor = Vendor::factory()->create([ + 'slug' => '66analytics', + 'name' => '66analytics', + 'source_type' => Vendor::SOURCE_LICENSED, + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'current_version' => '65.0.0', + 'is_active' => true, + ]); + + $result = $this->service->checkVendor($vendor); + + expect($result['has_update'])->toBeFalse(); +}); + +it('checks altum plugin versions via plugins-versions endpoint', function () { + Http::fake([ + 'https://dev.altumcode.com/plugins-versions' => Http::response([ + 'affiliate' => ['version' => '2.0.1'], + 'teams' => ['version' => '3.0.0'], + ]), + ]); + + $vendor = Vendor::factory()->create([ + 'slug' => 'altum-plugin-affiliate', + 'name' => 'Affiliate Plugin', + 'source_type' => Vendor::SOURCE_PLUGIN, + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'current_version' => '2.0.0', + 'is_active' => true, + ]); + + $result = $this->service->checkVendor($vendor); + + expect($result['status'])->toBe('success') + ->and($result['latest'])->toBe('2.0.1') + ->and($result['has_update'])->toBeTrue(); +}); + +it('handles altum info.php timeout gracefully', function () { + Http::fake([ + 'https://66analytics.com/info.php' => Http::response('', 500), + ]); + + $vendor = Vendor::factory()->create([ + 'slug' => '66analytics', + 'name' => '66analytics', + 'source_type' => Vendor::SOURCE_LICENSED, + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'current_version' => '65.0.0', + 'is_active' => true, + ]); + + $result = $this->service->checkVendor($vendor); + + expect($result['status'])->toBe('error') + ->and($result['has_update'])->toBeFalse(); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=AltumCodeChecker` +Expected: FAIL — altum vendors still hit `skipCheck()` + +**Step 3: Write minimal implementation** + +In `/Users/snider/Code/core/php-uptelligence/Services/VendorUpdateCheckerService.php`, modify `checkVendor()` to route altum vendors: + +```php +public function checkVendor(Vendor $vendor): array +{ + $result = match (true) { + $this->isAltumPlatform($vendor) && $vendor->isLicensed() => $this->checkAltumProduct($vendor), + $this->isAltumPlatform($vendor) && $vendor->isPlugin() => $this->checkAltumPlugin($vendor), + $vendor->isOss() && $this->isGitHubUrl($vendor->git_repo_url) => $this->checkGitHub($vendor), + $vendor->isOss() && $this->isGiteaUrl($vendor->git_repo_url) => $this->checkGitea($vendor), + default => $this->skipCheck($vendor), + }; + + // ... rest unchanged +} +``` + +Add the three new methods: + +```php +/** + * Check if vendor is on the AltumCode platform. + */ +protected function isAltumPlatform(Vendor $vendor): bool +{ + return $vendor->plugin_platform === Vendor::PLATFORM_ALTUM; +} + +/** + * AltumCode product info endpoint mapping. + */ +protected function getAltumProductInfoUrl(Vendor $vendor): ?string +{ + $urls = [ + '66analytics' => 'https://66analytics.com/info.php', + '66biolinks' => 'https://66biolinks.com/info.php', + '66pusher' => 'https://66pusher.com/info.php', + '66socialproof' => 'https://66socialproof.com/info.php', + ]; + + return $urls[$vendor->slug] ?? null; +} + +/** + * Check an AltumCode product for updates via its info.php endpoint. + */ +protected function checkAltumProduct(Vendor $vendor): array +{ + $url = $this->getAltumProductInfoUrl($vendor); + if (! $url) { + return $this->errorResult("No info.php URL mapped for {$vendor->slug}"); + } + + try { + $response = Http::timeout(5)->get($url); + + if (! $response->successful()) { + return $this->errorResult("AltumCode info.php returned {$response->status()}"); + } + + $data = $response->json(); + $latestVersion = $data['latest_release_version'] ?? null; + + if (! $latestVersion) { + return $this->errorResult('No version in info.php response'); + } + + return $this->buildResult( + vendor: $vendor, + latestVersion: $this->normaliseVersion($latestVersion), + releaseInfo: [ + 'version_code' => $data['latest_release_version_code'] ?? null, + 'source' => $url, + ] + ); + } catch (\Exception $e) { + return $this->errorResult("AltumCode check failed: {$e->getMessage()}"); + } +} + +/** + * Check an AltumCode plugin for updates via the central plugins-versions endpoint. + */ +protected function checkAltumPlugin(Vendor $vendor): array +{ + try { + $allPlugins = $this->getAltumPluginVersions(); + + if ($allPlugins === null) { + return $this->errorResult('Failed to fetch AltumCode plugin versions'); + } + + // Extract the plugin_id from the vendor slug (strip 'altum-plugin-' prefix) + $pluginId = str_replace('altum-plugin-', '', $vendor->slug); + + if (! isset($allPlugins[$pluginId])) { + return $this->errorResult("Plugin '{$pluginId}' not found in AltumCode registry"); + } + + $latestVersion = $allPlugins[$pluginId]['version'] ?? null; + + return $this->buildResult( + vendor: $vendor, + latestVersion: $this->normaliseVersion($latestVersion), + releaseInfo: ['source' => 'dev.altumcode.com/plugins-versions'] + ); + } catch (\Exception $e) { + return $this->errorResult("AltumCode plugin check failed: {$e->getMessage()}"); + } +} + +/** + * Fetch all AltumCode plugin versions (cached for 1 hour within a check run). + */ +protected ?array $altumPluginVersionsCache = null; + +protected function getAltumPluginVersions(): ?array +{ + if ($this->altumPluginVersionsCache !== null) { + return $this->altumPluginVersionsCache; + } + + $response = Http::timeout(5)->get('https://dev.altumcode.com/plugins-versions'); + + if (! $response->successful()) { + return null; + } + + $this->altumPluginVersionsCache = $response->json(); + + return $this->altumPluginVersionsCache; +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=AltumCodeChecker` +Expected: PASS (4 tests) + +**Step 5: Commit** + +```bash +cd /Users/snider/Code/core/php-uptelligence +git add Services/VendorUpdateCheckerService.php tests/Unit/AltumCodeCheckerTest.php +git commit -m "feat: add AltumCode product + plugin version checking + +Extends VendorUpdateCheckerService to check AltumCode products via +their info.php endpoints and plugins via dev.altumcode.com/plugins-versions. +No auth required — all endpoints are public. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 2: Seed AltumCode vendors + +**Files:** +- Create: `/Users/snider/Code/core/php-uptelligence/database/seeders/AltumCodeVendorSeeder.php` +- Test: `/Users/snider/Code/core/php-uptelligence/tests/Unit/AltumCodeVendorSeederTest.php` + +**Step 1: Write the failing test** + +Create `/Users/snider/Code/core/php-uptelligence/tests/Unit/AltumCodeVendorSeederTest.php`: + +```php +artisan('db:seed', ['--class' => 'Core\\Mod\\Uptelligence\\Database\\Seeders\\AltumCodeVendorSeeder']); + + expect(Vendor::where('source_type', Vendor::SOURCE_LICENSED) + ->where('plugin_platform', Vendor::PLATFORM_ALTUM) + ->count() + )->toBe(4); +}); + +it('seeds 13 altum plugins', function () { + $this->artisan('db:seed', ['--class' => 'Core\\Mod\\Uptelligence\\Database\\Seeders\\AltumCodeVendorSeeder']); + + expect(Vendor::where('source_type', Vendor::SOURCE_PLUGIN) + ->where('plugin_platform', Vendor::PLATFORM_ALTUM) + ->count() + )->toBe(13); +}); + +it('is idempotent', function () { + $this->artisan('db:seed', ['--class' => 'Core\\Mod\\Uptelligence\\Database\\Seeders\\AltumCodeVendorSeeder']); + $this->artisan('db:seed', ['--class' => 'Core\\Mod\\Uptelligence\\Database\\Seeders\\AltumCodeVendorSeeder']); + + expect(Vendor::where('plugin_platform', Vendor::PLATFORM_ALTUM)->count())->toBe(17); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=AltumCodeVendorSeeder` +Expected: FAIL — seeder class not found + +**Step 3: Write minimal implementation** + +Create `/Users/snider/Code/core/php-uptelligence/database/seeders/AltumCodeVendorSeeder.php`: + +```php + '66analytics', 'name' => '66analytics', 'vendor_name' => 'AltumCode', 'current_version' => '65.0.0'], + ['slug' => '66biolinks', 'name' => '66biolinks', 'vendor_name' => 'AltumCode', 'current_version' => '65.0.0'], + ['slug' => '66pusher', 'name' => '66pusher', 'vendor_name' => 'AltumCode', 'current_version' => '65.0.0'], + ['slug' => '66socialproof', 'name' => '66socialproof', 'vendor_name' => 'AltumCode', 'current_version' => '65.0.0'], + ]; + + foreach ($products as $product) { + Vendor::updateOrCreate( + ['slug' => $product['slug']], + [ + ...$product, + 'source_type' => Vendor::SOURCE_LICENSED, + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'is_active' => true, + ] + ); + } + + $plugins = [ + ['slug' => 'altum-plugin-affiliate', 'name' => 'Affiliate Plugin', 'current_version' => '2.0.0'], + ['slug' => 'altum-plugin-push-notifications', 'name' => 'Push Notifications Plugin', 'current_version' => '1.0.0'], + ['slug' => 'altum-plugin-teams', 'name' => 'Teams Plugin', 'current_version' => '1.0.0'], + ['slug' => 'altum-plugin-pwa', 'name' => 'PWA Plugin', 'current_version' => '1.0.0'], + ['slug' => 'altum-plugin-image-optimizer', 'name' => 'Image Optimizer Plugin', 'current_version' => '3.1.0'], + ['slug' => 'altum-plugin-email-shield', 'name' => 'Email Shield Plugin', 'current_version' => '1.0.0'], + ['slug' => 'altum-plugin-dynamic-og-images', 'name' => 'Dynamic OG Images Plugin', 'current_version' => '1.0.0'], + ['slug' => 'altum-plugin-offload', 'name' => 'Offload & CDN Plugin', 'current_version' => '1.0.0'], + ['slug' => 'altum-plugin-payment-blocks', 'name' => 'Payment Blocks Plugin', 'current_version' => '1.0.0'], + ['slug' => 'altum-plugin-ultimate-blocks', 'name' => 'Ultimate Blocks Plugin', 'current_version' => '9.1.0'], + ['slug' => 'altum-plugin-pro-blocks', 'name' => 'Pro Blocks Plugin', 'current_version' => '1.0.0'], + ['slug' => 'altum-plugin-pro-notifications', 'name' => 'Pro Notifications Plugin', 'current_version' => '1.0.0'], + ['slug' => 'altum-plugin-aix', 'name' => 'AIX Plugin', 'current_version' => '1.0.0'], + ]; + + foreach ($plugins as $plugin) { + Vendor::updateOrCreate( + ['slug' => $plugin['slug']], + [ + ...$plugin, + 'vendor_name' => 'AltumCode', + 'source_type' => Vendor::SOURCE_PLUGIN, + 'plugin_platform' => Vendor::PLATFORM_ALTUM, + 'is_active' => true, + ] + ); + } + } +} +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=AltumCodeVendorSeeder` +Expected: PASS (3 tests) + +**Step 5: Commit** + +```bash +cd /Users/snider/Code/core/php-uptelligence +git add database/seeders/AltumCodeVendorSeeder.php tests/Unit/AltumCodeVendorSeederTest.php +git commit -m "feat: seed AltumCode vendors — 4 products + 13 plugins + +Idempotent seeder using updateOrCreate. Products are SOURCE_LICENSED, +plugins are SOURCE_PLUGIN, all PLATFORM_ALTUM. Version numbers will +need updating to match actual deployed versions. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 3: Create Claude Code plugin skill for downloads + +**Files:** +- Create: `/Users/snider/.claude/plugins/altum-updater/plugin.json` +- Create: `/Users/snider/.claude/plugins/altum-updater/skills/update-altum.md` + +**Step 1: Create plugin manifest** + +Create `/Users/snider/.claude/plugins/altum-updater/plugin.json`: + +```json +{ + "name": "altum-updater", + "description": "Download AltumCode product and plugin updates from LemonSqueezy and CodeCanyon", + "version": "0.1.0", + "skills": [ + { + "name": "update-altum", + "path": "skills/update-altum.md", + "description": "Download AltumCode product and plugin updates from marketplaces. Use when the user mentions updating AltumCode products, downloading from LemonSqueezy or CodeCanyon, or running the update checker." + } + ] +} +``` + +**Step 2: Create skill file** + +Create `/Users/snider/.claude/plugins/altum-updater/skills/update-altum.md`: + +```markdown +--- +name: update-altum +description: Download AltumCode product and plugin updates from LemonSqueezy and CodeCanyon +--- + +# AltumCode Update Downloader + +## Overview + +Downloads updated AltumCode products and plugins from two marketplaces: +- **LemonSqueezy** (Playwright): 66analytics, 66pusher, 66biolinks (extended), 13 plugins +- **CodeCanyon** (Claude in Chrome): 66biolinks (regular), 66socialproof + +## Pre-flight + +1. Run `php artisan uptelligence:check-updates --vendor=66analytics` (or check all) to see what needs updating +2. Show the user the version comparison table +3. Ask which products/plugins to download + +## LemonSqueezy Download Flow (Playwright) + +LemonSqueezy uses magic link auth. The user will need to tap the link on their phone. + +1. Navigate to `https://app.lemonsqueezy.com/my-orders` +2. If on login page, fill email `snider@lt.hn` and click Sign In +3. Tell user: "Magic link sent — tap the link on your phone" +4. Wait for redirect to orders page +5. For each product/plugin that needs updating: + a. Click the product link on the orders page (paginated — 10 per page, 2 pages) + b. In the order detail, find the "Download" button under "Files" + c. Click Download — file saves to default downloads folder +6. Move downloaded zips to staging: `~/Code/lthn/saas/updates/YYYY-MM-DD/` + +### LemonSqueezy Product Names (as shown on orders page) + +| Our Name | LemonSqueezy Order Name | +|----------|------------------------| +| 66analytics | "66analytics - Regular License" | +| 66pusher | "66pusher - Regular License" | +| 66biolinks (extended) | "66biolinks custom" | +| Affiliate Plugin | "Affiliate Plugin" | +| Push Notifications Plugin | "Push Notifications Plugin" | +| Teams Plugin | "Teams Plugin" | +| PWA Plugin | "PWA Plugin" | +| Image Optimizer Plugin | "Image Optimizer Plugin" | +| Email Shield Plugin | "Email Shield Plugin" | +| Dynamic OG Images | "Dynamic OG images plugin" | +| Offload & CDN | "Offload & CDN Plugin" | +| Payment Blocks | "Payment Blocks - 66biolinks plugin" | +| Ultimate Blocks | "Ultimate Blocks - 66biolinks plugin" | +| Pro Blocks | "Pro Blocks - 66biolinks plugin" | +| Pro Notifications | "Pro Notifications - 66socialproof plugin" | +| AltumCode Club | "The AltumCode Club" | + +## CodeCanyon Download Flow (Claude in Chrome) + +CodeCanyon uses saved browser session cookies (user: snidered). + +1. Navigate to `https://codecanyon.net/downloads` +2. Dismiss cookie banner if present (click "Reject all") +3. For 66socialproof: + a. Find "66socialproof" Download button + b. Click the dropdown arrow + c. Click "All files & documentation" +4. For 66biolinks: + a. Find "66biolinks" Download button (scroll down) + b. Click the dropdown arrow + c. Click "All files & documentation" +5. Move downloaded zips to staging + +### CodeCanyon Download URLs (stable) + +- 66socialproof: `/user/snidered/download_purchase/8d8ef4c1-5add-4eba-9a89-4261a9c87e0b` +- 66biolinks: `/user/snidered/download_purchase/38d79f4e-19cd-480a-b068-4332629b5206` + +## Post-Download + +1. List all zips in staging folder +2. For each product zip: + - Extract to `~/Code/lthn/saas/services/{product}/package/product/` +3. For each plugin zip: + - Extract to the correct product's `plugins/{plugin_id}/` directory + - Note: Some plugins apply to multiple products (affiliate, teams, etc.) +4. Show summary of what was updated +5. Remind user: "Files staged. Run `deploy_saas.yml` when ready to deploy." + +## Important Notes + +- Never make purchases or enter financial information +- LemonSqueezy session expires — if Playwright gets a login page mid-flow, re-trigger magic link +- CodeCanyon session depends on Chrome cookies — if logged out, tell user to log in manually +- The AltumCode Club subscription is NOT a downloadable product — skip it +- Plugin `aix` may not appear on LemonSqueezy (bundled with products) — skip if not found +``` + +**Step 3: Verify plugin loads** + +Run: `claude` in a new terminal, then type `/update-altum` to verify the skill is discovered. + +**Step 4: Commit** + +```bash +cd /Users/snider/.claude/plugins/altum-updater +git init +git add plugin.json skills/update-altum.md +git commit -m "feat: altum-updater Claude Code plugin — marketplace download skill + +Playwright for LemonSqueezy, Chrome for CodeCanyon. Includes full +product/plugin mapping and download flow documentation. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 4: Sync deployed plugin versions from source + +**Files:** +- Create: `/Users/snider/Code/core/php-uptelligence/Console/SyncAltumVersionsCommand.php` +- Modify: `/Users/snider/Code/core/php-uptelligence/Boot.php` (register command) +- Test: `/Users/snider/Code/core/php-uptelligence/tests/Unit/SyncAltumVersionsCommandTest.php` + +**Step 1: Write the failing test** + +```php +artisan('uptelligence:sync-altum-versions', ['--dry-run' => true]) + ->assertExitCode(0); +}); +``` + +**Step 2: Run test to verify it fails** + +Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=SyncAltumVersions` +Expected: FAIL — command not found + +**Step 3: Write minimal implementation** + +Create `/Users/snider/Code/core/php-uptelligence/Console/SyncAltumVersionsCommand.php`: + +```php + '66analytics/package/product', + '66biolinks' => '66biolinks/package/product', + '66pusher' => '66pusher/package/product', + '66socialproof' => '66socialproof/package/product', + ]; + + public function handle(): int + { + $basePath = $this->option('path') + ?? env('SAAS_SERVICES_PATH', base_path('../lthn/saas/services')); + $dryRun = $this->option('dry-run'); + + $this->info('Syncing AltumCode versions from source...'); + $this->newLine(); + + $updates = []; + + // Sync product versions + foreach ($this->productPaths as $slug => $relativePath) { + $productPath = rtrim($basePath, '/') . '/' . $relativePath; + $version = $this->readProductVersion($productPath); + + if ($version) { + $updates[] = $this->syncVendorVersion($slug, $version, $dryRun); + } else { + $this->warn(" Could not read version for {$slug} at {$productPath}"); + } + } + + // Sync plugin versions — read from biolinks as canonical source + $biolinkPluginsPath = rtrim($basePath, '/') . '/66biolinks/package/product/plugins'; + if (is_dir($biolinkPluginsPath)) { + foreach (glob($biolinkPluginsPath . '/*/config.php') as $configFile) { + $pluginId = basename(dirname($configFile)); + $version = $this->readPluginVersion($configFile); + + if ($version) { + $slug = "altum-plugin-{$pluginId}"; + $updates[] = $this->syncVendorVersion($slug, $version, $dryRun); + } + } + } + + // Output table + $this->table( + ['Vendor', 'Old Version', 'New Version', 'Status'], + array_filter($updates) + ); + + if ($dryRun) { + $this->warn('Dry run — no changes written.'); + } + + return self::SUCCESS; + } + + protected function readProductVersion(string $productPath): ?string + { + // Read version from app/init.php or similar — look for PRODUCT_VERSION define + $initFile = $productPath . '/app/init.php'; + if (! file_exists($initFile)) { + return null; + } + + $content = file_get_contents($initFile); + if (preg_match("/define\('PRODUCT_VERSION',\s*'([^']+)'\)/", $content, $matches)) { + return $matches[1]; + } + + return null; + } + + protected function readPluginVersion(string $configFile): ?string + { + if (! file_exists($configFile)) { + return null; + } + + $content = file_get_contents($configFile); + + // PHP config format: 'version' => '2.0.0' + if (preg_match("/'version'\s*=>\s*'([^']+)'/", $content, $matches)) { + return $matches[1]; + } + + return null; + } + + protected function syncVendorVersion(string $slug, string $version, bool $dryRun): ?array + { + $vendor = Vendor::where('slug', $slug)->first(); + if (! $vendor) { + return [$slug, '(not in DB)', $version, 'SKIPPED']; + } + + $oldVersion = $vendor->current_version; + if ($oldVersion === $version) { + return [$slug, $oldVersion, $version, 'current']; + } + + if (! $dryRun) { + $vendor->update(['current_version' => $version]); + } + + return [$slug, $oldVersion ?? '(none)', $version, $dryRun ? 'WOULD UPDATE' : 'UPDATED']; + } +} +``` + +Register in Boot.php — add to `onConsole()`: + +```php +$event->command(Console\SyncAltumVersionsCommand::class); +``` + +**Step 4: Run test to verify it passes** + +Run: `cd /Users/snider/Code/core/php-uptelligence && composer test -- --filter=SyncAltumVersions` +Expected: PASS + +**Step 5: Commit** + +```bash +cd /Users/snider/Code/core/php-uptelligence +git add Console/SyncAltumVersionsCommand.php Boot.php tests/Unit/SyncAltumVersionsCommandTest.php +git commit -m "feat: sync deployed AltumCode versions from source files + +Reads PRODUCT_VERSION from product init.php and plugin versions from +config.php files. Updates uptelligence_vendors table so check-updates +knows what's actually deployed. + +Co-Authored-By: Virgil " +``` + +--- + +### Task 5: End-to-end verification + +**Step 1: Seed vendors on local dev** + +```bash +cd /Users/snider/Code/lab/host.uk.com +php artisan db:seed --class="Core\Mod\Uptelligence\Database\Seeders\AltumCodeVendorSeeder" +``` + +**Step 2: Sync actual deployed versions** + +```bash +php artisan uptelligence:sync-altum-versions --path=/Users/snider/Code/lthn/saas/services +``` + +**Step 3: Run the update check** + +```bash +php artisan uptelligence:check-updates +``` + +Expected: Table showing current vs latest versions for all 17 AltumCode vendors. + +**Step 4: Test the skill** + +Open a new Claude Code session and run `/update-altum` to verify the skill loads and shows the workflow. + +**Step 5: Commit any fixes** + +```bash +git add -A && git commit -m "fix: adjustments from end-to-end testing" +``` diff --git a/pkg/lib/workspace/default/.core/reference/docs/primitives.md b/pkg/lib/workspace/default/.core/reference/docs/primitives.md new file mode 100644 index 0000000..43701f2 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/primitives.md @@ -0,0 +1,169 @@ +--- +title: Core Primitives +description: The repeated shapes that make CoreGO easy to navigate. +--- + +# Core Primitives + +CoreGO is easiest to use when you read it as a small vocabulary repeated everywhere. Most of the framework is built from the same handful of types. + +## Primitive Map + +| Type | Used For | +|------|----------| +| `Options` | Input values and lightweight metadata | +| `Result` | Output values and success state | +| `Service` | Lifecycle-managed components | +| `Message` | Broadcast events | +| `Query` | Request-response lookups | +| `Task` | Side-effecting work items | + +## `Option` and `Options` + +`Option` is one key-value pair. `Options` is an ordered slice of them. + +```go +opts := core.Options{ + {Key: "name", Value: "brain"}, + {Key: "path", Value: "prompts"}, + {Key: "debug", Value: true}, +} +``` + +Use the helpers to read values: + +```go +name := opts.String("name") +path := opts.String("path") +debug := opts.Bool("debug") +hasPath := opts.Has("path") +raw := opts.Get("name") +``` + +### Important Details + +- `Get` returns the first matching key. +- `String`, `Int`, and `Bool` do not convert between types. +- Missing keys return zero values. +- CLI flags with values are stored as strings, so `--port=8080` should be read with `opts.String("port")`, not `opts.Int("port")`. + +## `Result` + +`Result` is the universal return shape. + +```go +r := core.Result{Value: "ready", OK: true} + +if r.OK { + fmt.Println(r.Value) +} +``` + +It has two jobs: + +- carry a value when work succeeds +- carry either an error or an empty state when work does not succeed + +### `Result.Result(...)` + +The `Result()` method adapts plain Go values and `(value, error)` pairs into a `core.Result`. + +```go +r1 := core.Result{}.Result("hello") +r2 := core.Result{}.Result(file, err) +``` + +This is how several built-in helpers bridge standard-library calls. + +## `Service` + +`Service` is the managed lifecycle DTO stored in the registry. + +```go +svc := core.Service{ + Name: "cache", + Options: core.Options{ + {Key: "backend", Value: "memory"}, + }, + OnStart: func() core.Result { + return core.Result{OK: true} + }, + OnStop: func() core.Result { + return core.Result{OK: true} + }, + OnReload: func() core.Result { + return core.Result{OK: true} + }, +} +``` + +### Important Details + +- `OnStart` and `OnStop` are used by the framework lifecycle. +- `OnReload` is stored on the service DTO, but CoreGO does not currently call it automatically. +- The registry stores `*core.Service`, not arbitrary typed service instances. + +## `Message`, `Query`, and `Task` + +These are simple aliases to `any`. + +```go +type Message any +type Query any +type Task any +``` + +That means your own structs become the protocol: + +```go +type deployStarted struct { + Environment string +} + +type workspaceCountQuery struct{} + +type syncRepositoryTask struct { + Name string +} +``` + +## `TaskWithIdentifier` + +Long-running tasks can opt into task identifiers. + +```go +type indexedTask struct { + ID string +} + +func (t *indexedTask) SetTaskIdentifier(id string) { t.ID = id } +func (t *indexedTask) GetTaskIdentifier() string { return t.ID } +``` + +If a task implements `TaskWithIdentifier`, `PerformAsync` injects the generated `task-N` identifier before dispatch. + +## `ServiceRuntime[T]` + +`ServiceRuntime[T]` is the small helper for packages that want to keep a Core reference and a typed options struct together. + +```go +type agentServiceOptions struct { + WorkspacePath string +} + +type agentService struct { + *core.ServiceRuntime[agentServiceOptions] +} + +runtime := core.NewServiceRuntime(c, agentServiceOptions{ + WorkspacePath: "/srv/agent-workspaces", +}) +``` + +It exposes: + +- `Core()` +- `Options()` +- `Config()` + +This helper does not register anything by itself. It is a composition aid for package authors. diff --git a/pkg/lib/workspace/default/.core/reference/docs/services.md b/pkg/lib/workspace/default/.core/reference/docs/services.md new file mode 100644 index 0000000..ad95d64 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/services.md @@ -0,0 +1,152 @@ +--- +title: Services +description: Register, inspect, and lock CoreGO services. +--- + +# Services + +In CoreGO, a service is a named lifecycle entry stored in the Core registry. + +## Register a Service + +```go +c := core.New() + +r := c.Service("audit", core.Service{ + OnStart: func() core.Result { + core.Info("audit started") + return core.Result{OK: true} + }, + OnStop: func() core.Result { + core.Info("audit stopped") + return core.Result{OK: true} + }, +}) +``` + +Registration succeeds when: + +- the name is not empty +- the registry is not locked +- the name is not already in use + +## Read a Service Back + +```go +r := c.Service("audit") +if r.OK { + svc := r.Value.(*core.Service) + _ = svc +} +``` + +The returned value is `*core.Service`. + +## List Registered Services + +```go +names := c.Services() +``` + +### Important Detail + +The current registry is map-backed. `Services()`, `Startables()`, and `Stoppables()` do not promise a stable order. + +## Lifecycle Snapshots + +Use these helpers when you want the current set of startable or stoppable services: + +```go +startables := c.Startables() +stoppables := c.Stoppables() +``` + +They return `[]*core.Service` inside `Result.Value`. + +## Lock the Registry + +CoreGO has a service-lock mechanism, but it is explicit. + +```go +c := core.New() + +c.LockEnable() +c.Service("audit", core.Service{}) +c.Service("cache", core.Service{}) +c.LockApply() +``` + +After `LockApply`, new registrations fail: + +```go +r := c.Service("late", core.Service{}) +fmt.Println(r.OK) // false +``` + +The default lock name is `"srv"`. You can pass a different name if you need a custom lock namespace. + +For the service registry itself, use the default `"srv"` lock path. That is the path used by `Core.Service(...)`. + +## `NewWithFactories` + +For GUI runtimes or factory-driven setup, CoreGO provides `NewWithFactories`. + +```go +r := core.NewWithFactories(nil, map[string]core.ServiceFactory{ + "audit": func() core.Result { + return core.Result{Value: core.Service{ + OnStart: func() core.Result { + return core.Result{OK: true} + }, + }, OK: true} + }, + "cache": func() core.Result { + return core.Result{Value: core.Service{}, OK: true} + }, +}) +``` + +### Important Details + +- each factory must return a `core.Service` in `Result.Value` +- factories are executed in sorted key order +- nil factories are skipped +- the return value is `*core.Runtime` + +## `Runtime` + +`Runtime` is a small wrapper used for external runtimes such as GUI bindings. + +```go +r := core.NewRuntime(nil) +rt := r.Value.(*core.Runtime) + +_ = rt.ServiceStartup(context.Background(), nil) +_ = rt.ServiceShutdown(context.Background()) +``` + +`Runtime.ServiceName()` returns `"Core"`. + +## `ServiceRuntime[T]` for Package Authors + +If you are writing a package on top of CoreGO, use `ServiceRuntime[T]` to keep a typed options struct and the parent `Core` together. + +```go +type repositoryServiceOptions struct { + BaseDirectory string +} + +type repositoryService struct { + *core.ServiceRuntime[repositoryServiceOptions] +} + +func newRepositoryService(c *core.Core) *repositoryService { + return &repositoryService{ + ServiceRuntime: core.NewServiceRuntime(c, repositoryServiceOptions{ + BaseDirectory: "/srv/repos", + }), + } +} +``` + +This is a package-authoring helper. It does not replace the `core.Service` registry entry. diff --git a/pkg/lib/workspace/default/.core/reference/docs/subsystems.md b/pkg/lib/workspace/default/.core/reference/docs/subsystems.md new file mode 100644 index 0000000..f39ea16 --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/subsystems.md @@ -0,0 +1,158 @@ +--- +title: Subsystems +description: Built-in accessors for app metadata, embedded data, filesystem, transport handles, i18n, and CLI. +--- + +# Subsystems + +`Core` gives you a set of built-in subsystems so small applications do not need extra plumbing before they can do useful work. + +## Accessor Map + +| Accessor | Purpose | +|----------|---------| +| `App()` | Application identity and external runtime | +| `Data()` | Named embedded filesystem mounts | +| `Drive()` | Named transport handles | +| `Fs()` | Local filesystem access | +| `I18n()` | Locale collection and translation delegation | +| `Cli()` | Command-line surface over the command tree | + +## `App` + +`App` stores process identity and optional GUI runtime state. + +```go +app := c.App() +app.Name = "agent-workbench" +app.Version = "0.25.0" +app.Description = "workspace runner" +app.Runtime = myRuntime +``` + +`Find` resolves an executable on `PATH` and returns an `*App`. + +```go +r := core.Find("go", "Go toolchain") +``` + +## `Data` + +`Data` mounts named embedded filesystems and makes them addressable through paths like `mount-name/path/to/file`. + +```go +c.Data().New(core.Options{ + {Key: "name", Value: "app"}, + {Key: "source", Value: appFS}, + {Key: "path", Value: "templates"}, +}) +``` + +Read content: + +```go +text := c.Data().ReadString("app/agent.md") +bytes := c.Data().ReadFile("app/agent.md") +list := c.Data().List("app") +names := c.Data().ListNames("app") +``` + +Extract a mounted directory: + +```go +r := c.Data().Extract("app/workspace", "/tmp/workspace", nil) +``` + +### Path Rule + +The first path segment is always the mount name. + +## `Drive` + +`Drive` is a registry for named transport handles. + +```go +c.Drive().New(core.Options{ + {Key: "name", Value: "api"}, + {Key: "transport", Value: "https://api.lthn.ai"}, +}) + +c.Drive().New(core.Options{ + {Key: "name", Value: "mcp"}, + {Key: "transport", Value: "mcp://mcp.lthn.sh"}, +}) +``` + +Read them back: + +```go +handle := c.Drive().Get("api") +hasMCP := c.Drive().Has("mcp") +names := c.Drive().Names() +``` + +## `Fs` + +`Fs` wraps local filesystem operations with a consistent `Result` shape. + +```go +c.Fs().Write("/tmp/core-go/example.txt", "hello") +r := c.Fs().Read("/tmp/core-go/example.txt") +``` + +Other helpers: + +```go +c.Fs().EnsureDir("/tmp/core-go/cache") +c.Fs().List("/tmp/core-go") +c.Fs().Stat("/tmp/core-go/example.txt") +c.Fs().Rename("/tmp/core-go/example.txt", "/tmp/core-go/example-2.txt") +c.Fs().Delete("/tmp/core-go/example-2.txt") +``` + +### Important Details + +- the default `Core` starts with `Fs{root:"/"}` +- relative paths resolve from the current working directory +- `Delete` and `DeleteAll` refuse to remove `/` and `$HOME` + +## `I18n` + +`I18n` collects locale mounts and forwards translation work to a translator implementation when one is registered. + +```go +c.I18n().SetLanguage("en-GB") +``` + +Without a translator, `Translate` returns the message key itself: + +```go +r := c.I18n().Translate("cmd.deploy.description") +``` + +With a translator: + +```go +c.I18n().SetTranslator(myTranslator) +``` + +Then: + +```go +langs := c.I18n().AvailableLanguages() +current := c.I18n().Language() +``` + +## `Cli` + +`Cli` exposes the command registry through a terminal-facing API. + +```go +c.Cli().SetBanner(func(_ *core.Cli) string { + return "Agent Workbench" +}) + +r := c.Cli().Run("workspace", "create", "--name=alpha") +``` + +Use [commands.md](commands.md) for the full command and flag model. diff --git a/pkg/lib/workspace/default/.core/reference/docs/testing.md b/pkg/lib/workspace/default/.core/reference/docs/testing.md new file mode 100644 index 0000000..656634a --- /dev/null +++ b/pkg/lib/workspace/default/.core/reference/docs/testing.md @@ -0,0 +1,118 @@ +--- +title: Testing +description: Test naming and testing patterns used by CoreGO. +--- + +# Testing + +The repository uses `github.com/stretchr/testify/assert` and a simple AX-friendly naming pattern. + +## Test Names + +Use: + +- `_Good` for expected success +- `_Bad` for expected failure +- `_Ugly` for panics, degenerate input, and edge behavior + +Examples from this repository: + +```go +func TestNew_Good(t *testing.T) {} +func TestService_Register_Duplicate_Bad(t *testing.T) {} +func TestCore_Must_Ugly(t *testing.T) {} +``` + +## Start with a Small Core + +```go +c := core.New(core.Options{ + {Key: "name", Value: "test-core"}, +}) +``` + +Then register only the pieces your test needs. + +## Test a Service + +```go +started := false + +c.Service("audit", core.Service{ + OnStart: func() core.Result { + started = true + return core.Result{OK: true} + }, +}) + +r := c.ServiceStartup(context.Background(), nil) +assert.True(t, r.OK) +assert.True(t, started) +``` + +## Test a Command + +```go +c.Command("greet", core.Command{ + Action: func(opts core.Options) core.Result { + return core.Result{Value: "hello " + opts.String("name"), OK: true} + }, +}) + +r := c.Cli().Run("greet", "--name=world") +assert.True(t, r.OK) +assert.Equal(t, "hello world", r.Value) +``` + +## Test a Query or Task + +```go +c.RegisterQuery(func(_ *core.Core, q core.Query) core.Result { + if q == "ping" { + return core.Result{Value: "pong", OK: true} + } + return core.Result{} +}) + +assert.Equal(t, "pong", c.QUERY("ping").Value) +``` + +```go +c.RegisterTask(func(_ *core.Core, t core.Task) core.Result { + if t == "compute" { + return core.Result{Value: 42, OK: true} + } + return core.Result{} +}) + +assert.Equal(t, 42, c.PERFORM("compute").Value) +``` + +## Test Async Work + +For `PerformAsync`, observe completion through the action bus. + +```go +completed := make(chan core.ActionTaskCompleted, 1) + +c.RegisterAction(func(_ *core.Core, msg core.Message) core.Result { + if event, ok := msg.(core.ActionTaskCompleted); ok { + completed <- event + } + return core.Result{OK: true} +}) +``` + +Then wait with normal Go test tools such as channels, timers, or `assert.Eventually`. + +## Use Real Temporary Paths + +When testing `Fs`, `Data.Extract`, or other I/O helpers, use `t.TempDir()` and create realistic paths instead of mocking the filesystem by default. + +## Repository Commands + +```bash +core go test +core go test --run TestPerformAsync_Good +go test ./... +```