chore: move plans from docs/ to tasks/
Consolidate planning documents in tasks/plans/ directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
cbd8ea87df
commit
c8124b7a88
5 changed files with 0 additions and 2863 deletions
|
|
@ -1,185 +0,0 @@
|
|||
# CLI Commands Registration Design
|
||||
|
||||
## Overview
|
||||
|
||||
Move CLI commands from `cmd/` into self-registering packages in `pkg/`. This enables build variants with reduced attack surface - only compiled code exists in the binary.
|
||||
|
||||
## Pattern
|
||||
|
||||
Same pattern as `i18n.RegisterLocales()`:
|
||||
- Packages register themselves during `init()`
|
||||
- Registration is stored until `cli.Init()` runs
|
||||
- Build tags control which packages are imported
|
||||
|
||||
## Registration API
|
||||
|
||||
```go
|
||||
// pkg/cli/commands.go
|
||||
|
||||
type CommandRegistration func(root *cobra.Command)
|
||||
|
||||
var (
|
||||
registeredCommands []CommandRegistration
|
||||
registeredCommandsMu sync.Mutex
|
||||
)
|
||||
|
||||
// RegisterCommands registers a function that adds commands to the CLI.
|
||||
func RegisterCommands(fn CommandRegistration) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
registeredCommands = append(registeredCommands, fn)
|
||||
}
|
||||
|
||||
func attachRegisteredCommands(root *cobra.Command) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
for _, fn := range registeredCommands {
|
||||
fn(root)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration with Core.App
|
||||
|
||||
The CLI stores `rootCmd` in `core.App`, unifying GUI and CLI under the same pattern:
|
||||
|
||||
```go
|
||||
// pkg/cli/runtime.go
|
||||
|
||||
func Init(opts Options) error {
|
||||
once.Do(func() {
|
||||
rootCmd := &cobra.Command{
|
||||
Use: opts.AppName,
|
||||
Version: opts.Version,
|
||||
}
|
||||
|
||||
attachRegisteredCommands(rootCmd)
|
||||
|
||||
c, err := framework.New(
|
||||
framework.WithApp(rootCmd),
|
||||
// ... services ...
|
||||
)
|
||||
// ...
|
||||
})
|
||||
return initErr
|
||||
}
|
||||
|
||||
func RootCmd() *cobra.Command {
|
||||
return framework.App().(*cobra.Command)
|
||||
}
|
||||
|
||||
func Execute() error {
|
||||
return RootCmd().Execute()
|
||||
}
|
||||
```
|
||||
|
||||
## Package Structure
|
||||
|
||||
Commands move from `cmd/` to `pkg/` with a `cmd.go` file:
|
||||
|
||||
```
|
||||
pkg/
|
||||
├── php/
|
||||
│ ├── i18n.go # registers locales
|
||||
│ ├── cmd.go # registers commands
|
||||
│ ├── locales/
|
||||
│ └── ...
|
||||
├── dev/
|
||||
│ ├── cmd.go # registers commands
|
||||
│ └── ...
|
||||
├── cli/
|
||||
│ ├── commands.go # RegisterCommands API
|
||||
│ ├── runtime.go # Init, Execute
|
||||
│ └── ...
|
||||
```
|
||||
|
||||
Each `cmd.go`:
|
||||
|
||||
```go
|
||||
// pkg/php/cmd.go
|
||||
package php
|
||||
|
||||
import "github.com/host-uk/core/pkg/cli"
|
||||
|
||||
func init() {
|
||||
cli.RegisterCommands(AddCommands)
|
||||
}
|
||||
|
||||
func AddCommands(root *cobra.Command) {
|
||||
// ... existing command setup ...
|
||||
}
|
||||
```
|
||||
|
||||
## Build Variants
|
||||
|
||||
Import files with build tags in `cmd/variants/`:
|
||||
|
||||
```
|
||||
cmd/
|
||||
├── main.go
|
||||
└── variants/
|
||||
├── full.go # default: all packages
|
||||
├── ci.go # CI/release only
|
||||
├── php.go # PHP tooling only
|
||||
└── minimal.go # core only
|
||||
```
|
||||
|
||||
```go
|
||||
// cmd/variants/full.go
|
||||
//go:build !ci && !php && !minimal
|
||||
|
||||
package variants
|
||||
|
||||
import (
|
||||
_ "github.com/host-uk/core/pkg/ai"
|
||||
_ "github.com/host-uk/core/pkg/build"
|
||||
_ "github.com/host-uk/core/pkg/ci"
|
||||
_ "github.com/host-uk/core/pkg/dev"
|
||||
_ "github.com/host-uk/core/pkg/docs"
|
||||
_ "github.com/host-uk/core/pkg/doctor"
|
||||
_ "github.com/host-uk/core/pkg/go"
|
||||
_ "github.com/host-uk/core/pkg/php"
|
||||
_ "github.com/host-uk/core/pkg/pkg"
|
||||
_ "github.com/host-uk/core/pkg/sdk"
|
||||
_ "github.com/host-uk/core/pkg/setup"
|
||||
_ "github.com/host-uk/core/pkg/test"
|
||||
_ "github.com/host-uk/core/pkg/vm"
|
||||
)
|
||||
```
|
||||
|
||||
```go
|
||||
// cmd/variants/ci.go
|
||||
//go:build ci
|
||||
|
||||
package variants
|
||||
|
||||
import (
|
||||
_ "github.com/host-uk/core/pkg/build"
|
||||
_ "github.com/host-uk/core/pkg/ci"
|
||||
_ "github.com/host-uk/core/pkg/doctor"
|
||||
_ "github.com/host-uk/core/pkg/sdk"
|
||||
)
|
||||
```
|
||||
|
||||
## Build Commands
|
||||
|
||||
- `go build` → full variant (default)
|
||||
- `go build -tags ci` → CI variant
|
||||
- `go build -tags php` → PHP-only variant
|
||||
|
||||
## Benefits
|
||||
|
||||
1. **Smaller attack surface** - only compiled code exists in binary
|
||||
2. **Self-registering packages** - same pattern as `i18n.RegisterLocales()`
|
||||
3. **Uses existing `core.App`** - no new framework concepts
|
||||
4. **Simple build variants** - just add `-tags` flag
|
||||
5. **Defence in depth** - no code = no vulnerabilities
|
||||
|
||||
## Migration Steps
|
||||
|
||||
1. Add `RegisterCommands()` to `pkg/cli/commands.go`
|
||||
2. Update `pkg/cli/runtime.go` to use `core.App` for rootCmd
|
||||
3. Move each `cmd/*` package to `pkg/*/cmd.go`
|
||||
4. Create `cmd/variants/` with build tag files
|
||||
5. Simplify `cmd/main.go` to minimal entry point
|
||||
6. Remove old `cmd/core_dev.go` and `cmd/core_ci.go`
|
||||
|
|
@ -1,373 +0,0 @@
|
|||
# Core Framework IPC Design
|
||||
|
||||
> Design document for refactoring CLI commands to use the Core framework's IPC system.
|
||||
|
||||
## Overview
|
||||
|
||||
The Core framework provides a dependency injection and inter-process communication (IPC) system originally designed for orchestrating services. This design extends the framework with request/response patterns and applies it to CLI commands.
|
||||
|
||||
Commands build "worker bundles" - sandboxed Core instances with specific services. The bundle configuration acts as a permissions layer: if a service isn't registered, that capability isn't available.
|
||||
|
||||
## Dispatch Patterns
|
||||
|
||||
Four patterns for service communication:
|
||||
|
||||
| Method | Behaviour | Returns | Use Case |
|
||||
|--------|-----------|---------|----------|
|
||||
| `ACTION` | Broadcast to all handlers | `error` | Events, notifications |
|
||||
| `QUERY` | First responder wins | `(any, bool, error)` | Get data |
|
||||
| `QUERYALL` | Broadcast, collect all | `([]any, error)` | Aggregate from multiple services |
|
||||
| `PERFORM` | First responder executes | `(any, bool, error)` | Execute a task with side effects |
|
||||
|
||||
### ACTION (existing)
|
||||
|
||||
Fire-and-forget broadcast. All registered handlers receive the message. Errors are aggregated.
|
||||
|
||||
```go
|
||||
c.ACTION(ActionServiceStartup{})
|
||||
```
|
||||
|
||||
### QUERY (new)
|
||||
|
||||
Request data from services. Stops at first handler that returns `handled=true`.
|
||||
|
||||
```go
|
||||
result, handled, err := c.QUERY(git.QueryStatus{Paths: paths})
|
||||
if !handled {
|
||||
// No service registered to handle this query
|
||||
}
|
||||
statuses := result.([]git.RepoStatus)
|
||||
```
|
||||
|
||||
### QUERYALL (new)
|
||||
|
||||
Broadcast query to all handlers, collect all responses. Useful for aggregating results from multiple services (e.g., multiple QA/lint tools).
|
||||
|
||||
```go
|
||||
results, err := c.QUERYALL(qa.QueryLint{Paths: paths})
|
||||
for _, r := range results {
|
||||
lint := r.(qa.LintResult)
|
||||
fmt.Printf("%s found %d issues\n", lint.Tool, len(lint.Issues))
|
||||
}
|
||||
```
|
||||
|
||||
### PERFORM (new)
|
||||
|
||||
Execute a task with side effects. Stops at first handler that returns `handled=true`.
|
||||
|
||||
```go
|
||||
result, handled, err := c.PERFORM(agentic.TaskCommit{
|
||||
Path: repo.Path,
|
||||
Name: repo.Name,
|
||||
})
|
||||
if !handled {
|
||||
// Agentic service not in bundle - commits not available
|
||||
}
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ cmd/dev/dev_work.go │
|
||||
│ - Builds worker bundle │
|
||||
│ - Triggers PERFORM(TaskWork{}) │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────────────────┐
|
||||
│ cmd/dev/bundles.go │
|
||||
│ - NewWorkBundle() - git + agentic + dev │
|
||||
│ - NewStatusBundle() - git + dev only │
|
||||
│ - Bundle config = permissions │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────▼───────────────────────────────────────┐
|
||||
│ pkg/dev/service.go │
|
||||
│ - Orchestrates workflow │
|
||||
│ - QUERY(git.QueryStatus{}) │
|
||||
│ - PERFORM(agentic.TaskCommit{}) │
|
||||
│ - PERFORM(git.TaskPush{}) │
|
||||
└─────────────────────┬───────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────┴─────────────┐
|
||||
▼ ▼
|
||||
┌───────────────────┐ ┌───────────────────┐
|
||||
│ pkg/git/service │ │ pkg/agentic/svc │
|
||||
│ │ │ │
|
||||
│ Queries: │ │ Tasks: │
|
||||
│ - QueryStatus │ │ - TaskCommit │
|
||||
│ - QueryDirtyRepos │ │ - TaskPrompt │
|
||||
│ - QueryAheadRepos │ │ │
|
||||
│ │ │ │
|
||||
│ Tasks: │ │ │
|
||||
│ - TaskPush │ │ │
|
||||
│ - TaskPull │ │ │
|
||||
└───────────────────┘ └───────────────────┘
|
||||
```
|
||||
|
||||
## Permissions Model
|
||||
|
||||
Permissions are implicit through bundle configuration:
|
||||
|
||||
```go
|
||||
// Full capabilities - can commit and push
|
||||
func NewWorkBundle(opts WorkBundleOptions) (*framework.Runtime, error) {
|
||||
return framework.NewWithFactories(nil, map[string]framework.ServiceFactory{
|
||||
"dev": func() (any, error) { return dev.NewService(opts.Dev)(nil) },
|
||||
"git": func() (any, error) { return git.NewService(opts.Git)(nil) },
|
||||
"agentic": func() (any, error) { return agentic.NewService(opts.Agentic)(nil) },
|
||||
})
|
||||
}
|
||||
|
||||
// Read-only - status queries only, no commits
|
||||
func NewStatusBundle(opts StatusBundleOptions) (*framework.Runtime, error) {
|
||||
return framework.NewWithFactories(nil, map[string]framework.ServiceFactory{
|
||||
"dev": func() (any, error) { return dev.NewService(opts.Dev)(nil) },
|
||||
"git": func() (any, error) { return git.NewService(opts.Git)(nil) },
|
||||
// No agentic service - TaskCommit will be unhandled
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
Service options provide fine-grained control:
|
||||
|
||||
```go
|
||||
agentic.NewService(agentic.ServiceOptions{
|
||||
AllowEdit: false, // Claude can only use read-only tools
|
||||
})
|
||||
|
||||
agentic.NewService(agentic.ServiceOptions{
|
||||
AllowEdit: true, // Claude can use Write/Edit tools
|
||||
})
|
||||
```
|
||||
|
||||
**Key principle**: Code never checks permissions explicitly. It dispatches actions and either they're handled or they're not. The bundle configuration is the single source of truth for what's allowed.
|
||||
|
||||
## Framework Changes
|
||||
|
||||
### New Types (interfaces.go)
|
||||
|
||||
```go
|
||||
type Query interface{}
|
||||
type Task interface{}
|
||||
|
||||
type QueryHandler func(*Core, Query) (any, bool, error)
|
||||
type TaskHandler func(*Core, Task) (any, bool, error)
|
||||
```
|
||||
|
||||
### Core Struct Additions (interfaces.go)
|
||||
|
||||
```go
|
||||
type Core struct {
|
||||
// ... existing fields
|
||||
|
||||
queryMu sync.RWMutex
|
||||
queryHandlers []QueryHandler
|
||||
|
||||
taskMu sync.RWMutex
|
||||
taskHandlers []TaskHandler
|
||||
}
|
||||
```
|
||||
|
||||
### New Methods (core.go)
|
||||
|
||||
```go
|
||||
// QUERY - first responder wins
|
||||
func (c *Core) QUERY(q Query) (any, bool, error)
|
||||
|
||||
// QUERYALL - broadcast, collect all responses
|
||||
func (c *Core) QUERYALL(q Query) ([]any, error)
|
||||
|
||||
// PERFORM - first responder executes
|
||||
func (c *Core) PERFORM(t Task) (any, bool, error)
|
||||
|
||||
// Registration
|
||||
func (c *Core) RegisterQuery(h QueryHandler)
|
||||
func (c *Core) RegisterTask(h TaskHandler)
|
||||
```
|
||||
|
||||
### Re-exports (framework.go)
|
||||
|
||||
```go
|
||||
type Query = core.Query
|
||||
type Task = core.Task
|
||||
type QueryHandler = core.QueryHandler
|
||||
type TaskHandler = core.TaskHandler
|
||||
```
|
||||
|
||||
## Service Implementation Pattern
|
||||
|
||||
Services register handlers during startup:
|
||||
|
||||
```go
|
||||
func (s *Service) OnStartup(ctx context.Context) error {
|
||||
s.Core().RegisterAction(s.handleAction)
|
||||
s.Core().RegisterQuery(s.handleQuery)
|
||||
s.Core().RegisterTask(s.handleTask)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) handleQuery(c *framework.Core, q framework.Query) (any, bool, error) {
|
||||
switch m := q.(type) {
|
||||
case QueryStatus:
|
||||
result := s.getStatus(m.Paths, m.Names)
|
||||
return result, true, nil
|
||||
case QueryDirtyRepos:
|
||||
return s.DirtyRepos(), true, nil
|
||||
}
|
||||
return nil, false, nil // Not handled
|
||||
}
|
||||
|
||||
func (s *Service) handleTask(c *framework.Core, t framework.Task) (any, bool, error) {
|
||||
switch m := t.(type) {
|
||||
case TaskPush:
|
||||
err := s.push(m.Path)
|
||||
return nil, true, err
|
||||
case TaskPull:
|
||||
err := s.pull(m.Path)
|
||||
return nil, true, err
|
||||
}
|
||||
return nil, false, nil // Not handled
|
||||
}
|
||||
```
|
||||
|
||||
## Git Service Queries & Tasks
|
||||
|
||||
```go
|
||||
// pkg/git/queries.go
|
||||
type QueryStatus struct {
|
||||
Paths []string
|
||||
Names map[string]string
|
||||
}
|
||||
|
||||
type QueryDirtyRepos struct{}
|
||||
type QueryAheadRepos struct{}
|
||||
|
||||
// pkg/git/tasks.go
|
||||
type TaskPush struct {
|
||||
Path string
|
||||
Name string
|
||||
}
|
||||
|
||||
type TaskPull struct {
|
||||
Path string
|
||||
Name string
|
||||
}
|
||||
|
||||
type TaskPushMultiple struct {
|
||||
Paths []string
|
||||
Names map[string]string
|
||||
}
|
||||
```
|
||||
|
||||
## Agentic Service Tasks
|
||||
|
||||
```go
|
||||
// pkg/agentic/tasks.go
|
||||
type TaskCommit struct {
|
||||
Path string
|
||||
Name string
|
||||
CanEdit bool
|
||||
}
|
||||
|
||||
type TaskPrompt struct {
|
||||
Prompt string
|
||||
WorkDir string
|
||||
AllowedTools []string
|
||||
}
|
||||
```
|
||||
|
||||
## Dev Workflow Service
|
||||
|
||||
```go
|
||||
// pkg/dev/tasks.go
|
||||
type TaskWork struct {
|
||||
RegistryPath string
|
||||
StatusOnly bool
|
||||
AutoCommit bool
|
||||
}
|
||||
|
||||
type TaskCommitAll struct {
|
||||
RegistryPath string
|
||||
}
|
||||
|
||||
type TaskPushAll struct {
|
||||
RegistryPath string
|
||||
Force bool
|
||||
}
|
||||
```
|
||||
|
||||
## Command Simplification
|
||||
|
||||
Before (dev_work.go - 327 lines of orchestration):
|
||||
|
||||
```go
|
||||
func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||
// Load registry
|
||||
// Get git status
|
||||
// Display table
|
||||
// Loop dirty repos, shell out to claude
|
||||
// Re-check status
|
||||
// Confirm push
|
||||
// Push repos
|
||||
// Handle diverged branches
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
After (dev_work.go - minimal):
|
||||
|
||||
```go
|
||||
func runWork(registryPath string, statusOnly, autoCommit bool) error {
|
||||
bundle, err := NewWorkBundle(WorkBundleOptions{
|
||||
RegistryPath: registryPath,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
bundle.Core.ServiceStartup(ctx, nil)
|
||||
defer bundle.Core.ServiceShutdown(ctx)
|
||||
|
||||
_, _, err = bundle.Core.PERFORM(dev.TaskWork{
|
||||
StatusOnly: statusOnly,
|
||||
AutoCommit: autoCommit,
|
||||
})
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
All orchestration logic moves to `pkg/dev/service.go` where it can be tested independently and reused.
|
||||
|
||||
## Implementation Tasks
|
||||
|
||||
1. **Framework Core** - Add Query, Task types and QUERY/QUERYALL/PERFORM methods
|
||||
2. **Framework Re-exports** - Update framework.go with new types
|
||||
3. **Git Service** - Add query and task handlers
|
||||
4. **Agentic Service** - Add task handlers
|
||||
5. **Dev Service** - Create workflow orchestration service
|
||||
6. **Bundles** - Create bundle factories in cmd/dev/
|
||||
7. **Commands** - Simplify cmd/dev/*.go to use bundles
|
||||
|
||||
## Future: CLI-Wide Runtime
|
||||
|
||||
Phase 2 will add a CLI-wide Core instance that:
|
||||
|
||||
- Handles signals (SIGINT, SIGTERM)
|
||||
- Manages UI state
|
||||
- Spawns worker bundles as "interactable elements"
|
||||
- Provides cross-bundle communication
|
||||
|
||||
Worker bundles become sandboxed children of the CLI runtime, with the runtime controlling what capabilities each bundle receives.
|
||||
|
||||
## Testing
|
||||
|
||||
Each layer is independently testable:
|
||||
|
||||
- **Framework**: Unit tests for QUERY/QUERYALL/PERFORM dispatch
|
||||
- **Services**: Unit tests with mock Core instances
|
||||
- **Bundles**: Integration tests with real services
|
||||
- **Commands**: E2E tests via CLI invocation
|
||||
|
||||
The permission model is testable by creating bundles with/without specific services and verifying behaviour.
|
||||
|
|
@ -1,134 +0,0 @@
|
|||
# i18n Package Refactor Design
|
||||
|
||||
## Goal
|
||||
Refactor pkg/i18n to be extensible without breaking changes in future. Based on Gemini review recommendations.
|
||||
|
||||
## File Structure
|
||||
|
||||
### Renamed/Merged
|
||||
| Current | New | Reason |
|
||||
|---------|-----|--------|
|
||||
| `interfaces.go` | `types.go` | Contains types, not interfaces |
|
||||
| `mutate.go` | `loader.go` | Loads/flattens JSON |
|
||||
| `actions.go` | `hooks.go` | Missing key callbacks |
|
||||
| `checks.go` | (merge into loader.go) | Loading helpers |
|
||||
| `mode.go` | (merge into types.go) | Just one type |
|
||||
|
||||
### New Files
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `handler.go` | KeyHandler interface + built-in handlers |
|
||||
| `context.go` | TranslationContext + C() helper |
|
||||
|
||||
### Unchanged
|
||||
`grammar.go`, `language.go`, `localise.go`, `debug.go`, `numbers.go`, `time.go`, `i18n.go`, `intents.go`, `compose.go`, `transform.go`
|
||||
|
||||
## Interfaces
|
||||
|
||||
### KeyHandler
|
||||
```go
|
||||
// KeyHandler processes translation keys before standard lookup.
|
||||
type KeyHandler interface {
|
||||
Match(key string) bool
|
||||
Handle(key string, args []any, next func() string) string
|
||||
}
|
||||
```
|
||||
|
||||
Built-in handlers:
|
||||
- `LabelHandler` - `i18n.label.*` → "Status:"
|
||||
- `ProgressHandler` - `i18n.progress.*` → "Building..."
|
||||
- `CountHandler` - `i18n.count.*` → "5 files"
|
||||
- `NumericHandler` - `i18n.numeric.*` → formatted numbers
|
||||
- `DoneHandler` - `i18n.done.*` → "File deleted"
|
||||
- `FailHandler` - `i18n.fail.*` → "Failed to delete file"
|
||||
|
||||
### Loader
|
||||
```go
|
||||
// Loader provides translation data to the Service.
|
||||
type Loader interface {
|
||||
Load(lang string) (map[string]Message, *GrammarData, error)
|
||||
Languages() []string
|
||||
}
|
||||
```
|
||||
|
||||
Built-in: `FSLoader` for embedded/filesystem JSON.
|
||||
|
||||
### TranslationContext
|
||||
```go
|
||||
type TranslationContext struct {
|
||||
Context string
|
||||
Gender string
|
||||
Formality Formality
|
||||
Extra map[string]any
|
||||
}
|
||||
|
||||
func C(context string) *TranslationContext
|
||||
```
|
||||
|
||||
## Service Changes
|
||||
|
||||
```go
|
||||
type Service struct {
|
||||
loader Loader
|
||||
messages map[string]map[string]Message
|
||||
grammar map[string]*GrammarData
|
||||
currentLang string
|
||||
fallbackLang string
|
||||
formality Formality
|
||||
mode Mode
|
||||
debug bool
|
||||
handlers []KeyHandler
|
||||
mu sync.RWMutex
|
||||
}
|
||||
```
|
||||
|
||||
### Constructors
|
||||
```go
|
||||
func New() (*Service, error)
|
||||
func NewWithLoader(loader Loader, opts ...Option) (*Service, error)
|
||||
|
||||
type Option func(*Service)
|
||||
func WithDefaultHandlers() Option
|
||||
func WithFallback(lang string) Option
|
||||
func WithFormality(f Formality) Option
|
||||
```
|
||||
|
||||
### T() Flow
|
||||
1. Parse args → extract Context, Subject, data
|
||||
2. Run handler chain (each can handle or call next)
|
||||
3. Standard lookup with context suffix fallback
|
||||
|
||||
## Public API
|
||||
|
||||
### Keep
|
||||
- `T(key, args...)`, `Raw(key, args...)`
|
||||
- `S(noun, value)` - Subject builder
|
||||
- `SetLanguage()`, `CurrentLanguage()`, `SetMode()`, `CurrentMode()`
|
||||
- `SetFormality()`, `SetDebug()`, `Direction()`, `IsRTL()`
|
||||
- Grammar: `PastTense()`, `Gerund()`, `Pluralize()`, `Article()`, `Title()`, `Label()`, `Progress()`
|
||||
|
||||
### Add
|
||||
- `C(context)` - Context builder
|
||||
- `NewWithLoader()` - Custom loader support
|
||||
- `AddHandler()`, `PrependHandler()` - Custom handlers
|
||||
|
||||
### Remove (No Aliases)
|
||||
- `NewSubject()` - use `S()`
|
||||
- `N()` - use `T("i18n.numeric.*")`
|
||||
|
||||
## Breaking Changes
|
||||
- Constructor signature changes
|
||||
- Internal file reorganisation
|
||||
- No backwards compatibility layer
|
||||
|
||||
## Implementation Order
|
||||
1. Create new files (types.go, handler.go, loader.go, context.go, hooks.go)
|
||||
2. Move types from interfaces.go → types.go
|
||||
3. Implement Loader interface + FSLoader
|
||||
4. Implement KeyHandler interface + built-in handlers
|
||||
5. Implement TranslationContext
|
||||
6. Update Service struct + constructors
|
||||
7. Update T() to use handler chain
|
||||
8. Update package-level functions in i18n.go
|
||||
9. Delete old files
|
||||
10. Update tests
|
||||
|
|
@ -1,486 +0,0 @@
|
|||
# Semantic i18n System Design
|
||||
|
||||
## Overview
|
||||
|
||||
Extend the i18n system beyond simple key-value translation to support **semantic intents** that encode meaning, enabling:
|
||||
|
||||
- Composite translations from reusable fragments
|
||||
- Grammatical awareness (gender, plurality, formality)
|
||||
- CLI prompt integration with localized options
|
||||
- Reduced calling code complexity
|
||||
|
||||
## Goals
|
||||
|
||||
1. **Simple cases stay simple** - `_("key")` works as expected
|
||||
2. **Complex cases become declarative** - Intent drives output, not caller logic
|
||||
3. **Translators have power** - Grammar rules live in translations, not code
|
||||
4. **CLI integration** - Questions, confirmations, choices are first-class
|
||||
|
||||
## API Design
|
||||
|
||||
### Function Reference (Stable API)
|
||||
|
||||
These function names are **permanent** - choose carefully, they cannot change.
|
||||
|
||||
| Function | Alias | Purpose |
|
||||
|----------|-------|---------|
|
||||
| `_()` | - | Simple gettext-style lookup |
|
||||
| `T()` | `C()` | Compose - semantic intent resolution |
|
||||
| `S()` | `Subject()` | Create typed subject with metadata |
|
||||
|
||||
### Simple Translation: `_()`
|
||||
|
||||
Standard gettext-style lookup. No magic, just key → value.
|
||||
|
||||
```go
|
||||
i18n._("cli.success") // "Success"
|
||||
i18n._("common.label.error") // "Error:"
|
||||
i18n._("common.error.failed", map[string]any{"Action": "load"}) // "Failed to load"
|
||||
```
|
||||
|
||||
### Compose: `T()` / `C()`
|
||||
|
||||
Semantic intent resolution. Takes an intent key from `core.*` namespace and returns a `Composed` result with multiple output forms.
|
||||
|
||||
```go
|
||||
// Full form
|
||||
result := i18n.T("core.delete", i18n.S("file", path))
|
||||
result := i18n.C("core.delete", i18n.S("file", path)) // Alias
|
||||
|
||||
// Result contains all forms
|
||||
result.Question // "Delete /path/to/file.txt?"
|
||||
result.Confirm // "Really delete /path/to/file.txt?"
|
||||
result.Success // "File deleted"
|
||||
result.Failure // "Failed to delete file"
|
||||
result.Meta // IntentMeta{Dangerous: true, Default: "no", ...}
|
||||
```
|
||||
|
||||
### Subject: `S()` / `Subject()`
|
||||
|
||||
Creates a typed subject with optional metadata for grammar rules.
|
||||
|
||||
```go
|
||||
// Simple
|
||||
i18n.S("file", "/path/to/file.txt")
|
||||
|
||||
// With count (plurality)
|
||||
i18n.S("commit", commits).Count(len(commits))
|
||||
|
||||
// With gender (for gendered languages)
|
||||
i18n.S("user", name).Gender("female")
|
||||
|
||||
// Chained
|
||||
i18n.S("file", path).Count(3).In("/project")
|
||||
```
|
||||
|
||||
### Type Signatures
|
||||
|
||||
```go
|
||||
// Simple lookup
|
||||
func _(key string, args ...any) string
|
||||
|
||||
// Compose (T and C are aliases)
|
||||
func T(intent string, subject *Subject) *Composed
|
||||
func C(intent string, subject *Subject) *Composed
|
||||
|
||||
// Subject builder
|
||||
func S(noun string, value any) *Subject
|
||||
func Subject(noun string, value any) *Subject
|
||||
|
||||
// Composed result
|
||||
type Composed struct {
|
||||
Question string
|
||||
Confirm string
|
||||
Success string
|
||||
Failure string
|
||||
Meta IntentMeta
|
||||
}
|
||||
|
||||
// Subject with metadata
|
||||
type Subject struct {
|
||||
Noun string
|
||||
Value any
|
||||
count int
|
||||
gender string
|
||||
// ... other metadata
|
||||
}
|
||||
|
||||
func (s *Subject) Count(n int) *Subject
|
||||
func (s *Subject) Gender(g string) *Subject
|
||||
func (s *Subject) In(location string) *Subject
|
||||
|
||||
// Intent metadata
|
||||
type IntentMeta struct {
|
||||
Type string // "action", "question", "info"
|
||||
Verb string // Reference to common.verb.*
|
||||
Dangerous bool // Requires confirmation
|
||||
Default string // "yes" or "no"
|
||||
Supports []string // Extra options like "all", "skip"
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Integration
|
||||
|
||||
The CLI package uses `T()` internally for prompts:
|
||||
|
||||
```go
|
||||
// Confirm uses T() internally
|
||||
confirmed := cli.Confirm("core.delete", i18n.S("file", path))
|
||||
// Internally: result := i18n.T("core.delete", subject)
|
||||
// Displays: result.Question + localized [y/N]
|
||||
// Returns: bool
|
||||
|
||||
// Question with options
|
||||
choice := cli.Question("core.save", i18n.S("changes", 3).Count(3), cli.Options{
|
||||
Default: "yes",
|
||||
Extra: []string{"all"},
|
||||
})
|
||||
// Displays: "Save 3 changes? [a/y/N]"
|
||||
// Returns: "yes" | "no" | "all"
|
||||
|
||||
// Choice from list
|
||||
selected := cli.Choose("core.select.branch", branches)
|
||||
// Displays localized prompt with arrow selection
|
||||
```
|
||||
|
||||
### cli.Confirm()
|
||||
|
||||
```go
|
||||
func Confirm(intent string, subject *i18n.Subject, opts ...ConfirmOption) bool
|
||||
|
||||
// Options
|
||||
cli.DefaultYes() // Default to yes instead of no
|
||||
cli.DefaultNo() // Explicit default no
|
||||
cli.Required() // No default, must choose
|
||||
cli.Timeout(30*time.Second) // Auto-select default after timeout
|
||||
```
|
||||
|
||||
### cli.Question()
|
||||
|
||||
```go
|
||||
func Question(intent string, subject *i18n.Subject, opts ...QuestionOption) string
|
||||
|
||||
// Options
|
||||
cli.Extra("all", "skip") // Extra options beyond y/n
|
||||
cli.Default("yes") // Which option is default
|
||||
cli.Validate(func(s string) bool) // Custom validation
|
||||
```
|
||||
|
||||
### cli.Choose()
|
||||
|
||||
```go
|
||||
func Choose[T any](intent string, items []T, opts ...ChooseOption) T
|
||||
|
||||
// Options
|
||||
cli.Display(func(T) string) // How to display each item
|
||||
cli.Filter() // Enable fuzzy filtering
|
||||
cli.Multi() // Allow multiple selection
|
||||
```
|
||||
|
||||
## Reserved Namespaces
|
||||
|
||||
### `common.*` - Reusable Fragments
|
||||
|
||||
Atomic translation units that can be composed:
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"verb": {
|
||||
"edit": "edit",
|
||||
"delete": "delete",
|
||||
"create": "create",
|
||||
"save": "save",
|
||||
"update": "update",
|
||||
"commit": "commit"
|
||||
},
|
||||
"noun": {
|
||||
"file": { "one": "file", "other": "files" },
|
||||
"commit": { "one": "commit", "other": "commits" },
|
||||
"change": { "one": "change", "other": "changes" }
|
||||
},
|
||||
"article": {
|
||||
"the": "the",
|
||||
"a": { "one": "a", "vowel": "an" }
|
||||
},
|
||||
"prompt": {
|
||||
"yes": "y",
|
||||
"no": "n",
|
||||
"all": "a",
|
||||
"skip": "s",
|
||||
"quit": "q"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `core.*` - Semantic Intents
|
||||
|
||||
Intents encode meaning and behavior:
|
||||
|
||||
```json
|
||||
{
|
||||
"core": {
|
||||
"edit": {
|
||||
"_meta": {
|
||||
"type": "action",
|
||||
"verb": "common.verb.edit",
|
||||
"dangerous": false
|
||||
},
|
||||
"question": "Should I {{.Verb}} {{.Subject}}?",
|
||||
"confirm": "{{.Verb | title}} {{.Subject}}?",
|
||||
"success": "{{.Subject | title}} {{.Verb | past}}",
|
||||
"failure": "Failed to {{.Verb}} {{.Subject}}"
|
||||
},
|
||||
"delete": {
|
||||
"_meta": {
|
||||
"type": "action",
|
||||
"verb": "common.verb.delete",
|
||||
"dangerous": true,
|
||||
"default": "no"
|
||||
},
|
||||
"question": "Delete {{.Subject}}? This cannot be undone.",
|
||||
"confirm": "Really delete {{.Subject}}?",
|
||||
"success": "{{.Subject | title}} deleted",
|
||||
"failure": "Failed to delete {{.Subject}}"
|
||||
},
|
||||
"save": {
|
||||
"_meta": {
|
||||
"type": "action",
|
||||
"verb": "common.verb.save",
|
||||
"supports": ["all", "skip"]
|
||||
},
|
||||
"question": "Save {{.Subject}}?",
|
||||
"success": "{{.Subject | title}} saved"
|
||||
},
|
||||
"commit": {
|
||||
"_meta": {
|
||||
"type": "action",
|
||||
"verb": "common.verb.commit",
|
||||
"dangerous": false
|
||||
},
|
||||
"question": "Commit {{.Subject}}?",
|
||||
"success": "{{.Subject | title}} committed",
|
||||
"failure": "Failed to commit {{.Subject}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Template Functions
|
||||
|
||||
Available in translation templates:
|
||||
|
||||
| Function | Description | Example |
|
||||
|----------|-------------|---------|
|
||||
| `title` | Title case | `{{.Name \| title}}` → "Hello World" |
|
||||
| `lower` | Lower case | `{{.Name \| lower}}` → "hello world" |
|
||||
| `upper` | Upper case | `{{.Name \| upper}}` → "HELLO WORLD" |
|
||||
| `past` | Past tense verb | `{{.Verb \| past}}` → "edited" |
|
||||
| `plural` | Pluralize noun | `{{.Noun \| plural .Count}}` → "files" |
|
||||
| `article` | Add article | `{{.Noun \| article}}` → "a file" |
|
||||
| `quote` | Wrap in quotes | `{{.Path \| quote}}` → `"/path/to/file"` |
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Foundation
|
||||
1. Define `Composed` and `Subject` types
|
||||
2. Add `S()` / `Subject()` builder
|
||||
3. Add `T()` / `C()` with intent resolution
|
||||
4. Parse `_meta` from JSON
|
||||
5. Add template functions (title, lower, past, etc.)
|
||||
|
||||
### Phase 2: CLI Integration
|
||||
1. Implement `cli.Confirm()` using intents
|
||||
2. Implement `cli.Question()` with options
|
||||
3. Implement `cli.Choose()` for lists
|
||||
4. Localize prompt characters [y/N] → [j/N] etc.
|
||||
|
||||
### Phase 3: Grammar Engine
|
||||
1. Verb conjugation (past tense, etc.)
|
||||
2. Noun plurality with irregular forms
|
||||
3. Article selection (a/an, gender)
|
||||
4. Language-specific rules
|
||||
|
||||
### Phase 4: Extended Languages
|
||||
1. Gender agreement (French, German, etc.)
|
||||
2. Formality levels (Japanese, Korean, etc.)
|
||||
3. Right-to-left support
|
||||
4. Plural forms beyond one/other (Russian, Arabic, etc.)
|
||||
|
||||
## Example: Full Flow
|
||||
|
||||
```go
|
||||
// In cmd/dev/dev_commit.go
|
||||
path := "/Users/dev/project"
|
||||
files := []string{"main.go", "config.yaml"}
|
||||
|
||||
// Old way (hardcoded English, manual prompt handling)
|
||||
fmt.Printf("Commit %d files in %s? [y/N] ", len(files), path)
|
||||
var response string
|
||||
fmt.Scanln(&response)
|
||||
if response != "y" && response != "Y" {
|
||||
return
|
||||
}
|
||||
|
||||
// New way (semantic, localized, integrated)
|
||||
if !cli.Confirm("core.commit", i18n.S("file", path).Count(len(files))) {
|
||||
return
|
||||
}
|
||||
|
||||
// For German user, displays:
|
||||
// "2 Dateien in /Users/dev/project committen? [j/N]"
|
||||
// (note: "j" for "ja" instead of "y" for "yes")
|
||||
```
|
||||
|
||||
## JSON Schema
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"common": {
|
||||
"description": "Reusable translation fragments",
|
||||
"type": "object"
|
||||
},
|
||||
"core": {
|
||||
"description": "Semantic intents with metadata",
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"_meta": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "enum": ["action", "question", "info"] },
|
||||
"verb": { "type": "string" },
|
||||
"dangerous": { "type": "boolean" },
|
||||
"default": { "enum": ["yes", "no"] },
|
||||
"supports": { "type": "array", "items": { "type": "string" } }
|
||||
}
|
||||
},
|
||||
"question": { "type": "string" },
|
||||
"confirm": { "type": "string" },
|
||||
"success": { "type": "string" },
|
||||
"failure": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Grammar Fundamentals
|
||||
|
||||
Parts of speech we need to handle:
|
||||
|
||||
| Part | Role | Example | Transforms |
|
||||
|------|------|---------|------------|
|
||||
| **Verb** | Action | delete, save, commit | tense (past/present), mood (imperative) |
|
||||
| **Noun** | Subject/Object | file, commit, user | plurality, gender, case |
|
||||
| **Article** | Determiner | a/an, the | vowel-awareness, gender agreement |
|
||||
| **Adjective** | Describes noun | modified, new, deleted | gender/number agreement |
|
||||
| **Preposition** | Relation | in, from, to | - |
|
||||
|
||||
### Verb Conjugation
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"verb": {
|
||||
"delete": {
|
||||
"base": "delete",
|
||||
"past": "deleted",
|
||||
"gerund": "deleting",
|
||||
"imperative": "delete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For most English verbs, derive automatically:
|
||||
- `past`: base + "ed" (or irregular lookup)
|
||||
- `gerund`: base + "ing"
|
||||
|
||||
### Noun Handling
|
||||
|
||||
```json
|
||||
{
|
||||
"common": {
|
||||
"noun": {
|
||||
"file": {
|
||||
"one": "file",
|
||||
"other": "files",
|
||||
"gender": "neuter"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Article Selection
|
||||
|
||||
English: a/an based on next word's sound (not letter)
|
||||
- "a file", "an item", "a user", "an hour"
|
||||
|
||||
Other languages: gender agreement (der/die/das, le/la, etc.)
|
||||
|
||||
## DX Improvements
|
||||
|
||||
### 1. Compile-Time Validation
|
||||
- `go generate` checks all `T("core.X")` calls have matching JSON keys
|
||||
- Warns on missing `_meta` fields
|
||||
- Type-checks template variables
|
||||
|
||||
### 2. IDE Support
|
||||
- JSON schema for autocomplete in translation files
|
||||
- Go constants generated from JSON keys: `i18n.CoreDelete` instead of `"core.delete"`
|
||||
|
||||
### 3. Fallback Chain
|
||||
```
|
||||
T("core.delete", subject)
|
||||
→ try core.delete.question
|
||||
→ try core.delete (plain string)
|
||||
→ try common.action.delete
|
||||
→ return "Delete {{.Subject}}?" (hardcoded fallback)
|
||||
```
|
||||
|
||||
### 4. Debug Mode
|
||||
```go
|
||||
i18n.Debug(true) // Shows: [core.delete] Delete file.txt?
|
||||
```
|
||||
|
||||
### 5. Short Subject Syntax
|
||||
```go
|
||||
// Instead of:
|
||||
i18n.T("core.delete", i18n.S("file", path))
|
||||
|
||||
// Allow:
|
||||
i18n.T("core.delete", path) // Infers subject type from intent's expected noun
|
||||
```
|
||||
|
||||
### 6. Fluent Chaining
|
||||
```go
|
||||
i18n.T("core.delete").
|
||||
Subject("file", path).
|
||||
Count(3).
|
||||
Question() // Returns just the question string
|
||||
```
|
||||
|
||||
## Notes for Future Implementation
|
||||
|
||||
- Use `github.com/gertd/go-pluralize` for English plurality
|
||||
- Consider `github.com/nicksnyder/go-i18n` patterns for CLDR plural rules
|
||||
- Store compiled templates in sync.Map for caching
|
||||
- `_meta` parsing happens once at load time, not per-call
|
||||
- CLI prompt chars from `common.prompt.*` - allows `[j/N]` for German
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Verb conjugation library** - Use existing Go library or build custom?
|
||||
2. **Gender detection** - How to infer gender for subjects in gendered languages?
|
||||
3. **Fallback behavior** - What happens when intent metadata is missing?
|
||||
4. **Caching** - Should compiled templates be cached?
|
||||
5. **Validation** - How to validate intent definitions at build time?
|
||||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue