From 71dcf4d717196e7e5b9613f6bb2d4ae9cbc9a584 Mon Sep 17 00:00:00 2001 From: xusihong Date: Fri, 3 Jul 2026 19:07:04 +0800 Subject: [PATCH 1/4] =?UTF-8?q?feat(inspect):=20=E7=AE=80=E5=8C=96?= =?UTF-8?q?=E8=83=BD=E5=8A=9B=E5=9B=BE=E6=9E=84=E5=BB=BA=E5=B9=B6=E6=B7=BB?= =?UTF-8?q?=E5=8A=A0=E7=AC=A6=E5=8F=B7=E5=9B=BE=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除能力节点中的模块、提供者等复杂字段,仅保留基本能力信息 - 删除 capabilityProvider、manifestCapabilityProvider、CapabilityModule、matchingImports 等函数 - 从 constants.go 中移除 providerConfigKey、capabilitySQL、capabilityMongo 等常量定义 - 从 description.go 中移除 CapabilityNode 的 Provider 和 Module 字段 - 添加 SymbolGraph、SymbolNode、SymbolEdge 和 SourceSpan 结构体用于 AI 分析 - 在 Description 中添加 SymbolGraph 和 Diagnostics 字段 - 修改 editSurfaces 函数以支持推断清单场景 - 更新 describeDiagnostics 函数以处理缺失清单情况 - 添加 inferredManifest 和非空切片辅助函数 - 在 lint 中简化 capabilityGraph 检查逻辑,仅验证 http 能力 - 更新测试文件以匹配新的清单格式版本 "2.0" --- .claude/skills/gitnexus/gitnexus-cli/SKILL.md | 83 + .../gitnexus/gitnexus-debugging/SKILL.md | 89 + .../gitnexus/gitnexus-exploring/SKILL.md | 78 + .../skills/gitnexus/gitnexus-guide/SKILL.md | 64 + .../gitnexus-impact-analysis/SKILL.md | 97 + .../gitnexus/gitnexus-refactoring/SKILL.md | 121 ++ .gitmodules | 4 - AGENTS.md | 43 + CLAUDE.md | 43 + README.md | 141 +- bridge | 1 - cmd/nucleus/internal/adopt/command.go | 56 + cmd/nucleus/internal/adopt/constants.go | 37 + cmd/nucleus/internal/adopt/output.go | 100 + cmd/nucleus/internal/adopt/run.go | 431 +++++ cmd/nucleus/internal/adopt/run_test.go | 172 ++ cmd/nucleus/internal/apply/apply.go | 63 +- cmd/nucleus/internal/apply/command_test.go | 17 +- cmd/nucleus/internal/apply/constants.go | 6 + cmd/nucleus/internal/apply/output.go | 8 +- cmd/nucleus/internal/capability/command.go | 83 - .../internal/capability/command_test.go | 336 ---- cmd/nucleus/internal/capability/constants.go | 45 - cmd/nucleus/internal/capability/doc.go | 7 - cmd/nucleus/internal/capability/gomod.go | 80 - cmd/nucleus/internal/capability/manifest.go | 147 -- cmd/nucleus/internal/capability/output.go | 121 -- cmd/nucleus/internal/capability/run.go | 287 --- cmd/nucleus/internal/capability/templates.go | 439 ----- cmd/nucleus/internal/capcatalog/catalog.go | 322 ---- .../internal/capcatalog/catalog_test.go | 46 - cmd/nucleus/internal/capcatalog/doc.go | 6 - .../capvocab/capability-kinds.v1.json | 137 ++ cmd/nucleus/internal/capvocab/vocab.go | 143 ++ cmd/nucleus/internal/capvocab/vocab_test.go | 70 + cmd/nucleus/internal/decision/command.go | 128 ++ cmd/nucleus/internal/decision/constants.go | 43 + cmd/nucleus/internal/decision/output.go | 163 ++ cmd/nucleus/internal/decision/run.go | 808 ++++++++ cmd/nucleus/internal/decision/run_test.go | 388 ++++ cmd/nucleus/internal/describe/command.go | 8 +- cmd/nucleus/internal/describe/constants.go | 41 +- cmd/nucleus/internal/describe/output.go | 24 +- cmd/nucleus/internal/describe/output_test.go | 33 +- cmd/nucleus/internal/describe/verification.go | 12 +- cmd/nucleus/internal/execute/command_test.go | 12 +- cmd/nucleus/internal/execute/constants.go | 13 + cmd/nucleus/internal/execute/executor.go | 38 +- cmd/nucleus/internal/execute/executor_test.go | 10 +- cmd/nucleus/internal/execute/output.go | 8 +- cmd/nucleus/internal/gen/command_test.go | 7 +- cmd/nucleus/internal/gen/constants.go | 2 + cmd/nucleus/internal/gen/output.go | 16 +- cmd/nucleus/internal/gen/output_test.go | 6 + cmd/nucleus/internal/graphquery/query.go | 168 ++ cmd/nucleus/internal/impact/command.go | 110 ++ cmd/nucleus/internal/impact/command_test.go | 128 ++ cmd/nucleus/internal/impact/constants.go | 29 + cmd/nucleus/internal/impact/output.go | 107 + cmd/nucleus/internal/impact/run.go | 170 ++ cmd/nucleus/internal/initcmd/agent.go | 86 - cmd/nucleus/internal/initcmd/command.go | 67 - cmd/nucleus/internal/initcmd/command_test.go | 284 --- cmd/nucleus/internal/initcmd/constants.go | 46 - cmd/nucleus/internal/initcmd/doc.go | 5 - cmd/nucleus/internal/initcmd/output.go | 68 - cmd/nucleus/internal/initcmd/run.go | 234 --- cmd/nucleus/internal/initcmd/templates.go | 743 ------- cmd/nucleus/internal/lint/constants.go | 14 +- cmd/nucleus/internal/lint/output.go | 27 +- cmd/nucleus/internal/lint/output_test.go | 36 +- cmd/nucleus/internal/mark/command.go | 119 ++ cmd/nucleus/internal/mark/command_test.go | 205 ++ cmd/nucleus/internal/mark/constants.go | 39 + cmd/nucleus/internal/mark/output.go | 75 + cmd/nucleus/internal/mark/run.go | 347 ++++ cmd/nucleus/internal/mcp/command.go | 46 + cmd/nucleus/internal/mcp/constants.go | 55 + cmd/nucleus/internal/mcp/server.go | 201 ++ cmd/nucleus/internal/mcp/server_test.go | 271 +++ cmd/nucleus/internal/mcp/tools.go | 370 ++++ cmd/nucleus/internal/migrate/command.go | 73 - cmd/nucleus/internal/migrate/command_test.go | 301 --- cmd/nucleus/internal/migrate/constants.go | 80 - cmd/nucleus/internal/migrate/doc.go | 6 - cmd/nucleus/internal/migrate/output.go | 173 -- cmd/nucleus/internal/migrate/paths.go | 94 - cmd/nucleus/internal/migrate/run.go | 536 ----- cmd/nucleus/internal/plan/constants.go | 22 +- cmd/nucleus/internal/plan/decisions.go | 124 ++ cmd/nucleus/internal/plan/executable.go | 28 +- cmd/nucleus/internal/plan/impact.go | 399 ++++ cmd/nucleus/internal/plan/output.go | 95 +- cmd/nucleus/internal/plan/output_test.go | 505 ++++- cmd/nucleus/internal/plan/surfaces.go | 11 +- cmd/nucleus/internal/plan/task.go | 177 +- cmd/nucleus/internal/plan/util.go | 17 +- .../recipe/builtin/sql-port-boundary.yaml | 14 + cmd/nucleus/internal/recipe/recipes.go | 498 +++++ cmd/nucleus/internal/recipe/recipes_test.go | 176 ++ cmd/nucleus/internal/repair/command_test.go | 15 +- cmd/nucleus/internal/repair/constants.go | 12 + cmd/nucleus/internal/repair/output.go | 4 +- cmd/nucleus/internal/repair/repair.go | 213 +- cmd/nucleus/internal/repair/repair_test.go | 67 +- cmd/nucleus/internal/report/ai_quality.go | 125 +- cmd/nucleus/internal/report/command.go | 2 - cmd/nucleus/internal/report/command_test.go | 119 +- cmd/nucleus/internal/report/constants.go | 22 +- cmd/nucleus/internal/report/output.go | 52 +- cmd/nucleus/internal/report/platform.go | 292 --- cmd/nucleus/internal/report/report_test.go | 203 +- cmd/nucleus/internal/report/run.go | 19 +- .../internal/root/adopt_example_test.go | 43 + .../internal/root/capability_example_test.go | 34 - .../internal/root/decision_example_test.go | 57 + .../internal/root/describe_example_test.go | 6 +- cmd/nucleus/internal/root/gen_example_test.go | 5 +- .../internal/root/lint_example_test.go | 2 +- .../internal/root/mark_example_test.go | 65 + cmd/nucleus/internal/root/mcp_example_test.go | 22 + .../internal/root/migrate_example_test.go | 56 - .../internal/root/report_example_test.go | 2 +- cmd/nucleus/internal/root/root.go | 41 +- .../internal/root/serve_example_test.go | 7 +- .../internal/root/service_fixture_test.go | 14 +- .../root/trace_impact_example_test.go | 55 + .../internal/root/validate_example_test.go | 2 +- .../internal/root/verify_example_test.go | 2 +- cmd/nucleus/internal/scenario/cases.go | 13 +- cmd/nucleus/internal/scenario/cases_test.go | 4 +- cmd/nucleus/internal/scenario/command_test.go | 21 +- cmd/nucleus/internal/scenario/constants.go | 3 +- cmd/nucleus/internal/scenario/http_runner.go | 13 +- cmd/nucleus/internal/scenario/output.go | 6 +- cmd/nucleus/internal/scenario/scenario.go | 6 +- .../internal/scenario/scenario_test.go | 16 +- cmd/nucleus/internal/serve/command_test.go | 19 +- cmd/nucleus/internal/serve/constants.go | 4 +- cmd/nucleus/internal/serve/server_test.go | 5 +- cmd/nucleus/internal/trace/command.go | 124 ++ cmd/nucleus/internal/trace/command_test.go | 190 ++ cmd/nucleus/internal/trace/constants.go | 31 + cmd/nucleus/internal/trace/output.go | 94 + cmd/nucleus/internal/trace/run.go | 281 +++ cmd/nucleus/internal/validate/constants.go | 8 +- cmd/nucleus/internal/validate/output.go | 20 +- cmd/nucleus/internal/validate/output_test.go | 6 + cmd/nucleus/internal/verify/command_test.go | 175 +- cmd/nucleus/internal/verify/constants.go | 30 +- cmd/nucleus/internal/verify/output.go | 3 + cmd/nucleus/internal/verify/run.go | 134 +- cmd/nucleus/internal/verify/tidy.go | 114 -- cmd/nucleus/main.go | 2 + contract | 2 +- docs/adr/0001-project-scope.md | 8 +- docs/adr/0002-agent-native-protocol-layer.md | 53 + ...rnel.md => agent-native-protocol-layer.md} | 9 +- docs/concepts/migrate-command.md | 63 - docs/concepts/report-command.md | 35 +- docs/concepts/serve-command.md | 4 +- docs/implementation-status.md | 140 +- .../2026-06-13-complete-validate-command.md | 5 +- ...6-06-17-scenario-command-implementation.md | 2 +- ...-07-03-agent-native-implementation-sync.md | 337 ++++ ...026-07-03-agent-native-nucleus-redesign.md | 1717 +++++++++++++++++ docs/platform-mapping.md | 37 - docs/usage.md | 461 +++++ examples/agent/codex/SKILL.md | 27 +- examples/agent/codex/references/boundaries.md | 23 +- examples/agent/codex/references/commands.md | 70 +- examples/agent/codex/references/mcp.md | 89 +- examples/agent/codex/references/workflow.md | 27 +- examples/deploy/docker/README.md | 35 - examples/deploy/docker/docker-compose.yml | 96 - .../deploy/docker/otel-collector-config.yaml | 32 - examples/deploy/docker/prometheus.yml | 9 - examples/library/README.md | 9 - examples/service/README.md | 32 - examples/worker/README.md | 9 - 180 files changed, 12564 insertions(+), 6709 deletions(-) create mode 100644 .claude/skills/gitnexus/gitnexus-cli/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-debugging/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-exploring/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-guide/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md create mode 100644 .claude/skills/gitnexus/gitnexus-refactoring/SKILL.md create mode 100644 AGENTS.md create mode 100644 CLAUDE.md delete mode 160000 bridge create mode 100644 cmd/nucleus/internal/adopt/command.go create mode 100644 cmd/nucleus/internal/adopt/constants.go create mode 100644 cmd/nucleus/internal/adopt/output.go create mode 100644 cmd/nucleus/internal/adopt/run.go create mode 100644 cmd/nucleus/internal/adopt/run_test.go delete mode 100644 cmd/nucleus/internal/capability/command.go delete mode 100644 cmd/nucleus/internal/capability/command_test.go delete mode 100644 cmd/nucleus/internal/capability/constants.go delete mode 100644 cmd/nucleus/internal/capability/doc.go delete mode 100644 cmd/nucleus/internal/capability/gomod.go delete mode 100644 cmd/nucleus/internal/capability/manifest.go delete mode 100644 cmd/nucleus/internal/capability/output.go delete mode 100644 cmd/nucleus/internal/capability/run.go delete mode 100644 cmd/nucleus/internal/capability/templates.go delete mode 100644 cmd/nucleus/internal/capcatalog/catalog.go delete mode 100644 cmd/nucleus/internal/capcatalog/catalog_test.go delete mode 100644 cmd/nucleus/internal/capcatalog/doc.go create mode 100644 cmd/nucleus/internal/capvocab/capability-kinds.v1.json create mode 100644 cmd/nucleus/internal/capvocab/vocab.go create mode 100644 cmd/nucleus/internal/capvocab/vocab_test.go create mode 100644 cmd/nucleus/internal/decision/command.go create mode 100644 cmd/nucleus/internal/decision/constants.go create mode 100644 cmd/nucleus/internal/decision/output.go create mode 100644 cmd/nucleus/internal/decision/run.go create mode 100644 cmd/nucleus/internal/decision/run_test.go create mode 100644 cmd/nucleus/internal/graphquery/query.go create mode 100644 cmd/nucleus/internal/impact/command.go create mode 100644 cmd/nucleus/internal/impact/command_test.go create mode 100644 cmd/nucleus/internal/impact/constants.go create mode 100644 cmd/nucleus/internal/impact/output.go create mode 100644 cmd/nucleus/internal/impact/run.go delete mode 100644 cmd/nucleus/internal/initcmd/agent.go delete mode 100644 cmd/nucleus/internal/initcmd/command.go delete mode 100644 cmd/nucleus/internal/initcmd/command_test.go delete mode 100644 cmd/nucleus/internal/initcmd/constants.go delete mode 100644 cmd/nucleus/internal/initcmd/doc.go delete mode 100644 cmd/nucleus/internal/initcmd/output.go delete mode 100644 cmd/nucleus/internal/initcmd/run.go delete mode 100644 cmd/nucleus/internal/initcmd/templates.go create mode 100644 cmd/nucleus/internal/mark/command.go create mode 100644 cmd/nucleus/internal/mark/command_test.go create mode 100644 cmd/nucleus/internal/mark/constants.go create mode 100644 cmd/nucleus/internal/mark/output.go create mode 100644 cmd/nucleus/internal/mark/run.go create mode 100644 cmd/nucleus/internal/mcp/command.go create mode 100644 cmd/nucleus/internal/mcp/constants.go create mode 100644 cmd/nucleus/internal/mcp/server.go create mode 100644 cmd/nucleus/internal/mcp/server_test.go create mode 100644 cmd/nucleus/internal/mcp/tools.go delete mode 100644 cmd/nucleus/internal/migrate/command.go delete mode 100644 cmd/nucleus/internal/migrate/command_test.go delete mode 100644 cmd/nucleus/internal/migrate/constants.go delete mode 100644 cmd/nucleus/internal/migrate/doc.go delete mode 100644 cmd/nucleus/internal/migrate/output.go delete mode 100644 cmd/nucleus/internal/migrate/paths.go delete mode 100644 cmd/nucleus/internal/migrate/run.go create mode 100644 cmd/nucleus/internal/plan/decisions.go create mode 100644 cmd/nucleus/internal/plan/impact.go create mode 100644 cmd/nucleus/internal/recipe/builtin/sql-port-boundary.yaml create mode 100644 cmd/nucleus/internal/recipe/recipes.go create mode 100644 cmd/nucleus/internal/recipe/recipes_test.go delete mode 100644 cmd/nucleus/internal/report/platform.go create mode 100644 cmd/nucleus/internal/root/adopt_example_test.go delete mode 100644 cmd/nucleus/internal/root/capability_example_test.go create mode 100644 cmd/nucleus/internal/root/decision_example_test.go create mode 100644 cmd/nucleus/internal/root/mark_example_test.go create mode 100644 cmd/nucleus/internal/root/mcp_example_test.go delete mode 100644 cmd/nucleus/internal/root/migrate_example_test.go create mode 100644 cmd/nucleus/internal/root/trace_impact_example_test.go create mode 100644 cmd/nucleus/internal/trace/command.go create mode 100644 cmd/nucleus/internal/trace/command_test.go create mode 100644 cmd/nucleus/internal/trace/constants.go create mode 100644 cmd/nucleus/internal/trace/output.go create mode 100644 cmd/nucleus/internal/trace/run.go delete mode 100644 cmd/nucleus/internal/verify/tidy.go create mode 100644 docs/adr/0002-agent-native-protocol-layer.md rename docs/concepts/{ai-first-microservice-kernel.md => agent-native-protocol-layer.md} (81%) delete mode 100644 docs/concepts/migrate-command.md create mode 100644 docs/plans/2026-07-03-agent-native-implementation-sync.md create mode 100644 docs/plans/2026-07-03-agent-native-nucleus-redesign.md delete mode 100644 docs/platform-mapping.md create mode 100644 docs/usage.md delete mode 100644 examples/deploy/docker/README.md delete mode 100644 examples/deploy/docker/docker-compose.yml delete mode 100644 examples/deploy/docker/otel-collector-config.yaml delete mode 100644 examples/deploy/docker/prometheus.yml delete mode 100644 examples/library/README.md delete mode 100644 examples/service/README.md delete mode 100644 examples/worker/README.md diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md new file mode 100644 index 0000000..cd9a83b --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -0,0 +1,83 @@ +--- +name: gitnexus-cli +description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\"" +--- + +# GitNexus CLI Commands + +All commands work via `npx` — no global install required. + +## Commands + +### analyze — Build or refresh the index + +```bash +npx gitnexus analyze +``` + +Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. + +| Flag | Effect | +| -------------- | ---------------------------------------------------------------- | +| `--force` | Force full re-index even if up to date | +| `--embeddings` | Enable embedding generation for semantic search (off by default) | +| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. | + +**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout. + +### status — Check index freshness + +```bash +npx gitnexus status +``` + +Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. + +### clean — Delete the index + +```bash +npx gitnexus clean +``` + +Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. + +| Flag | Effect | +| --------- | ------------------------------------------------- | +| `--force` | Skip confirmation prompt | +| `--all` | Clean all indexed repos, not just the current one | + +### wiki — Generate documentation from the graph + +```bash +npx gitnexus wiki +``` + +Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). + +| Flag | Effect | +| ------------------- | ----------------------------------------- | +| `--force` | Force full regeneration | +| `--model ` | LLM model (default: minimax/minimax-m2.5) | +| `--base-url ` | LLM API base URL | +| `--api-key ` | LLM API key | +| `--concurrency ` | Parallel LLM calls (default: 3) | +| `--gist` | Publish wiki as a public GitHub Gist | + +### list — Show all indexed repos + +```bash +npx gitnexus list +``` + +Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. + +## After Indexing + +1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded +2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task + +## Troubleshooting + +- **"Not inside a git repository"**: Run from a directory inside a git repo +- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server +- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md new file mode 100644 index 0000000..9510b97 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitnexus-debugging +description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\"" +--- + +# Debugging with GitNexus + +## When to Use + +- "Why is this function failing?" +- "Trace where this error comes from" +- "Who calls this method?" +- "This endpoint returns 500" +- Investigating bugs, errors, or unexpected behavior + +## Workflow + +``` +1. gitnexus_query({query: ""}) → Find related execution flows +2. gitnexus_context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior) +- [ ] gitnexus_query for error text or related code +- [ ] Identify the suspect function from returned processes +- [ ] gitnexus_context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `gitnexus_query` for error text → `context` on throw sites | +| Wrong return value | `context` on the function → trace callees for data flow | +| Intermittent failure | `context` → look for external calls, async deps | +| Performance issue | `context` → find symbols with many callers (hot paths) | +| Recent regression | `detect_changes` to see what your changes affect | + +## Tools + +**gitnexus_query** — find code related to error: + +``` +gitnexus_query({query: "payment validation error"}) +→ Processes: CheckoutFlow, ErrorHandling +→ Symbols: validatePayment, handlePaymentError, PaymentException +``` + +**gitnexus_context** — full context for a suspect: + +``` +gitnexus_context({name: "validatePayment"}) +→ Incoming calls: processCheckout, webhookHandler +→ Outgoing calls: verifyCard, fetchRates (external API!) +→ Processes: CheckoutFlow (step 3/7) +``` + +**gitnexus_cypher** — custom call chain traces: + +```cypher +MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) +RETURN [n IN nodes(path) | n.name] AS chain +``` + +## Example: "Payment endpoint returns 500 intermittently" + +``` +1. gitnexus_query({query: "payment error handling"}) + → Processes: CheckoutFlow, ErrorHandling + → Symbols: validatePayment, handlePaymentError + +2. gitnexus_context({name: "validatePayment"}) + → Outgoing calls: verifyCard, fetchRates (external API!) + +3. READ gitnexus://repo/my-app/process/CheckoutFlow + → Step 3: validatePayment → calls fetchRates (external) + +4. Root cause: fetchRates calls external API without proper timeout +``` diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md new file mode 100644 index 0000000..927a4e4 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -0,0 +1,78 @@ +--- +name: gitnexus-exploring +description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\"" +--- + +# Exploring Codebases with GitNexus + +## When to Use + +- "How does authentication work?" +- "What's the project structure?" +- "Show me the main components" +- "Where is the database logic?" +- Understanding code you haven't seen before + +## Workflow + +``` +1. READ gitnexus://repos → Discover indexed repos +2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness +3. gitnexus_query({query: ""}) → Find related execution flows +4. gitnexus_context({name: ""}) → Deep dive on specific symbol +5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow +``` + +> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] gitnexus_query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] gitnexus_context on key symbols for callers/callees +- [ ] READ process resource for full execution traces +- [ ] Read source files for implementation details +``` + +## Resources + +| Resource | What you get | +| --------------------------------------- | ------------------------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) | +| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) | +| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) | + +## Tools + +**gitnexus_query** — find execution flows related to a concept: + +``` +gitnexus_query({query: "payment processing"}) +→ Processes: CheckoutFlow, RefundFlow, WebhookHandler +→ Symbols grouped by flow with file locations +``` + +**gitnexus_context** — 360-degree view of a symbol: + +``` +gitnexus_context({name: "validateUser"}) +→ Incoming calls: loginHandler, apiMiddleware +→ Outgoing calls: checkToken, getUserById +→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) +``` + +## Example: "How does payment processing work?" + +``` +1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes +2. gitnexus_query({query: "payment processing"}) + → CheckoutFlow: processPayment → validateCard → chargeStripe + → RefundFlow: initiateRefund → calculateRefund → processRefund +3. gitnexus_context({name: "processPayment"}) + → Incoming: checkoutHandler, webhookHandler + → Outgoing: validateCard, chargeStripe, saveTransaction +4. Read src/payments/processor.ts for implementation details +``` diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md new file mode 100644 index 0000000..937ac73 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -0,0 +1,64 @@ +--- +name: gitnexus-guide +description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\"" +--- + +# GitNexus Guide + +Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema. + +## Always Start Here + +For any task involving code understanding, debugging, impact analysis, or refactoring: + +1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness +2. **Match your task to a skill below** and **read that skill file** +3. **Follow the skill's workflow and checklist** + +> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. + +## Skills + +| Task | Skill to read | +| -------------------------------------------- | ------------------- | +| Understand architecture / "How does X work?" | `gitnexus-exploring` | +| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` | +| Trace bugs / "Why is X failing?" | `gitnexus-debugging` | +| Rename / extract / split / refactor | `gitnexus-refactoring` | +| Tools, resources, schema reference | `gitnexus-guide` (this file) | +| Index, status, clean, wiki CLI commands | `gitnexus-cli` | + +## Tools Reference + +| Tool | What it gives you | +| ---------------- | ------------------------------------------------------------------------ | +| `query` | Process-grouped code intelligence — execution flows related to a concept | +| `context` | 360-degree symbol view — categorized refs, processes it participates in | +| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence | +| `detect_changes` | Git-diff impact — what do your current changes affect | +| `rename` | Multi-file coordinated rename with confidence-tagged edits | +| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | +| `list_repos` | Discover indexed repos | + +## Resources Reference + +Lightweight reads (~100-500 tokens) for navigation: + +| Resource | Content | +| ---------------------------------------------- | ----------------------------------------- | +| `gitnexus://repo/{name}/context` | Stats, staleness check | +| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores | +| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members | +| `gitnexus://repo/{name}/processes` | All execution flows | +| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace | +| `gitnexus://repo/{name}/schema` | Graph schema for Cypher | + +## Graph Schema + +**Nodes:** File, Function, Class, Interface, Method, Community, Process +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 0000000..e19af28 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -0,0 +1,97 @@ +--- +name: gitnexus-impact-analysis +description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\"" +--- + +# Impact Analysis with GitNexus + +## When to Use + +- "Is it safe to change this function?" +- "What will break if I modify X?" +- "Show me the blast radius" +- "Who uses this code?" +- Before making non-trivial code changes +- Before committing — to understand what your changes affect + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. gitnexus_detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklist + +``` +- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] Review d=1 items first (these WILL BREAK) +- [ ] Check high-confidence (>0.8) dependencies +- [ ] READ processes to check affected execution flows +- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] Assess risk level and report to user +``` + +## Understanding Output + +| Depth | Risk Level | Meaning | +| ----- | ---------------- | ------------------------ | +| d=1 | **WILL BREAK** | Direct callers/importers | +| d=2 | LIKELY AFFECTED | Indirect dependencies | +| d=3 | MAY NEED TESTING | Transitive effects | + +## Risk Assessment + +| Affected | Risk | +| ------------------------------ | -------- | +| <5 symbols, few processes | LOW | +| 5-15 symbols, 2-5 processes | MEDIUM | +| >15 symbols or many processes | HIGH | +| Critical path (auth, payments) | CRITICAL | + +## Tools + +**gitnexus_impact** — the primary tool for symbol blast radius: + +``` +gitnexus_impact({ + target: "validateUser", + direction: "upstream", + minConfidence: 0.8, + maxDepth: 3 +}) + +→ d=1 (WILL BREAK): + - loginHandler (src/auth/login.ts:42) [CALLS, 100%] + - apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%] + +→ d=2 (LIKELY AFFECTED): + - authRouter (src/routes/auth.ts:22) [CALLS, 95%] +``` + +**gitnexus_detect_changes** — git-diff based impact analysis: + +``` +gitnexus_detect_changes({scope: "staged"}) + +→ Changed: 5 symbols in 3 files +→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline +→ Risk: MEDIUM +``` + +## Example: "What breaks if I change validateUser?" + +``` +1. gitnexus_impact({target: "validateUser", direction: "upstream"}) + → d=1: loginHandler, apiMiddleware (WILL BREAK) + → d=2: authRouter, sessionManager (LIKELY AFFECTED) + +2. READ gitnexus://repo/my-app/processes + → LoginFlow and TokenRefresh touch validateUser + +3. Risk: 2 direct callers, 2 processes = MEDIUM +``` diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md new file mode 100644 index 0000000..f48cc01 --- /dev/null +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -0,0 +1,121 @@ +--- +name: gitnexus-refactoring +description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\"" +--- + +# Refactoring with GitNexus + +## When to Use + +- "Rename this function safely" +- "Extract this into a module" +- "Split this service" +- "Move this to a new file" +- Any task involving renaming, extracting, splitting, or restructuring code + +## Workflow + +``` +1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents +2. gitnexus_query({query: "X"}) → Find execution flows involving X +3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `npx gitnexus analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and ast_search edits (review carefully) +- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits +- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module + +``` +- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs +- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface +- [ ] Extract code, update imports +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**gitnexus_rename** — automated multi-file rename: + +``` +gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +→ 12 edits across 8 files +→ 10 graph edits (high confidence), 2 ast_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**gitnexus_impact** — map all dependents first: + +``` +gitnexus_impact({target: "validateUser", direction: "upstream"}) +→ d=1: loginHandler, apiMiddleware, testUtils +→ Affected Processes: LoginFlow, TokenRefresh +``` + +**gitnexus_detect_changes** — verify your changes after refactoring: + +``` +gitnexus_detect_changes({scope: "all"}) +→ Changed: 8 files, 12 symbols +→ Affected processes: LoginFlow, TokenRefresh +→ Risk: MEDIUM +``` + +**gitnexus_cypher** — custom reference queries: + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) +RETURN caller.name, caller.filePath ORDER BY caller.filePath +``` + +## Risk Rules + +| Risk Factor | Mitigation | +| ------------------- | ----------------------------------------- | +| Many callers (>5) | Use gitnexus_rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | gitnexus_query to find them | +| External/public API | Version and deprecate properly | + +## Example: Rename `validateUser` to `authenticateUser` + +``` +1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) + → 12 edits: 10 graph (safe), 2 ast_search (review) + → Files: validator.ts, login.ts, middleware.ts, config.json... + +2. Review ast_search edits (config.json: dynamic reference!) + +3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) + → Applied 12 edits across 8 files + +4. gitnexus_detect_changes({scope: "all"}) + → Affected: LoginFlow, TokenRefresh + → Risk: MEDIUM — run tests for these flows +``` diff --git a/.gitmodules b/.gitmodules index d4a9636..cdde34d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,3 @@ -[submodule "bridge"] - path = bridge - url = https://github.com/nucleuskit/bridge.git - branch = main [submodule "cap"] path = cap url = https://github.com/nucleuskit/cap.git diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5ac2855 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,43 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **nucleus** (9707 symbols, 25775 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/nucleus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/nucleus/clusters` | All functional areas | +| `gitnexus://repo/nucleus/processes` | All execution flows | +| `gitnexus://repo/nucleus/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5ac2855 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,43 @@ + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **nucleus** (9707 symbols, 25775 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/nucleus/context` | Codebase overview, check index freshness | +| `gitnexus://repo/nucleus/clusters` | All functional areas | +| `gitnexus://repo/nucleus/processes` | All execution flows | +| `gitnexus://repo/nucleus/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + \ No newline at end of file diff --git a/README.md b/README.md index 250e723..a12181d 100644 --- a/README.md +++ b/README.md @@ -3,27 +3,29 @@ [![CI](https://github.com/nucleuskit/nucleus/actions/workflows/ci.yml/badge.svg)](https://github.com/nucleuskit/nucleus/actions/workflows/ci.yml) [![License](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) -Nucleus is an AI-first Go microservice kernel for contract-driven service generation, evolution, and verification. +Nucleus is an agent-native Go microservice protocol layer for structured service inspection, bounded AI edits, technical decision evidence, and local verification. -It is designed for AI agents, CI systems, and human reviewers to work from the same source of truth: service contracts, manifests, generated freshness, safe edit surfaces, and verification evidence. +It is designed for AI agents, CI systems, and human reviewers to work from the same local facts: service contracts, source graphs, manifest indexes, generated freshness, safe edit surfaces, decision evidence, and verification evidence. > Status: pre-alpha. The public repository is being prepared for the first source import under `github.com/nucleuskit/nucleus`. -For a code-level view of what is implemented, what is scaffold-only, and the -current module release status, see [Implementation Status](docs/implementation-status.md). +For a code-level view of what is implemented, removed, and still in progress, +see [Implementation Status](docs/implementation-status.md). + +For hands-on usage, see [Nucleus 使用指南](docs/usage.md). ## Why Nucleus -Traditional Go microservice frameworks are optimized for humans writing code inside an IDE. Nucleus is optimized for AI agents changing services safely and repeatably. +Traditional Go microservice frameworks are optimized for humans writing code inside an IDE. Nucleus is optimized for AI agents understanding and changing existing Go services safely and repeatably. Nucleus focuses on four rules: - **Contract-first**: OpenAPI, protobuf, and error definitions are the source of truth for external behavior. -- **Manifest-first**: service identity, capabilities, dependencies, and AI edit surfaces are declared in `nucleus.yaml`. -- **Thin kernel**: core lifecycle, identity, errors, context, and response types stay small and dependency-aware. -- **AI-safe loop**: `describe -> plan -> lint -> verify` makes service changes reviewable, reproducible, and bounded. +- **Manifest as protocol index**: service identity, contracts, capability anchors, AI edit surfaces, and verification commands are declared in `nucleus.yaml`. +- **Graph-first inspection**: symbols, calls, interfaces, routes, tests, and generated freshness are exposed as machine-readable local facts. +- **AI-safe loop**: `adopt -> mark -> describe graph -> trace -> impact -> plan -> decision -> verify`, with optional `mcp --stdio` access for agents, makes service changes reviewable, reproducible, and bounded. -Nucleus is not a full-stack middleware bundle. Redis, Kafka, SQL, Nacos, tracing exporters, and similar infrastructure are connected through explicit capability interfaces and optional bridges. +Nucleus is not a full-stack middleware bundle, project scaffold, provider SDK collection, or platform control plane. Redis, Kafka, SQL, Nacos, tracing exporters, ORMs, and similar infrastructure are user-project decisions recorded as explicit decision evidence. ## Core Concepts @@ -35,55 +37,69 @@ A Nucleus service treats these files as public behavior contracts: - `api/proto/*.proto` for gRPC APIs - `api/errors.yaml` for stable error codes and HTTP mappings -Generated handlers, types, clients, and freshness metadata are derived from these contracts. +Generated artifacts, when used, are contract-derived only. They must carry freshness metadata and remain outside normal business edit surfaces. ### Manifest -`nucleus.yaml` describes the service identity and machine-readable operating surface: +`nucleus.yaml` describes the service identity and machine-readable protocol index: -- service name, version, tier, and ownership -- declared capabilities and providers -- dependencies and contract snapshots +- service name and metadata +- declared contracts +- capability anchors and symbols - edit surfaces for AI agents - verification commands for CI and local review -### Capability Protocol +Provider, library, ORM, and driver choices do not live in `nucleus.yaml`; they belong in `.nucleus/decisions/*.yaml`. + +### Capability Anchors Nucleus separates capability declaration from infrastructure implementation: -- `github.com/nucleuskit/cap/*` defines small interfaces, options, and no-op implementations. -- `github.com/nucleuskit/bridge/*` provides optional adapters. -- Application wiring injects bridges into runtime code. -- Domain code stays independent from transport and infrastructure SDKs. +- capabilities are semantic anchors, not provider implementations +- capability kinds are advisory vocabulary, not hard-coded enums +- provider decisions live in `.nucleus/decisions/*.yaml` +- recipes may suggest approaches, but cannot write files or execute commands +- domain code and application wiring remain owned by the user project ### AI-Safe Change Loop The intended workflow inside a service directory is: ```text +nucleus adopt --json +nucleus mark contract http --kind openapi --path api/openapi.yaml +nucleus mark capability order_store --kind relational_store --symbol OrderStore nucleus describe --json -nucleus validate +nucleus trace symbol --json +nucleus trace capability order_store --json +nucleus impact symbol --json nucleus plan --task "change request" --json +nucleus decision validate .nucleus/decisions/.yaml nucleus gen -nucleus scenario --json -nucleus serve --check --json nucleus lint nucleus verify nucleus report --json +nucleus mcp --stdio ``` -The goal is not just to generate code. The goal is to produce evidence that the change respected contracts, manifests, generated freshness, dependency boundaries, and verification commands. +The goal is not just to generate code. The goal is to produce evidence that the change respected contracts, manifests, source graphs, locked decisions, generated freshness, edit surfaces, and verification commands. + +`adopt` adds a minimal protocol index to an existing Go project. It does not +create business directories, choose providers, or modify `go.mod`. -`describe` emits the service facts that agents and reviewers consume. `validate` -checks manifest and contract source legality before later workflow steps. `lint` -checks project conventions and risk rules, while `verify` executes the validation, -lint, build, and test evidence pipeline. +`describe` emits service facts that agents and reviewers consume. `trace` and +`impact` expose call chains and change blast radius, while `plan` embeds a +best-effort impact summary next to edit surfaces and verification commands. +`lint` checks protocol consistency, safety boundaries, decision evidence, +generated freshness, and stale graphs without requiring a fixed project layout. +`verify` executes only manifest-declared verification commands and emits +evidence. -`gen` writes reproducible artifacts under generated targets such as -`contract/gen` and `internal/adapter/http/gen`. Use `nucleus gen --json` when -automation needs auditable evidence; the result includes -`result_kind: "nucleus.gen_result"`, `ok`, `source_hash`, generated `files`, -`summary`, and validation `diagnostics`. +`gen` writes reproducible contract-derived artifacts only. It must not create +application wiring, provider glue, runtime bootstraps, or dependency changes. +Use `nucleus gen --json` when automation needs auditable evidence; the result +includes `result_kind: "nucleus.gen_result"`, `ok`, `source_hash`, generated +`files`, `summary`, and validation `diagnostics`. `scenario` turns OpenAPI routes, error catalogs, and flow inspection into reviewable test suggestions. Use `nucleus scenario --json` for a scenario plan, @@ -99,43 +115,36 @@ without opening a listener, or `nucleus serve --addr 127.0.0.1:8080` to expose `/healthz`, `/readyz`, and `/.well-known/nucleus.json`. The command is metadata-only: it does not auto-wire provider SDKs or generated business handlers. JSON output uses `result_kind: "nucleus.serve_result"`, -`schema_version: "serve.v1"`, `schema_ref: -"contract/schema/serve.schema.json"`, `ok`, `mode`, `summary`, `diagnostics`, -and `server`. - -`report` summarizes AI change quality and platform readiness without making -network calls. By default it reads AI task result JSON files from -`artifacts/nucleus/ai-tasks`; pass `--ai-tasks` for an explicit directory, or -`--platform` to emit local release-readiness metadata from `contract/inspect`. -Like other subcommands, the default output is human-readable and `--json` emits -a stable envelope with `result_kind: "nucleus.report_result"`, -`schema_version: "report.v1"`, `schema_ref: -"contract/schema/report.schema.json"`, `ok`, `mode`, `summary`, and -`diagnostics`. - -For any service directory containing `nucleus.yaml` and contract sources, run -`nucleus validate --dir `. Successful human output includes a short -validation summary; `--json` emits the same result with stable `ok`, `summary`, -and `diagnostics` fields. +`schema_version: "serve-result.v1"`, `schema_ref: +"contract/schema/serve-result.v1.schema.json"`, `ok`, `mode`, `summary`, +`diagnostics`, and `server`. + +`report` summarizes local quality without making network calls or referencing a +control plane. It reports graph coverage, decision quality, verification status, +edit-surface violations, generated freshness, AI task evidence, unresolved +symbols, and locked decision changes. + +`mcp` exposes the same local facts as structured stdio MCP tools for agents. +It is read-only metadata access over contracts, edit surfaces, capabilities, +symbol search, trace, impact, decisions, report, plan, and local recipes. + +Projects without OpenAPI, proto, or error catalogs are still valid Nucleus +projects. They run in graph-only mode until a task touches external behavior. ## Project Shape -The main repository hosts the CLI, examples, and public documentation: +The main repository hosts the CLI, protocol schemas, source inspection code, and public documentation: ```text -api/ Contract files cmd/nucleus/ CLI implementation -examples/ Runnable examples and templates -docs/ Concepts, ADRs, plans, and platform mapping +contract/schema/ Protocol schemas +contract/inspect/ Source and contract inspection +docs/ Concepts, ADRs, and plans ``` -The kernel, contract, capability, bridge, and runtime packages are published as -separate Go modules: +The contract and narrowly scoped runtime packages are published as separate Go modules: -- `github.com/nucleuskit/core` - `github.com/nucleuskit/contract` -- `github.com/nucleuskit/cap` -- `github.com/nucleuskit/bridge` - `github.com/nucleuskit/http` - `github.com/nucleuskit/grpc` - `github.com/nucleuskit/worker` @@ -145,10 +154,10 @@ separate Go modules: The early public roadmap is intentionally narrow: - Publish the source under `github.com/nucleuskit/nucleus`. -- Stabilize the first `nucleus validate`, `gen`, `describe`, `plan`, `lint`, and `verify` loop. -- Keep `core` standard-library-only. -- Make `examples/hello-http` the first runnable contract-first service. -- Add focused capability protocols before adding optional bridges. +- Stabilize the first `adopt`, `describe graph`, `trace`, `impact`, `plan`, `decision`, `verify`, and local MCP loop. +- Add protocol schemas for manifest v2, decisions, recipes, graphs, reports, diagnostics, and evidence. +- Remove scaffold, provider bridge, and platform-readiness paths from the core boundary. +- Keep generated artifacts contract-derived and readonly by default. - Publish `v0.1.0-alpha.1` once the public module path, CI, examples, and docs are aligned. ## Contributing @@ -156,10 +165,10 @@ The early public roadmap is intentionally narrow: Nucleus is early, but contributions should already follow the project boundaries: - Start with contracts and manifests when changing external behavior. -- Keep kernel code small and dependency-aware. -- Prefer explicit capabilities over hidden framework defaults. +- Keep protocol code small and dependency-aware. +- Prefer explicit capability anchors and decisions over hidden framework defaults. - Add tests or verification evidence with behavior changes. -- Do not add compatibility shims for private or legacy internal SDKs. +- Do not add provider SDK adapters, scaffold defaults, or compatibility shims for private or legacy internal SDKs. See [CONTRIBUTING.md](CONTRIBUTING.md) and [SECURITY.md](SECURITY.md). diff --git a/bridge b/bridge deleted file mode 160000 index 2549645..0000000 --- a/bridge +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 25496459a325018be4a0f22621db136b5ac6fc4c diff --git a/cmd/nucleus/internal/adopt/command.go b/cmd/nucleus/internal/adopt/command.go new file mode 100644 index 0000000..1d238ac --- /dev/null +++ b/cmd/nucleus/internal/adopt/command.go @@ -0,0 +1,56 @@ +package adopt + +import "github.com/spf13/cobra" + +// Config carries root-level flag values used by the adopt command. +type Config struct { + Dir *string +} + +type options struct { + service string + version string + intent string + agent string + force bool + json bool + pretty bool +} + +// NewCommand creates the adopt subcommand. +func NewCommand(config Config) *cobra.Command { + opts := &options{version: defaultVersion, intent: defaultIntent} + cmd := &cobra.Command{ + Use: commandUseAdopt, + Short: commandShortSummary, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + output, err := run(config, opts) + if opts.json { + if renderErr := renderJSON(cmd.OutOrStdout(), output, opts.pretty); renderErr != nil { + return renderErr + } + } else { + renderHuman(cmd.OutOrStdout(), output) + } + return err + }, + } + cmd.Flags().StringVar(&opts.service, flagService, "", flagHelpService) + cmd.Flags().StringVar(&opts.version, flagVersion, defaultVersion, flagHelpVersion) + cmd.Flags().StringVar(&opts.intent, flagIntent, defaultIntent, flagHelpIntent) + cmd.Flags().StringVar(&opts.agent, flagAgent, "", flagHelpAgent) + cmd.Flags().BoolVar(&opts.force, flagForce, false, flagHelpForce) + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + return cmd +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/cmd/nucleus/internal/adopt/constants.go b/cmd/nucleus/internal/adopt/constants.go new file mode 100644 index 0000000..06021e4 --- /dev/null +++ b/cmd/nucleus/internal/adopt/constants.go @@ -0,0 +1,37 @@ +package adopt + +const ( + commandUseAdopt = "adopt" + commandShortSummary = "add a minimal Nucleus protocol index to an existing Go project" + defaultDir = "." + defaultVersion = "0.0.0" + defaultIntent = "Agent-readable protocol index for an existing Go service." + resultKindAdopt = "nucleus.adopt_result" + schemaVersionAdopt = "adopt-result.v1" + schemaRefAdopt = "contract/schema/adopt-result.v1.schema.json" + manifestFileName = "nucleus.yaml" + decisionsKeepFile = ".nucleus/decisions/.gitkeep" + nucleusReadmeFile = ".nucleus/README.md" + codexInstructionFile = ".nucleus/agents/codex.md" + agentCodex = "codex" +) + +const ( + flagService = "service" + flagVersion = "version" + flagIntent = "intent" + flagAgent = "agent" + flagForce = "force" + flagJSON = "json" + flagPretty = "pretty" +) + +const ( + flagHelpService = "service name to write into nucleus.yaml; inferred from go.mod or directory when omitted" + flagHelpVersion = "service version to write into nucleus.yaml" + flagHelpIntent = "AI intent to write into nucleus.yaml" + flagHelpAgent = "optional agent instruction pack to write under .nucleus/agents; supported: codex" + flagHelpForce = "overwrite nucleus.yaml when it already exists" + flagHelpJSON = "emit JSON output" + flagHelpPretty = "pretty-print JSON output" +) diff --git a/cmd/nucleus/internal/adopt/output.go b/cmd/nucleus/internal/adopt/output.go new file mode 100644 index 0000000..1eaa35c --- /dev/null +++ b/cmd/nucleus/internal/adopt/output.go @@ -0,0 +1,100 @@ +package adopt + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/nucleuskit/contract/diagnostic" +) + +type result struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Summary summary `json:"summary"` + DetectedModule string `json:"detected_module"` + PackageSummary []string `json:"package_summary"` + DetectedContracts []pathFact `json:"detected_contracts"` + DetectedTestCommands []string `json:"detected_test_commands"` + CreatedFiles []pathFact `json:"created_files"` + GeneratedFileCandidates []pathFact `json:"generated_file_candidates"` + SymbolIndexSummary map[string]int `json:"symbol_index_summary"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +type summary struct { + CreatedFiles int `json:"created_files"` + DetectedContracts int `json:"detected_contracts"` + DetectedCommands int `json:"detected_commands"` + Packages int `json:"packages"` + Symbols int `json:"symbols"` +} + +type pathFact struct { + Path string `json:"path"` + Kind string `json:"kind,omitempty"` + Reason string `json:"reason,omitempty"` +} + +func renderJSON(writer io.Writer, output result, pretty bool) error { + output = normalizeResult(output) + encoder := json.NewEncoder(writer) + if pretty { + encoder.SetIndent("", " ") + } + return encoder.Encode(output) +} + +func renderHuman(writer io.Writer, output result) { + output = normalizeResult(output) + if !output.OK { + _, _ = fmt.Fprintln(writer, "FAILED") + } else { + _, _ = fmt.Fprintln(writer, "OK adopted") + } + _, _ = fmt.Fprintf(writer, "module: %s\n", output.DetectedModule) + _, _ = fmt.Fprintf(writer, "created files: %d\n", len(output.CreatedFiles)) + for _, file := range output.CreatedFiles { + _, _ = fmt.Fprintf(writer, " - %s\n", file.Path) + } + if len(output.Diagnostics) > 0 { + _, _ = fmt.Fprintln(writer, "diagnostics:") + for _, item := range output.Diagnostics { + _, _ = fmt.Fprintf(writer, " - %s %s: %s\n", item.Severity, item.Code, item.Message) + } + } +} + +func normalizeResult(output result) result { + if output.PackageSummary == nil { + output.PackageSummary = []string{} + } + if output.DetectedContracts == nil { + output.DetectedContracts = []pathFact{} + } + if output.DetectedTestCommands == nil { + output.DetectedTestCommands = []string{} + } + if output.CreatedFiles == nil { + output.CreatedFiles = []pathFact{} + } + if output.GeneratedFileCandidates == nil { + output.GeneratedFileCandidates = []pathFact{} + } + if output.SymbolIndexSummary == nil { + output.SymbolIndexSummary = map[string]int{} + } + if output.Diagnostics == nil { + output.Diagnostics = diagnostic.Diagnostics{} + } + output.Summary = summary{ + CreatedFiles: len(output.CreatedFiles), + DetectedContracts: len(output.DetectedContracts), + DetectedCommands: len(output.DetectedTestCommands), + Packages: len(output.PackageSummary), + Symbols: output.SymbolIndexSummary["symbols"], + } + return output +} diff --git a/cmd/nucleus/internal/adopt/run.go b/cmd/nucleus/internal/adopt/run.go new file mode 100644 index 0000000..1642218 --- /dev/null +++ b/cmd/nucleus/internal/adopt/run.go @@ -0,0 +1,431 @@ +package adopt + +import ( + "bufio" + "encoding/json" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/token" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/nucleuskit/contract/diagnostic" +) + +func run(config Config, opts *options) (result, error) { + dir := stringValue(config.Dir, defaultDir) + facts := scan(dir) + normalized := normalizeOptions(opts, facts, dir) + diagnostics := append(diagnostic.Diagnostics{}, facts.diagnostics...) + if err := validateOptions(normalized); err != nil { + diagnostics = append(diagnostics, errorDiagnostic("adopt.invalid_options", "", err.Error())) + return buildResult(facts, nil, diagnostics), err + } + + created, writeDiagnostics, err := writeProtocolIndex(dir, normalized) + diagnostics = append(diagnostics, writeDiagnostics...) + diagnostics.Sort() + output := buildResult(facts, created, diagnostics) + if err != nil { + return output, err + } + return output, nil +} + +type normalizedOptions struct { + service string + version string + intent string + agent string + force bool +} + +type projectFacts struct { + module string + service string + packages []string + contracts []pathFact + testCommands []string + generatedFileCandidates []pathFact + symbols map[string]int + diagnostics diagnostic.Diagnostics +} + +func normalizeOptions(opts *options, facts projectFacts, dir string) normalizedOptions { + if opts == nil { + opts = &options{} + } + service := strings.TrimSpace(opts.service) + if service == "" { + service = facts.service + } + if service == "" { + service = inferredServiceName(dir, facts.module) + } + version := strings.TrimSpace(opts.version) + if version == "" { + version = defaultVersion + } + intent := strings.TrimSpace(opts.intent) + if intent == "" { + intent = defaultIntent + } + return normalizedOptions{ + service: service, + version: version, + intent: intent, + agent: strings.TrimSpace(opts.agent), + force: opts.force, + } +} + +func validateOptions(opts normalizedOptions) error { + switch opts.agent { + case "", agentCodex: + default: + return fmt.Errorf("unknown agent %q", opts.agent) + } + if strings.TrimSpace(opts.service) == "" { + return errors.New("service name is required") + } + if strings.ContainsAny(opts.service, "\n\r\t") { + return errors.New("service name must be a single line") + } + return nil +} + +func writeProtocolIndex(dir string, opts normalizedOptions) ([]pathFact, diagnostic.Diagnostics, error) { + files := map[string]string{ + manifestFileName: manifestYAML(opts), + decisionsKeepFile: "", + nucleusReadmeFile: nucleusReadme(), + } + if opts.agent == agentCodex { + files[codexInstructionFile] = codexInstruction() + } + + var diagnostics diagnostic.Diagnostics + created := make([]pathFact, 0, len(files)) + for _, name := range sortedKeys(files) { + pathValue := filepath.Join(dir, filepath.FromSlash(name)) + if !opts.force { + if _, err := os.Stat(pathValue); err == nil { + diagnostics = append(diagnostics, warningDiagnostic("adopt.file_exists", name, "existing protocol file was left unchanged")) + continue + } else if err != nil && !os.IsNotExist(err) { + return created, append(diagnostics, errorDiagnostic("adopt.stat_failed", name, err.Error())), err + } + } + if err := os.MkdirAll(filepath.Dir(pathValue), 0o755); err != nil { + return created, append(diagnostics, errorDiagnostic("adopt.mkdir_failed", name, err.Error())), err + } + if err := os.WriteFile(pathValue, []byte(files[name]), 0o644); err != nil { + return created, append(diagnostics, errorDiagnostic("adopt.write_failed", name, err.Error())), err + } + created = append(created, pathFact{Path: name, Kind: "protocol_index", Reason: "adopt"}) + } + return created, diagnostics, nil +} + +func buildResult(facts projectFacts, created []pathFact, diagnostics diagnostic.Diagnostics) result { + return normalizeResult(result{ + ResultKind: resultKindAdopt, + SchemaVersion: schemaVersionAdopt, + SchemaRef: schemaRefAdopt, + OK: !diagnostics.Failed(), + DetectedModule: facts.module, + PackageSummary: facts.packages, + DetectedContracts: facts.contracts, + DetectedTestCommands: facts.testCommands, + CreatedFiles: created, + GeneratedFileCandidates: facts.generatedFileCandidates, + SymbolIndexSummary: facts.symbols, + Diagnostics: diagnostics, + }) +} + +func scan(dir string) projectFacts { + module, moduleDiagnostics := detectModule(dir) + facts := projectFacts{ + module: module, + service: inferredServiceName(dir, module), + contracts: detectContracts(dir), + testCommands: detectTestCommands(dir, module), + generatedFileCandidates: []pathFact{}, + symbols: map[string]int{}, + diagnostics: moduleDiagnostics, + } + packageNames := map[string]string{} + symbols := map[string]int{ + "packages": 0, + "files": 0, + "functions": 0, + "methods": 0, + "types": 0, + "symbols": 0, + } + if info, err := os.Stat(dir); err != nil { + if !os.IsNotExist(err) { + facts.diagnostics = append(facts.diagnostics, warningDiagnostic("adopt.scan_skipped", ".", err.Error())) + } + facts.symbols = symbols + facts.diagnostics.Sort() + return facts + } else if !info.IsDir() { + facts.diagnostics = append(facts.diagnostics, warningDiagnostic("adopt.scan_skipped", ".", "target path is not a directory")) + facts.symbols = symbols + facts.diagnostics.Sort() + return facts + } + _ = filepath.WalkDir(dir, func(filePath string, entry os.DirEntry, err error) error { + if err != nil { + facts.diagnostics = append(facts.diagnostics, warningDiagnostic("adopt.scan_skipped", safeRel(dir, filePath), err.Error())) + return nil + } + if entry.IsDir() { + if shouldSkipDir(entry.Name()) && filePath != dir { + return filepath.SkipDir + } + return nil + } + rel := safeRel(dir, filePath) + if generatedCandidate(rel) { + facts.generatedFileCandidates = append(facts.generatedFileCandidates, pathFact{Path: rel, Kind: "generated_candidate", Reason: "name or path suggests generated code"}) + } + if !strings.HasSuffix(entry.Name(), ".go") { + return nil + } + parsed, parseErr := parser.ParseFile(token.NewFileSet(), filePath, nil, 0) + if parseErr != nil { + facts.diagnostics = append(facts.diagnostics, warningDiagnostic("adopt.go_parse_skipped", rel, parseErr.Error())) + return nil + } + symbols["files"]++ + packagePath := path.Dir(rel) + if packagePath == "." { + packagePath = "." + } + packageNames[packagePath] = parsed.Name.Name + ast.Inspect(parsed, func(node ast.Node) bool { + switch typed := node.(type) { + case *ast.FuncDecl: + if typed.Recv == nil { + symbols["functions"]++ + } else { + symbols["methods"]++ + } + symbols["symbols"]++ + case *ast.TypeSpec: + symbols["types"]++ + symbols["symbols"]++ + } + return true + }) + return nil + }) + for packagePath, packageName := range packageNames { + facts.packages = append(facts.packages, packagePath+" "+packageName) + } + sort.Strings(facts.packages) + sort.Slice(facts.generatedFileCandidates, func(i, j int) bool { + return facts.generatedFileCandidates[i].Path < facts.generatedFileCandidates[j].Path + }) + symbols["packages"] = len(facts.packages) + facts.symbols = symbols + facts.diagnostics.Sort() + return facts +} + +func detectModule(dir string) (string, diagnostic.Diagnostics) { + file, err := os.Open(filepath.Join(dir, "go.mod")) + if err != nil { + if os.IsNotExist(err) { + return "", diagnostic.Diagnostics{warningDiagnostic("adopt.go_mod_missing", "go.mod", "go.mod was not found; service name was inferred from directory")} + } + return "", diagnostic.Diagnostics{warningDiagnostic("adopt.go_mod_read_failed", "go.mod", err.Error())} + } + defer file.Close() + + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "module ") { + fields := strings.Fields(line) + if len(fields) >= 2 { + return fields[1], nil + } + } + } + if err := scanner.Err(); err != nil { + return "", diagnostic.Diagnostics{warningDiagnostic("adopt.go_mod_read_failed", "go.mod", err.Error())} + } + return "", diagnostic.Diagnostics{warningDiagnostic("adopt.go_mod_module_missing", "go.mod", "go.mod does not declare a module path")} +} + +func detectContracts(dir string) []pathFact { + candidates := []pathFact{ + {Path: "api/openapi.yaml", Kind: "openapi"}, + {Path: "api/openapi.yml", Kind: "openapi"}, + {Path: "api/errors.yaml", Kind: "errors"}, + } + var facts []pathFact + for _, candidate := range candidates { + if regularFile(filepath.Join(dir, filepath.FromSlash(candidate.Path))) { + candidate.Reason = "well-known contract path" + facts = append(facts, candidate) + } + } + protoRoot := filepath.Join(dir, "api", "proto") + _ = filepath.WalkDir(protoRoot, func(filePath string, entry os.DirEntry, err error) error { + if err != nil || entry == nil || entry.IsDir() || !strings.HasSuffix(entry.Name(), ".proto") { + return nil + } + facts = append(facts, pathFact{Path: safeRel(dir, filePath), Kind: "proto", Reason: "proto contract candidate"}) + return nil + }) + sort.Slice(facts, func(i, j int) bool { + return facts[i].Path < facts[j].Path + }) + return facts +} + +func detectTestCommands(dir string, module string) []string { + var commands []string + if module != "" || regularFile(filepath.Join(dir, "go.mod")) { + commands = append(commands, "go test ./...") + } + if regularFile(filepath.Join(dir, "api", "openapi.yaml")) || regularFile(filepath.Join(dir, "api", "openapi.yml")) || regularFile(filepath.Join(dir, "api", "errors.yaml")) { + commands = append(commands, "nucleus validate --dir .") + } + return commands +} + +func manifestYAML(opts normalizedOptions) string { + return strings.Join([]string{ + "schema_version: " + quoteYAML("2.0"), + "service:", + " name: " + quoteYAML(opts.service), + " version: " + quoteYAML(opts.version), + "ai:", + " intent: " + quoteYAML(opts.intent), + " allowed_changes:", + " - " + quoteYAML("nucleus.yaml"), + " - " + quoteYAML(".nucleus/**"), + " - " + quoteYAML("api/**"), + " - " + quoteYAML("docs/**"), + " readonly: []", + " forbidden:", + " - " + quoteYAML("configs/*.local.yaml"), + " - " + quoteYAML("configs/*.secret.yaml"), + " - " + quoteYAML(".env"), + " - " + quoteYAML(".env.*"), + "capabilities: []", + "dependencies: []", + "verify:", + " commands:", + " - " + quoteYAML("go test ./..."), + "", + }, "\n") +} + +func nucleusReadme() string { + return "# Nucleus Protocol Index\n" + + "\n" + + "This directory stores local, agent-readable protocol metadata for the project.\n" + + "\n" + + "- Provider, library, and driver choices belong in `.nucleus/decisions`.\n" + + "- Generated or inferred facts should be rebuilt with `nucleus describe`.\n" + + "- This directory must not contain business implementation code.\n" +} + +func codexInstruction() string { + return "# Codex Agent Notes\n" + + "\n" + + "Use Nucleus as a protocol layer, not as a project scaffold.\n" + + "\n" + + "- Inspect with `nucleus describe --dir . --json --flow` before behavior changes.\n" + + "- Keep provider/library/driver choices in structured decision evidence.\n" + + "- Do not modify go.mod or go.sum unless the user explicitly approves the dependency decision.\n" + + "- Prefer existing project structure and interfaces over new framework-shaped directories.\n" +} + +func quoteYAML(value string) string { + data, err := json.Marshal(value) + if err != nil { + return `""` + } + return string(data) +} + +func inferredServiceName(dir string, module string) string { + if module != "" { + if name := path.Base(module); name != "." && name != "/" { + return name + } + } + name := filepath.Base(filepath.Clean(dir)) + if name == "." || name == string(filepath.Separator) { + return "unknown" + } + return name +} + +func generatedCandidate(rel string) bool { + rel = strings.ReplaceAll(rel, "\\", "/") + base := path.Base(rel) + return strings.Contains(rel, "/gen/") || strings.Contains(rel, "/generated/") || strings.HasSuffix(base, ".gen.go") || strings.HasSuffix(base, ".pb.go") +} + +func regularFile(path string) bool { + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func shouldSkipDir(name string) bool { + switch name { + case ".git", ".idea", ".vscode", "vendor", "node_modules", ".nucleus": + return true + default: + return false + } +} + +func safeRel(dir string, filePath string) string { + rel, err := filepath.Rel(dir, filePath) + if err != nil { + return filepath.ToSlash(filePath) + } + return filepath.ToSlash(rel) +} + +func sortedKeys(values map[string]string) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + +func warningDiagnostic(code string, path string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{ + Severity: diagnostic.SeverityWarning, + Code: code, + Path: path, + Message: message, + } +} + +func errorDiagnostic(code string, path string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{ + Severity: diagnostic.SeverityError, + Code: code, + Path: path, + Message: message, + } +} diff --git a/cmd/nucleus/internal/adopt/run_test.go b/cmd/nucleus/internal/adopt/run_test.go new file mode 100644 index 0000000..aed03f3 --- /dev/null +++ b/cmd/nucleus/internal/adopt/run_test.go @@ -0,0 +1,172 @@ +package adopt + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestRunCreatesMinimalProtocolIndex(t *testing.T) { + dir := t.TempDir() + writeAdoptFile(t, dir, "go.mod", "module example.com/order-api\n\ngo 1.26.3\n") + writeAdoptFile(t, dir, "service.go", `package order + +type Store interface { + Get(id string) error +} + +func NewStore() Store { + return nil +} +`) + beforeGoMod := readAdoptFile(t, dir, "go.mod") + + output, err := run(Config{Dir: &dir}, &options{json: true}) + if err != nil { + t.Fatalf("run() error = %v", err) + } + if !output.OK { + t.Fatalf("output.OK = false: %#v", output.Diagnostics) + } + if output.ResultKind != resultKindAdopt || output.SchemaVersion != schemaVersionAdopt || output.SchemaRef != schemaRefAdopt { + t.Fatalf("unexpected schema metadata: %#v", output) + } + if output.DetectedModule != "example.com/order-api" { + t.Fatalf("detected module = %q", output.DetectedModule) + } + assertAdoptFileExists(t, dir, manifestFileName) + assertAdoptFileExists(t, dir, decisionsKeepFile) + assertAdoptFileExists(t, dir, nucleusReadmeFile) + assertAdoptFileMissing(t, dir, "cmd") + assertAdoptFileMissing(t, dir, "internal") + assertAdoptFileMissing(t, dir, "deploy") + assertAdoptFileMissing(t, dir, "go.sum") + if got := readAdoptFile(t, dir, "go.mod"); got != beforeGoMod { + t.Fatalf("go.mod was modified:\n%s", got) + } + manifest := readAdoptFile(t, dir, manifestFileName) + for _, forbidden := range []string{"provider:", "driver:", "library:", "internal/", "go.mod", "go.sum"} { + if strings.Contains(manifest, forbidden) { + t.Fatalf("manifest leaked forbidden content %q:\n%s", forbidden, manifest) + } + } + if !strings.Contains(manifest, `name: "order-api"`) { + t.Fatalf("manifest did not infer service name:\n%s", manifest) + } + if output.Summary.CreatedFiles != 3 || output.Summary.Packages != 1 || output.Summary.Symbols == 0 { + t.Fatalf("unexpected summary: %#v", output.Summary) + } +} + +func TestRunDetectsContractCandidatesWithoutRequiringContracts(t *testing.T) { + dir := t.TempDir() + writeAdoptFile(t, dir, "go.mod", "module example.com/library\n\ngo 1.26.3\n") + writeAdoptFile(t, dir, "api/openapi.yaml", "openapi: 3.0.3\npaths: {}\n") + writeAdoptFile(t, dir, "api/errors.yaml", "errors: []\n") + + output, err := run(Config{Dir: &dir}, &options{json: true}) + if err != nil { + t.Fatalf("run() error = %v", err) + } + if len(output.DetectedContracts) != 2 { + t.Fatalf("detected contracts = %#v", output.DetectedContracts) + } + if len(output.DetectedTestCommands) == 0 { + t.Fatalf("expected test command candidates: %#v", output) + } +} + +func TestRunAllowsProjectWithoutGoMod(t *testing.T) { + dir := t.TempDir() + + output, err := run(Config{Dir: &dir}, &options{json: true}) + if err != nil { + t.Fatalf("run() error = %v", err) + } + if !output.OK { + t.Fatalf("output.OK = false: %#v", output.Diagnostics) + } + if len(output.Diagnostics) != 1 || output.Diagnostics[0].Code != "adopt.go_mod_missing" { + t.Fatalf("diagnostics = %#v, want go_mod_missing warning", output.Diagnostics) + } + assertAdoptFileExists(t, dir, manifestFileName) +} + +func TestRunCreatesMissingTargetDirectoryWithoutScanNoise(t *testing.T) { + dir := filepath.Join(t.TempDir(), "missing") + + output, err := run(Config{Dir: &dir}, &options{json: true}) + if err != nil { + t.Fatalf("run() error = %v", err) + } + if !output.OK { + t.Fatalf("output.OK = false: %#v", output.Diagnostics) + } + assertAdoptFileExists(t, dir, manifestFileName) + for _, item := range output.Diagnostics { + if item.Code == "adopt.scan_skipped" { + t.Fatalf("unexpected scan diagnostic for missing target directory: %#v", output.Diagnostics) + } + } +} + +func TestCommandRendersSchemaCompliantJSON(t *testing.T) { + dir := t.TempDir() + writeAdoptFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--json", "--pretty", "--agent", "codex"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute adopt: %v", err) + } + var output result + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output.ResultKind != resultKindAdopt || !output.OK { + t.Fatalf("unexpected output: %#v", output) + } + assertAdoptFileExists(t, dir, codexInstructionFile) +} + +func writeAdoptFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func readAdoptFile(t *testing.T, dir string, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) + if err != nil { + t.Fatal(err) + } + return string(data) +} + +func assertAdoptFileExists(t *testing.T, dir string, name string) { + t.Helper() + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))); err != nil { + t.Fatalf("%s should exist: %v", name, err) + } +} + +func assertAdoptFileMissing(t *testing.T, dir string, name string) { + t.Helper() + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))); err == nil { + t.Fatalf("%s should not exist", name) + } else if !os.IsNotExist(err) { + t.Fatalf("stat %s: %v", name, err) + } +} diff --git a/cmd/nucleus/internal/apply/apply.go b/cmd/nucleus/internal/apply/apply.go index 1716b9b..a8e2b54 100644 --- a/cmd/nucleus/internal/apply/apply.go +++ b/cmd/nucleus/internal/apply/apply.go @@ -34,22 +34,15 @@ func BuildEvidence(dir string, planPath string) (map[string]any, error) { "kind": "edit_surface_check", "path": path, "surface": surface, - "pass": allowed, + "status": stepStatus(allowed), + "ok": allowed, "exit_code": boolExitCode(allowed), "needs_write": false, }) } - return map[string]any{ - "schema_version": "evidence.v1", - "kind": "nucleus.apply_evidence", - "mode": "dry-run", - "pass": pass, - "steps": steps, - "diffs": []string{}, - "rollback_points": []map[string]any{ - {"id": "dry-run", "strategy": "no writes performed", "available": true}, - }, - }, nil + return applyEvidence("dry-run", pass, steps, []map[string]any{ + {"id": "dry-run", "strategy": "no writes performed", "available": true}, + }), nil } func Apply(dir string, planPath string) (map[string]any, error) { @@ -80,7 +73,8 @@ func Apply(dir string, planPath string) (map[string]any, error) { "kind": "edit_surface_check", "path": path, "surface": surface, - "pass": allowed, + "status": stepStatus(allowed), + "ok": allowed, "exit_code": boolExitCode(allowed), "needs_write": true, }) @@ -99,7 +93,8 @@ func Apply(dir string, planPath string) (map[string]any, error) { "id": fmt.Sprintf("apply-write-%d", index+1), "kind": "file_write", "path": path, - "pass": false, + "status": statusFailed, + "ok": false, "exit_code": 1, "error": err.Error(), }) @@ -111,7 +106,8 @@ func Apply(dir string, planPath string) (map[string]any, error) { "id": fmt.Sprintf("apply-write-%d", index+1), "kind": "file_write", "path": path, - "pass": false, + "status": statusFailed, + "ok": false, "exit_code": 1, "error": err.Error(), }) @@ -124,7 +120,8 @@ func Apply(dir string, planPath string) (map[string]any, error) { "id": fmt.Sprintf("apply-write-%d", index+1), "kind": "file_write", "path": path, - "pass": false, + "status": statusFailed, + "ok": false, "exit_code": 1, "error": err.Error(), }) @@ -138,7 +135,8 @@ func Apply(dir string, planPath string) (map[string]any, error) { "id": fmt.Sprintf("apply-write-%d", index+1), "kind": "file_write", "path": path, - "pass": false, + "status": statusFailed, + "ok": false, "exit_code": 1, "error": err.Error(), }) @@ -150,7 +148,8 @@ func Apply(dir string, planPath string) (map[string]any, error) { "id": fmt.Sprintf("apply-write-%d", index+1), "kind": "file_write", "path": path, - "pass": false, + "status": statusFailed, + "ok": false, "exit_code": 1, "error": err.Error(), }) @@ -161,7 +160,8 @@ func Apply(dir string, planPath string) (map[string]any, error) { "id": fmt.Sprintf("apply-write-%d", index+1), "kind": "file_write", "path": path, - "pass": true, + "status": statusPassed, + "ok": true, "exit_code": 0, "bytes": len(content), }) @@ -203,7 +203,8 @@ func appendSkippedCommands(steps []map[string]any, commands []map[string]any) [] "id": fmt.Sprintf("command-skipped-%d", index+1), "kind": "command_skipped", "command": commandText, - "pass": true, + "status": statusSkipped, + "ok": true, "exit_code": 0, "reason": "apply does not execute shell commands", }) @@ -213,11 +214,14 @@ func appendSkippedCommands(steps []map[string]any, commands []map[string]any) [] func applyEvidence(mode string, pass bool, steps []map[string]any, rollbackPoints []map[string]any) map[string]any { return map[string]any{ - "schema_version": "evidence.v1", - "kind": "nucleus.apply_evidence", + "result_kind": resultKindApplyEvidence, + "schema_version": schemaVersionEvidence, + "schema_ref": schemaRefEvidence, + "ok": pass, "mode": mode, - "pass": pass, + "status": stepStatus(pass), "steps": steps, + "diagnostics": []map[string]any{}, "diffs": []string{}, "rollback_points": rollbackPoints, } @@ -346,3 +350,16 @@ func boolExitCode(pass bool) int { } return 1 } + +const ( + statusPassed = "passed" + statusFailed = "failed" + statusSkipped = "skipped" +) + +func stepStatus(ok bool) string { + if ok { + return statusPassed + } + return statusFailed +} diff --git a/cmd/nucleus/internal/apply/command_test.go b/cmd/nucleus/internal/apply/command_test.go index 8fe5529..c0864d5 100644 --- a/cmd/nucleus/internal/apply/command_test.go +++ b/cmd/nucleus/internal/apply/command_test.go @@ -38,18 +38,19 @@ func TestCommandJSONDryRunSuccess(t *testing.T) { } var output struct { - Kind string `json:"kind"` - Pass bool `json:"pass"` - Mode string `json:"mode"` - Steps []map[string]any `json:"steps"` + ResultKind string `json:"result_kind"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Mode string `json:"mode"` + Steps []map[string]any `json:"steps"` } if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) } - if output.Kind != "nucleus.apply_evidence" || !output.Pass || output.Mode != "dry-run" { + if output.ResultKind != resultKindApplyEvidence || output.SchemaRef != schemaRefEvidence || !output.OK || output.Mode != "dry-run" { t.Fatalf("unexpected apply evidence: %#v", output) } - if len(output.Steps) != 1 || output.Steps[0]["surface"] != "allowed" { + if len(output.Steps) != 1 || output.Steps[0]["surface"] != "allowed" || output.Steps[0]["ok"] != true { t.Fatalf("steps = %#v, want one allowed edit check", output.Steps) } } @@ -117,7 +118,7 @@ func TestApplyRejectsSymlinkEditPath(t *testing.T) { if err != nil { t.Fatalf("Apply returned error: %v", err) } - if evidence["pass"] != false { + if evidence["ok"] != false { t.Fatalf("symlink edit should fail: %#v", evidence) } data, err := os.ReadFile(outsidePath) @@ -131,7 +132,7 @@ func TestApplyRejectsSymlinkEditPath(t *testing.T) { func writeApplyService(t *testing.T, dir string, allowed []string) { t.Helper() - writeApplyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeApplyFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" diff --git a/cmd/nucleus/internal/apply/constants.go b/cmd/nucleus/internal/apply/constants.go index 528775b..4e4a32b 100644 --- a/cmd/nucleus/internal/apply/constants.go +++ b/cmd/nucleus/internal/apply/constants.go @@ -24,3 +24,9 @@ const ( jsonIndentPrefix = "" jsonIndentValue = " " ) + +const ( + resultKindApplyEvidence = "nucleus.apply_evidence" + schemaVersionEvidence = "evidence.v1" + schemaRefEvidence = "contract/schema/evidence.v1.schema.json" +) diff --git a/cmd/nucleus/internal/apply/output.go b/cmd/nucleus/internal/apply/output.go index 8dc2a17..02d5847 100644 --- a/cmd/nucleus/internal/apply/output.go +++ b/cmd/nucleus/internal/apply/output.go @@ -34,8 +34,8 @@ func renderJSON(writer io.Writer, evidence map[string]any, pretty bool) error { } func evidencePass(evidence map[string]any) bool { - pass, _ := evidence["pass"].(bool) - return pass + ok, _ := evidence["ok"].(bool) + return ok } func stringField(evidence map[string]any, key string) string { @@ -62,13 +62,13 @@ func failedSteps(evidence map[string]any) int { case []any: for _, value := range steps { step, ok := value.(map[string]any) - if ok && step["pass"] == false { + if ok && step["ok"] == false { failed++ } } case []map[string]any: for _, step := range steps { - if step["pass"] == false { + if step["ok"] == false { failed++ } } diff --git a/cmd/nucleus/internal/capability/command.go b/cmd/nucleus/internal/capability/command.go deleted file mode 100644 index 48f0b25..0000000 --- a/cmd/nucleus/internal/capability/command.go +++ /dev/null @@ -1,83 +0,0 @@ -package capability - -import ( - "errors" - "fmt" - - "github.com/spf13/cobra" -) - -// Config carries root-level flag values used by the capability command. -type Config struct { - Dir *string -} - -type options struct { - provider string - dryRun bool - force bool - json bool - pretty bool -} - -// ErrCapabilityFailed is returned when a capability scaffold cannot be applied safely. -var ErrCapabilityFailed = errors.New("capability failed") - -// NewCommand creates the capability subcommand group. -func NewCommand(config Config) *cobra.Command { - cmd := &cobra.Command{ - Use: commandUseCapability, - Short: commandShortCapability, - SilenceUsage: true, - SilenceErrors: true, - Args: cobra.NoArgs, - } - cmd.AddCommand(newAddCommand(config)) - return cmd -} - -func newAddCommand(config Config) *cobra.Command { - opts := &options{} - cmd := &cobra.Command{ - Use: commandUseCapabilityAdd, - Short: commandShortCapabilityAdd, - SilenceUsage: true, - SilenceErrors: true, - Args: func(cmd *cobra.Command, args []string) error { - if len(args) != 1 { - return fmt.Errorf("capability add requires exactly one capability") - } - return nil - }, - RunE: func(cmd *cobra.Command, args []string) error { - result, err := run(config, opts, args[0]) - if opts.json { - if renderErr := renderJSON(cmd.OutOrStdout(), result, opts.pretty); renderErr != nil { - return renderErr - } - } else { - renderHuman(cmd.OutOrStdout(), cmd.ErrOrStderr(), result) - } - if err != nil { - return err - } - if !result.OK { - return fmt.Errorf("%w: capability diagnostics contain errors", ErrCapabilityFailed) - } - return nil - }, - } - cmd.Flags().StringVar(&opts.provider, flagProvider, "", flagHelpProvider) - cmd.Flags().BoolVar(&opts.dryRun, flagDryRun, false, flagHelpDryRun) - cmd.Flags().BoolVar(&opts.force, flagForce, false, flagHelpForce) - cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) - cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) - return cmd -} - -func stringValue(value *string, fallback string) string { - if value == nil { - return fallback - } - return *value -} diff --git a/cmd/nucleus/internal/capability/command_test.go b/cmd/nucleus/internal/capability/command_test.go deleted file mode 100644 index 1d60bff..0000000 --- a/cmd/nucleus/internal/capability/command_test.go +++ /dev/null @@ -1,336 +0,0 @@ -package capability - -import ( - "bytes" - "encoding/json" - "errors" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - contractlint "github.com/nucleuskit/contract/lint" - "github.com/nucleuskit/contract/validation" - "github.com/nucleuskit/nucleus/cmd/nucleus/internal/initcmd" - "github.com/nucleuskit/nucleus/cmd/nucleus/internal/plan" -) - -func TestAddRedisProducesVerifiableService(t *testing.T) { - dir := initService(t) - - output := executeCapabilityCommand(t, dir, "add", "redis", "--json") - if output.ResultKind != resultKindCapability || !output.OK { - t.Fatalf("unexpected output: %#v", output) - } - if output.Capability != "redis" || output.Provider != "redis" { - t.Fatalf("capability/provider = %s/%s, want redis/redis", output.Capability, output.Provider) - } - assertChange(t, output.Files, "nucleus.yaml", actionUpdated) - assertChange(t, output.Files, "internal/component/redis/redis.go", actionCreated) - assertFileContains(t, dir, "nucleus.yaml", "redis:") - assertFileContains(t, dir, "nucleus.yaml", "provider: redis") - assertFileContains(t, dir, "internal/component/redis/redis.go", "func NewFromEnv() (*RedisComponent, error)") - assertFileContains(t, dir, "internal/app/capabilities_redis.go", "func NewRedisCapability() (*RedisCapability, error)") - assertValidationClean(t, dir) - assertStrictLintClean(t, dir) - runGoTest(t, dir) -} - -func TestAddSQLPostgresProducesBuildableService(t *testing.T) { - dir := initService(t) - - output := executeCapabilityCommand(t, dir, "add", "sql", "--provider", "postgres", "--json") - if output.Capability != "sql" || output.Provider != "postgres" || !output.OK { - t.Fatalf("unexpected output: %#v", output) - } - assertChange(t, output.Files, "go.mod", actionUpdated) - assertChange(t, output.Files, "internal/component/sql/postgres.go", actionCreated) - assertFileContains(t, dir, "go.mod", "github.com/lib/pq v1.10.9") - assertFileNotContains(t, dir, "go.mod", "anniext.cn") - assertFileContains(t, dir, "internal/component/sql/postgres.go", `import (`) - assertFileContains(t, dir, "internal/component/sql/postgres.go", `_ "github.com/lib/pq"`) - assertFileNotContains(t, dir, "internal/component/sql/postgres.go", "anniext.cn") - assertValidationClean(t, dir) - assertStrictLintClean(t, dir) - runGoTest(t, dir) -} - -func TestDryRunDoesNotWriteFiles(t *testing.T) { - dir := initService(t) - before := readFile(t, dir, "nucleus.yaml") - - output := executeCapabilityCommand(t, dir, "add", "redis", "--dry-run", "--json") - if !output.OK || !output.DryRun { - t.Fatalf("unexpected dry-run output: %#v", output) - } - assertChange(t, output.Files, "nucleus.yaml", actionWouldUpdate) - assertChange(t, output.Files, "internal/component/redis/redis.go", actionWouldCreate) - assertFileNotExists(t, dir, "internal/component/redis/redis.go") - if after := readFile(t, dir, "nucleus.yaml"); after != before { - t.Fatalf("nucleus.yaml changed during dry-run\nbefore=%s\nafter=%s", before, after) - } -} - -func TestRejectsGeneratedFileConflictUnlessForced(t *testing.T) { - dir := initService(t) - writeFile(t, dir, "internal/component/redis/redis.go", "package redis\n\nconst custom = true\n") - before := readFile(t, dir, "nucleus.yaml") - - output, err := executeCapabilityCommandError(t, dir, "add", "redis", "--json") - if !errors.Is(err, ErrCapabilityFailed) { - t.Fatalf("execute error = %v, want ErrCapabilityFailed", err) - } - if output.OK { - t.Fatalf("ok = true, want false: %#v", output) - } - assertChange(t, output.Files, "internal/component/redis/redis.go", actionConflict) - if after := readFile(t, dir, "nucleus.yaml"); after != before { - t.Fatalf("nucleus.yaml changed despite conflict\nbefore=%s\nafter=%s", before, after) - } - - forced := executeCapabilityCommand(t, dir, "add", "redis", "--force", "--json") - if !forced.OK || !forced.Forced { - t.Fatalf("unexpected forced output: %#v", forced) - } - assertFileContains(t, dir, "internal/component/redis/redis.go", "type RedisConfig struct") - assertValidationClean(t, dir) - assertStrictLintClean(t, dir) - runGoTest(t, dir) -} - -func TestRejectsUnsupportedProviderWithStructuredJSON(t *testing.T) { - dir := initService(t) - - output, err := executeCapabilityCommandError(t, dir, "add", "redis", "--provider", "postgres", "--json") - if !errors.Is(err, ErrCapabilityFailed) { - t.Fatalf("execute error = %v, want ErrCapabilityFailed", err) - } - if output.OK { - t.Fatalf("ok = true, want false: %#v", output) - } - if len(output.Diagnostics) != 1 || output.Diagnostics[0].Code != "capability.provider_unsupported" { - t.Fatalf("diagnostics = %#v, want provider unsupported", output.Diagnostics) - } -} - -func TestRejectsRuntimeCapabilityScaffold(t *testing.T) { - dir := initService(t) - - output, err := executeCapabilityCommandError(t, dir, "add", "http", "--json") - if !errors.Is(err, ErrCapabilityFailed) { - t.Fatalf("execute error = %v, want ErrCapabilityFailed", err) - } - if output.OK { - t.Fatalf("ok = true, want false: %#v", output) - } - if len(output.Diagnostics) != 1 || output.Diagnostics[0].Code != "capability.runtime_unsupported" { - t.Fatalf("diagnostics = %#v, want runtime unsupported", output.Diagnostics) - } -} - -func TestPrettyJSONOutput(t *testing.T) { - dir := initService(t) - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"add", "redis", "--dry-run", "--json", "--pretty"}) - - if err := cmd.Execute(); err != nil { - t.Fatalf("execute capability: %v", err) - } - if !strings.Contains(stdout.String(), "\n \"result_kind\"") { - t.Fatalf("stdout = %q, want indented JSON", stdout.String()) - } -} - -func TestPlanSuggestedCapabilityCommandsAreAcceptedByDryRun(t *testing.T) { - dir := initService(t) - for _, task := range []string{ - "接入 redis 能力", - "接入 postgres 数据库能力", - "接入 sentry 错误追踪能力", - } { - output, err := plan.BuildOutput(plan.OutputOptions{Dir: dir, Task: task}) - if err != nil { - t.Fatalf("BuildOutput(%q): %v", task, err) - } - commands := anyStringSlice(output["commands"]) - found := false - for _, command := range commands { - if !strings.HasPrefix(command, "nucleus capability add ") { - continue - } - found = true - args := append(strings.Fields(strings.TrimPrefix(command, "nucleus capability ")), "--dry-run", "--json") - result := executeCapabilityCommand(t, dir, args...) - if !result.OK { - t.Fatalf("dry-run for %q returned not ok: %#v", command, result) - } - } - if !found { - t.Fatalf("task %q produced no capability add command: %#v", task, commands) - } - } -} - -type capabilityCommandOutput struct { - ResultKind string `json:"result_kind"` - OK bool `json:"ok"` - Capability string `json:"capability"` - Provider string `json:"provider"` - DryRun bool `json:"dry_run,omitempty"` - Forced bool `json:"forced,omitempty"` - Files []fileChange - Diagnostics []struct { - Severity string `json:"severity"` - Code string `json:"code"` - Path string `json:"path,omitempty"` - Message string `json:"message"` - } `json:"diagnostics,omitempty"` -} - -func initService(t *testing.T) string { - t.Helper() - dir := t.TempDir() - cmd := initcmd.NewCommand(initcmd.Config{Dir: &dir}) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs([]string{"--name", "demo", "--module", "example.com/demo"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("execute init: %v\nstderr=%s\nstdout=%s", err, stderr.String(), stdout.String()) - } - return dir -} - -func executeCapabilityCommand(t *testing.T, dir string, args ...string) capabilityCommandOutput { - t.Helper() - output, err := executeCapabilityCommandError(t, dir, args...) - if err != nil { - t.Fatalf("execute capability: %v\noutput=%#v", err, output) - } - return output -} - -func executeCapabilityCommandError(t *testing.T, dir string, args ...string) (capabilityCommandOutput, error) { - t.Helper() - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs(args) - err := cmd.Execute() - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } - var output capabilityCommandOutput - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) - } - return output, err -} - -func assertValidationClean(t *testing.T, dir string) { - t.Helper() - diagnostics := validation.ValidateService(dir) - if diagnostics.Failed() { - t.Fatalf("validation diagnostics = %#v", diagnostics) - } -} - -func assertStrictLintClean(t *testing.T, dir string) { - t.Helper() - findings := contractlint.Run(dir, true) - if len(findings) != 0 { - t.Fatalf("strict lint findings = %#v", findings) - } -} - -func runGoTest(t *testing.T, dir string) { - t.Helper() - cmd := exec.Command("go", "test", "./...") - cmd.Dir = dir - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("go test ./... failed: %v\n%s", err, string(output)) - } -} - -func assertChange(t *testing.T, files []fileChange, path string, action string) { - t.Helper() - for _, file := range files { - if file.Path == path { - if file.Action != action { - t.Fatalf("file %s action = %s, want %s; files=%#v", path, file.Action, action, files) - } - return - } - } - t.Fatalf("files = %#v, want %s", files, path) -} - -func assertFileContains(t *testing.T, dir string, name string, want string) { - t.Helper() - data := readFile(t, dir, name) - if !strings.Contains(data, want) { - t.Fatalf("%s = %q, want %q", name, data, want) - } -} - -func assertFileNotContains(t *testing.T, dir string, name string, unwanted string) { - t.Helper() - data := readFile(t, dir, name) - if strings.Contains(data, unwanted) { - t.Fatalf("%s = %q, did not want %q", name, data, unwanted) - } -} - -func assertFileNotExists(t *testing.T, dir string, name string) { - t.Helper() - if _, err := os.Stat(filepath.Join(dir, name)); err == nil || !os.IsNotExist(err) { - t.Fatalf("%s exists or stat failed with %v, want not exist", name, err) - } -} - -func readFile(t *testing.T, dir string, name string) string { - t.Helper() - data, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - t.Fatal(err) - } - return string(data) -} - -func writeFile(t *testing.T, dir string, name string, data string) { - t.Helper() - path := filepath.Join(dir, name) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(data), 0o600); err != nil { - t.Fatal(err) - } -} - -func anyStringSlice(value any) []string { - items, ok := value.([]string) - if ok { - return items - } - raw, ok := value.([]any) - if !ok { - return nil - } - values := make([]string, 0, len(raw)) - for _, item := range raw { - text, ok := item.(string) - if ok { - values = append(values, text) - } - } - return values -} diff --git a/cmd/nucleus/internal/capability/constants.go b/cmd/nucleus/internal/capability/constants.go deleted file mode 100644 index 1fbeb1d..0000000 --- a/cmd/nucleus/internal/capability/constants.go +++ /dev/null @@ -1,45 +0,0 @@ -package capability - -const ( - commandUseCapability = "capability" - commandUseCapabilityAdd = "add " - commandShortCapability = "manage service capability scaffolds" - commandShortCapabilityAdd = "add a capability declaration and service-side scaffold" - defaultDir = "." -) - -const ( - flagProvider = "provider" - flagDryRun = "dry-run" - flagForce = "force" - flagJSON = "json" - flagPretty = "pretty" -) - -const ( - flagHelpProvider = "capability provider scaffold" - flagHelpDryRun = "preview changes without writing files" - flagHelpForce = "overwrite generated scaffold files when their content differs" - flagHelpJSON = "emit machine-readable capability result" - flagHelpPretty = "pretty-print JSON output" -) - -const ( - resultKindCapability = "nucleus.capability_result" - schemaVersion = "capability.v1" - schemaRefEvidence = "contract/schema/evidence.schema.json" -) - -const ( - actionCreated = "created" - actionUpdated = "updated" - actionUnchanged = "unchanged" - actionWouldCreate = "would_create" - actionWouldUpdate = "would_update" - actionConflict = "conflict" -) - -const ( - postgresDriverModule = "github.com/lib/pq" - postgresDriverVersion = "v1.10.9" -) diff --git a/cmd/nucleus/internal/capability/doc.go b/cmd/nucleus/internal/capability/doc.go deleted file mode 100644 index e1be8f5..0000000 --- a/cmd/nucleus/internal/capability/doc.go +++ /dev/null @@ -1,7 +0,0 @@ -// Package capability implements CLI scaffolding for manifest-first capability -// declarations. -// -// The package is intentionally service-side orchestration code. It does not -// define public capability protocols, provider SDK APIs, or runtime defaults; -// those belong in cap, bridge, runtime, or generated service wiring. -package capability diff --git a/cmd/nucleus/internal/capability/gomod.go b/cmd/nucleus/internal/capability/gomod.go deleted file mode 100644 index 9f1f607..0000000 --- a/cmd/nucleus/internal/capability/gomod.go +++ /dev/null @@ -1,80 +0,0 @@ -package capability - -import ( - "fmt" - "os" - "strings" -) - -type moduleRequirement struct { - Path string - Version string -} - -func updateGoModRequires(path string, requires []moduleRequirement) ([]byte, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, fmt.Errorf("go.mod is required and must be readable") - } - text := string(data) - var missing []moduleRequirement - for _, item := range requires { - if strings.TrimSpace(item.Path) == "" || strings.TrimSpace(item.Version) == "" { - return nil, fmt.Errorf("module requirement path and version are required") - } - if !strings.Contains(text, item.Path+" ") { - missing = append(missing, item) - } - } - if len(missing) == 0 { - return data, nil - } - if strings.Contains(text, "\nrequire (\n") { - blockStart := strings.Index(text, "\nrequire (\n") - index := strings.LastIndex(text, "\n)") - if index > blockStart { - var builder strings.Builder - for _, item := range missing { - builder.WriteString("\t" + item.Path + " " + item.Version + "\n") - } - text = text[:index] + "\n" + builder.String() + text[index:] - return []byte(text), nil - } - } - if !strings.HasSuffix(text, "\n") { - text += "\n" - } - var builder strings.Builder - builder.WriteString(text) - builder.WriteString("\nrequire (\n") - for _, item := range missing { - builder.WriteString("\t" + item.Path + " " + item.Version + "\n") - } - builder.WriteString(")\n") - return []byte(builder.String()), nil -} - -func updateGoSumEntries(path string, entries []string) ([]byte, error) { - data, err := os.ReadFile(path) - if err != nil && !os.IsNotExist(err) { - return nil, fmt.Errorf("go.sum must be readable") - } - text := string(data) - if text != "" && !strings.HasSuffix(text, "\n") { - text += "\n" - } - for _, entry := range entries { - if strings.Contains(text, entry+"\n") { - continue - } - text += entry + "\n" - } - return []byte(text), nil -} - -func postgresGoSumEntries() []string { - return []string{ - "github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=", - "github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=", - } -} diff --git a/cmd/nucleus/internal/capability/manifest.go b/cmd/nucleus/internal/capability/manifest.go deleted file mode 100644 index 0aae83a..0000000 --- a/cmd/nucleus/internal/capability/manifest.go +++ /dev/null @@ -1,147 +0,0 @@ -package capability - -import ( - "bytes" - "fmt" - "os" - "path/filepath" - - "github.com/nucleuskit/contract/diagnostic" - "github.com/nucleuskit/nucleus/cmd/nucleus/internal/capcatalog" - "go.yaml.in/yaml/v3" -) - -func updatedManifest(path string, spec capcatalog.Spec, provider string) ([]byte, diagnostic.Diagnostics) { - data, err := os.ReadFile(path) - if err != nil { - return nil, errorDiagnostic("capability.manifest_unavailable", "nucleus.yaml is required and must be readable") - } - var doc yaml.Node - if err := yaml.Unmarshal(data, &doc); err != nil { - return nil, errorDiagnostic("capability.manifest_parse_failed", fmt.Sprintf("parse nucleus.yaml: %v", err)) - } - root := documentRoot(&doc) - if root == nil || root.Kind != yaml.MappingNode { - return nil, errorDiagnostic("capability.manifest_invalid", "nucleus.yaml must be a YAML mapping") - } - ensureCapability(root, spec.Name) - ensureProvider(root, spec, provider) - ensureAllowedChanges(root, allowedChangesFor(spec, provider)) - var buffer bytes.Buffer - encoder := yaml.NewEncoder(&buffer) - encoder.SetIndent(2) - if err := encoder.Encode(root); err != nil { - return nil, errorDiagnostic("capability.manifest_encode_failed", err.Error()) - } - if err := encoder.Close(); err != nil { - return nil, errorDiagnostic("capability.manifest_encode_failed", err.Error()) - } - return buffer.Bytes(), nil -} - -func documentRoot(doc *yaml.Node) *yaml.Node { - if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { - return doc.Content[0] - } - return doc -} - -func ensureCapability(root *yaml.Node, capability string) { - capabilities := ensureMappingValue(root, "capabilities", yaml.SequenceNode) - for _, item := range capabilities.Content { - if item.Value == capability { - return - } - } - capabilities.Content = append(capabilities.Content, scalarNode(capability)) -} - -func ensureProvider(root *yaml.Node, spec capcatalog.Spec, provider string) { - nucleus := ensureMappingValue(root, "nucleus", yaml.MappingNode) - providers := ensureMappingValue(nucleus, "providers", yaml.MappingNode) - capabilityConfig := ensureMappingValue(providers, spec.Name, yaml.MappingNode) - setScalarValue(capabilityConfig, "provider", provider) - if spec.DSNEnv != "" { - setScalarValue(capabilityConfig, "dsn_env", spec.DSNEnv) - } - if spec.Name == "sql" && provider == "postgres" { - setScalarValue(capabilityConfig, "driver", "postgres") - } -} - -func ensureAllowedChanges(root *yaml.Node, items []string) { - ai := ensureMappingValue(root, "ai", yaml.MappingNode) - allowed := ensureMappingValue(ai, "allowed_changes", yaml.SequenceNode) - existing := map[string]struct{}{} - for _, item := range allowed.Content { - existing[item.Value] = struct{}{} - } - for _, item := range items { - if _, ok := existing[item]; ok { - continue - } - allowed.Content = append(allowed.Content, scalarNode(item)) - existing[item] = struct{}{} - } -} - -func allowedChangesFor(spec capcatalog.Spec, provider string) []string { - changes := []string{ - "nucleus.yaml", - filepath.ToSlash(filepath.Join("internal", "component", "**")), - filepath.ToSlash(filepath.Join("internal", "app", "**")), - filepath.ToSlash(filepath.Join("docs", "**")), - } - if spec.Name == "sql" && provider == "postgres" { - changes = append(changes, - "go.mod", - "go.sum", - filepath.ToSlash(filepath.Join("internal", "adapter", "store", "**")), - filepath.ToSlash(filepath.Join("deploy", "**")), - ) - } - return changes -} - -func ensureMappingValue(root *yaml.Node, key string, kind yaml.Kind) *yaml.Node { - for index := 0; index+1 < len(root.Content); index += 2 { - if root.Content[index].Value == key { - value := root.Content[index+1] - if value.Kind != kind { - value.Kind = kind - value.Tag = tagForKind(kind) - value.Value = "" - value.Content = nil - } - return value - } - } - value := &yaml.Node{Kind: kind, Tag: tagForKind(kind)} - root.Content = append(root.Content, scalarNode(key), value) - return value -} - -func setScalarValue(root *yaml.Node, key string, value string) { - for index := 0; index+1 < len(root.Content); index += 2 { - if root.Content[index].Value == key { - root.Content[index+1] = scalarNode(value) - return - } - } - root.Content = append(root.Content, scalarNode(key), scalarNode(value)) -} - -func scalarNode(value string) *yaml.Node { - return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: value} -} - -func tagForKind(kind yaml.Kind) string { - switch kind { - case yaml.SequenceNode: - return "!!seq" - case yaml.MappingNode: - return "!!map" - default: - return "!!str" - } -} diff --git a/cmd/nucleus/internal/capability/output.go b/cmd/nucleus/internal/capability/output.go deleted file mode 100644 index 3301ace..0000000 --- a/cmd/nucleus/internal/capability/output.go +++ /dev/null @@ -1,121 +0,0 @@ -package capability - -import ( - "encoding/json" - "fmt" - "io" - - "github.com/nucleuskit/contract/diagnostic" -) - -type capabilityResult struct { - ResultKind string `json:"result_kind"` - SchemaVersion string `json:"schema_version"` - SchemaRef string `json:"schema_ref"` - OK bool `json:"ok"` - Mode string `json:"mode"` - Capability string `json:"capability,omitempty"` - Provider string `json:"provider,omitempty"` - DryRun bool `json:"dry_run,omitempty"` - Forced bool `json:"forced,omitempty"` - Summary capabilitySummary `json:"summary"` - Files []fileChange `json:"files"` - Diagnostics diagnostic.Diagnostics `json:"diagnostics"` - NextSteps []string `json:"next_steps,omitempty"` -} - -type capabilitySummary struct { - Changed int `json:"changed"` - Created int `json:"created"` - Updated int `json:"updated"` - Unchanged int `json:"unchanged"` - Conflicts int `json:"conflicts"` - Errors int `json:"errors"` - Warnings int `json:"warnings"` -} - -type fileChange struct { - Path string `json:"path"` - Action string `json:"action"` -} - -func renderJSON(writer io.Writer, result capabilityResult, pretty bool) error { - result = normalizeResult(result) - encoder := json.NewEncoder(writer) - if pretty { - encoder.SetIndent("", " ") - } - return encoder.Encode(result) -} - -func renderHuman(stdout io.Writer, stderr io.Writer, result capabilityResult) { - status := "OK" - if !result.OK { - status = "FAILED" - } - if result.DryRun && result.OK { - status = "DRY-RUN" - } - _, _ = fmt.Fprintf(stdout, "%s capability %s provider=%s\n", status, result.Capability, result.Provider) - if len(result.Files) > 0 { - _, _ = fmt.Fprintln(stdout, "files:") - for _, file := range result.Files { - _, _ = fmt.Fprintf(stdout, " - %s %s\n", file.Action, file.Path) - } - } - if len(result.NextSteps) > 0 { - _, _ = fmt.Fprintln(stdout, "next steps:") - for _, step := range result.NextSteps { - _, _ = fmt.Fprintf(stdout, " - %s\n", step) - } - } - for _, item := range result.Diagnostics { - if item.Severity == diagnostic.SeverityError { - _, _ = fmt.Fprintf(stderr, "error: %s\n", item.Message) - } - } -} - -func normalizeResult(result capabilityResult) capabilityResult { - if result.ResultKind == "" { - result.ResultKind = resultKindCapability - } - if result.SchemaVersion == "" { - result.SchemaVersion = schemaVersion - } - if result.SchemaRef == "" { - result.SchemaRef = schemaRefEvidence - } - if result.Mode == "" { - result.Mode = "add" - } - if result.Files == nil { - result.Files = []fileChange{} - } - if result.Diagnostics == nil { - result.Diagnostics = diagnostic.Diagnostics{} - } - return result -} - -func summarize(files []fileChange, diagnostics diagnostic.Diagnostics) capabilitySummary { - summary := capabilitySummary{ - Errors: diagnostics.Count(diagnostic.SeverityError), - Warnings: diagnostics.Count(diagnostic.SeverityWarning), - } - for _, file := range files { - switch file.Action { - case actionCreated, actionWouldCreate: - summary.Created++ - summary.Changed++ - case actionUpdated, actionWouldUpdate: - summary.Updated++ - summary.Changed++ - case actionUnchanged: - summary.Unchanged++ - case actionConflict: - summary.Conflicts++ - } - } - return summary -} diff --git a/cmd/nucleus/internal/capability/run.go b/cmd/nucleus/internal/capability/run.go deleted file mode 100644 index d4b0ab5..0000000 --- a/cmd/nucleus/internal/capability/run.go +++ /dev/null @@ -1,287 +0,0 @@ -package capability - -import ( - "fmt" - "os" - "path/filepath" - "sort" - "strings" - - "github.com/nucleuskit/contract/diagnostic" - "github.com/nucleuskit/nucleus/cmd/nucleus/internal/capcatalog" -) - -type fileOperation struct { - path string - content []byte - generated bool -} - -func run(config Config, opts *options, capabilityName string) (capabilityResult, error) { - dir := stringValue(config.Dir, defaultDir) - spec, provider, diagnostics := normalizeRequest(capabilityName, opts.provider) - result := capabilityResult{ - ResultKind: resultKindCapability, - SchemaVersion: schemaVersion, - SchemaRef: schemaRefEvidence, - OK: !diagnostics.Failed(), - Mode: "add", - Capability: spec.Name, - Provider: provider, - DryRun: opts.dryRun, - Forced: opts.force, - Diagnostics: diagnostics, - } - if diagnostics.Failed() { - result.Summary = summarize(nil, diagnostics) - return result, fmt.Errorf("%w: invalid capability request", ErrCapabilityFailed) - } - - operations, buildDiagnostics := buildOperations(dir, spec, provider) - result.Diagnostics = append(result.Diagnostics, buildDiagnostics...) - if result.Diagnostics.Failed() { - result.OK = false - result.Summary = summarize(nil, result.Diagnostics) - return result, fmt.Errorf("%w: scaffold planning failed", ErrCapabilityFailed) - } - - files, applyDiagnostics, err := applyOperations(dir, operations, opts) - result.Files = files - result.Diagnostics = append(result.Diagnostics, applyDiagnostics...) - result.OK = !result.Diagnostics.Failed() - result.Summary = summarize(result.Files, result.Diagnostics) - if result.OK { - result.NextSteps = nextSteps(spec, provider, opts.dryRun) - } - if err != nil { - return result, err - } - if !result.OK { - return result, fmt.Errorf("%w: scaffold has conflicts", ErrCapabilityFailed) - } - return result, nil -} - -func normalizeRequest(capabilityName string, providerName string) (capcatalog.Spec, string, diagnostic.Diagnostics) { - spec, ok := capcatalog.Lookup(capabilityName) - if !ok { - return capcatalog.Spec{}, "", errorDiagnostic("capability.unsupported", fmt.Sprintf("unsupported capability %q", strings.TrimSpace(capabilityName))) - } - if !spec.Planning { - return spec, "", errorDiagnostic("capability.runtime_unsupported", fmt.Sprintf("runtime capability %q is managed by nucleus init/gen and cannot be scaffolded with capability add", spec.Name)) - } - provider := capcatalog.Normalize(providerName) - if provider == "" { - provider = spec.DefaultProvider - } - if !capcatalog.ProviderSupported(spec, provider) { - return spec, provider, errorDiagnostic("capability.provider_unsupported", fmt.Sprintf("unsupported %s provider %q; supported providers: %s", spec.Name, provider, strings.Join(spec.ProviderNames(), ", "))) - } - return spec, provider, nil -} - -func buildOperations(dir string, spec capcatalog.Spec, provider string) ([]fileOperation, diagnostic.Diagnostics) { - manifestPath := filepath.Join(dir, "nucleus.yaml") - manifestContent, diagnostics := updatedManifest(manifestPath, spec, provider) - if diagnostics.Failed() { - return nil, diagnostics - } - module, err := modulePath(filepath.Join(dir, "go.mod")) - if err != nil { - return nil, errorDiagnostic("capability.module_unavailable", err.Error()) - } - - ops := []fileOperation{{ - path: manifestPath, - content: manifestContent, - }} - if spec.Name == "sql" && provider == "postgres" { - sqlOps, diagnostics := postgresOperations(dir, module, spec, provider) - if diagnostics.Failed() { - return nil, diagnostics - } - ops = append(ops, sqlOps...) - return ops, nil - } - ops = append(ops, genericOperations(dir, module, spec, provider)...) - return ops, nil -} - -func genericOperations(dir string, module string, spec capcatalog.Spec, provider string) []fileOperation { - providerFile := providerFileName(provider) - return []fileOperation{ - { - path: filepath.Join(dir, "internal", "component", spec.Name, providerFile+".go"), - content: []byte(genericComponentTemplate(spec, provider)), - generated: true, - }, - { - path: filepath.Join(dir, "internal", "app", "capabilities_"+spec.Name+".go"), - content: []byte(genericAppTemplate(module, spec, provider)), - generated: true, - }, - { - path: filepath.Join(dir, "docs", "capabilities", spec.Name+"-"+providerFile+".md"), - content: []byte(genericDocsTemplate(spec, provider)), - generated: true, - }, - } -} - -func postgresOperations(dir string, module string, spec capcatalog.Spec, provider string) ([]fileOperation, diagnostic.Diagnostics) { - goMod, err := updateGoModRequires(filepath.Join(dir, "go.mod"), []moduleRequirement{{Path: postgresDriverModule, Version: postgresDriverVersion}}) - if err != nil { - return nil, errorDiagnostic("capability.gomod_failed", err.Error()) - } - goSum, err := updateGoSumEntries(filepath.Join(dir, "go.sum"), postgresGoSumEntries()) - if err != nil { - return nil, errorDiagnostic("capability.gosum_failed", err.Error()) - } - providerFile := providerFileName(provider) - return []fileOperation{ - {path: filepath.Join(dir, "go.mod"), content: goMod}, - {path: filepath.Join(dir, "go.sum"), content: goSum}, - { - path: filepath.Join(dir, "internal", "component", "sql", providerFile+".go"), - content: []byte(postgresComponentTemplate()), - generated: true, - }, - { - path: filepath.Join(dir, "internal", "app", "capabilities_sql.go"), - content: []byte(postgresAppTemplate(module)), - generated: true, - }, - { - path: filepath.Join(dir, "internal", "adapter", "store", "postgres", "repository.go"), - content: []byte(postgresRepositoryTemplate()), - generated: true, - }, - { - path: filepath.Join(dir, "deploy", "migrations", "000001_sql_postgres.sql"), - content: []byte(postgresMigrationTemplate()), - generated: true, - }, - { - path: filepath.Join(dir, "docs", "capabilities", spec.Name+"-"+providerFile+".md"), - content: []byte(postgresDocsTemplate(spec, provider)), - generated: true, - }, - }, nil -} - -func applyOperations(dir string, operations []fileOperation, opts *options) ([]fileChange, diagnostic.Diagnostics, error) { - sort.Slice(operations, func(i, j int) bool { - return operations[i].path < operations[j].path - }) - var files []fileChange - var diagnostics diagnostic.Diagnostics - for _, op := range operations { - change, itemDiagnostics := classifyOperation(dir, op, opts) - files = append(files, change) - diagnostics = append(diagnostics, itemDiagnostics...) - } - if diagnostics.Failed() { - return files, diagnostics, fmt.Errorf("%w: scaffold has file conflicts", ErrCapabilityFailed) - } - if opts.dryRun { - return files, diagnostics, nil - } - for _, op := range operations { - if err := writeOperation(op); err != nil { - item := diagnostic.Diagnostic{ - Severity: diagnostic.SeverityError, - Code: "capability.write_failed", - Path: relativeFile(dir, op.path), - Message: "write file failed", - } - diagnostics = append(diagnostics, item) - return files, diagnostics, fmt.Errorf("%w: write file failed", ErrCapabilityFailed) - } - } - return files, diagnostics, nil -} - -func classifyOperation(dir string, op fileOperation, opts *options) (fileChange, diagnostic.Diagnostics) { - rel := relativeFile(dir, op.path) - data, err := os.ReadFile(op.path) - if err != nil { - if os.IsNotExist(err) { - if opts.dryRun { - return fileChange{Path: rel, Action: actionWouldCreate}, nil - } - return fileChange{Path: rel, Action: actionCreated}, nil - } - return fileChange{Path: rel, Action: actionConflict}, errorDiagnosticAt("capability.read_failed", rel, "read file failed") - } - if string(data) == string(op.content) { - return fileChange{Path: rel, Action: actionUnchanged}, nil - } - if op.generated && !opts.force { - return fileChange{Path: rel, Action: actionConflict}, errorDiagnosticAt("capability.file_conflict", rel, "generated scaffold file already exists with different content; rerun with --force to overwrite") - } - if opts.dryRun { - return fileChange{Path: rel, Action: actionWouldUpdate}, nil - } - return fileChange{Path: rel, Action: actionUpdated}, nil -} - -func writeOperation(op fileOperation) error { - if err := os.MkdirAll(filepath.Dir(op.path), 0o755); err != nil { - return err - } - return os.WriteFile(op.path, op.content, 0o644) -} - -func nextSteps(spec capcatalog.Spec, provider string, dryRun bool) []string { - if dryRun { - return []string{"rerun without --dry-run to apply the scaffold"} - } - steps := []string{ - "run nucleus validate --dir .", - "run nucleus lint --dir . --strict", - "run nucleus verify --dir . --json", - } - if spec.Name == "sql" && provider == "postgres" { - steps = append([]string{"set NUCLEUS_DATABASE_DSN outside committed config before enabling the database connection"}, steps...) - } - return steps -} - -func modulePath(path string) (string, error) { - data, err := os.ReadFile(path) - if err != nil { - return "", fmt.Errorf("go.mod is required and must be readable") - } - for _, line := range strings.Split(string(data), "\n") { - fields := strings.Fields(line) - if len(fields) == 2 && fields[0] == "module" { - return fields[1], nil - } - } - return "", fmt.Errorf("go.mod module path is required") -} - -func relativeFile(dir string, path string) string { - rel, err := filepath.Rel(dir, path) - if err != nil { - return filepath.ToSlash(path) - } - return filepath.ToSlash(rel) -} - -func errorDiagnostic(code string, message string) diagnostic.Diagnostics { - return diagnostic.Diagnostics{{ - Severity: diagnostic.SeverityError, - Code: code, - Message: message, - }} -} - -func errorDiagnosticAt(code string, path string, message string) diagnostic.Diagnostics { - return diagnostic.Diagnostics{{ - Severity: diagnostic.SeverityError, - Code: code, - Path: path, - Message: message, - }} -} diff --git a/cmd/nucleus/internal/capability/templates.go b/cmd/nucleus/internal/capability/templates.go deleted file mode 100644 index 21418bb..0000000 --- a/cmd/nucleus/internal/capability/templates.go +++ /dev/null @@ -1,439 +0,0 @@ -package capability - -import ( - "fmt" - "strings" - - "github.com/nucleuskit/nucleus/cmd/nucleus/internal/capcatalog" -) - -func genericComponentTemplate(spec capcatalog.Spec, provider string) string { - typeName := goExportName(spec.Name) + "Component" - configName := goExportName(spec.Name) + "Config" - return fmt.Sprintf(`package %s - -import ( - "context" - "fmt" - "os" -) - -const ( - // CapabilityName is the manifest capability represented by this component. - CapabilityName = %q - // ProviderName is the manifest provider represented by this component. - ProviderName = %q - // DSNEnv is the optional environment variable used by this provider. - DSNEnv = %q -) - -// %s carries service-owned configuration for the %s capability. -type %s struct { - Provider string `+"`json:\"provider\" yaml:\"provider\"`"+` - DSNEnv string `+"`json:\"dsn_env,omitempty\" yaml:\"dsn_env,omitempty\"`"+` - DSN string `+"`json:\"-\" yaml:\"-\"`"+` -} - -// DefaultConfig returns the generated default provider metadata. -func DefaultConfig() %s { - return %s{ - Provider: ProviderName, - DSNEnv: DSNEnv, - } -} - -// LoadConfigFromEnv loads non-secret provider configuration from the process environment. -func LoadConfigFromEnv() %s { - cfg := DefaultConfig() - if cfg.DSNEnv != "" { - cfg.DSN = os.Getenv(cfg.DSNEnv) - } - return cfg -} - -// Validate checks static configuration without opening network connections. -func (c %s) Validate() error { - if c.Provider == "" { - return fmt.Errorf("%s provider is required") - } - return nil -} - -// %s is a compile-safe service-side capability adapter. -type %s struct { - config %s -} - -// New creates the capability adapter without importing provider SDKs. -func New(config %s) (*%s, error) { - if err := config.Validate(); err != nil { - return nil, err - } - return &%s{config: config}, nil -} - -// NewFromEnv creates the capability adapter from environment-backed configuration. -func NewFromEnv() (*%s, error) { - return New(LoadConfigFromEnv()) -} - -// Config returns the effective capability configuration. -func (c *%s) Config() %s { - if c == nil { - return DefaultConfig() - } - return c.config -} - -// Ready validates local configuration and honors context cancellation. -func (c *%s) Ready(ctx context.Context) error { - if err := ctx.Err(); err != nil { - return err - } - return c.Config().Validate() -} - -// Shutdown releases provider resources. The generated adapter owns no resources. -func (c *%s) Shutdown(ctx context.Context) error { - return ctx.Err() -} -`, goPackageName(spec.Name), spec.Name, provider, spec.DSNEnv, - configName, spec.Name, configName, - configName, configName, - configName, - configName, spec.Name, - typeName, typeName, configName, - configName, typeName, - typeName, - typeName, - typeName, configName, - typeName, - typeName) -} - -func genericAppTemplate(module string, spec capcatalog.Spec, provider string) string { - typeName := goExportName(spec.Name) + "Capability" - componentAlias := "component" + goExportName(spec.Name) - return fmt.Sprintf(`package app - -import ( - "context" - - %s "%s/internal/component/%s" -) - -const ( - // %sName is the manifest capability name. - %sName = %q - // %sProvider is the manifest provider name. - %sProvider = %q -) - -// %s owns the generated %s capability adapter. -type %s struct { - Component *%s.%s -} - -// New%s creates the generated capability adapter from environment-backed configuration. -func New%s() (*%s, error) { - component, err := %s.NewFromEnv() - if err != nil { - return nil, err - } - return &%s{Component: component}, nil -} - -// Shutdown releases generated capability resources. -func (c *%s) Shutdown(ctx context.Context) error { - if c == nil || c.Component == nil { - return nil - } - return c.Component.Shutdown(ctx) -} -`, componentAlias, module, goPackageName(spec.Name), - typeName, typeName, spec.Name, - typeName, typeName, provider, - typeName, spec.Name, typeName, componentAlias, goExportName(spec.Name)+"Component", - typeName, typeName, typeName, - componentAlias, - typeName, - typeName) -} - -func genericDocsTemplate(spec capcatalog.Spec, provider string) string { - dsnLine := "" - if spec.DSNEnv != "" { - dsnLine = "- Runtime DSN is read from " + spec.DSNEnv + " and must not be committed.\n" - } - return fmt.Sprintf("# Capability: %s/%s\n\n"+ - "- Capability %q is declared in nucleus.yaml.\n"+ - "- Provider %q is recorded as explicit manifest metadata.\n"+ - "%s- Generated service code is compile-safe and owns no hidden global provider state.\n"+ - "- Replace the generated component with concrete bridge wiring when the selected provider package exists.\n", - spec.Name, provider, spec.Name, provider, dsnLine) -} - -func postgresComponentTemplate() string { - return `package sql - -import ( - "context" - "database/sql" - "fmt" - "os" - - _ "github.com/lib/pq" -) - -const ( - // CapabilityName is the manifest capability represented by this component. - CapabilityName = "sql" - // ProviderName is the manifest provider represented by this component. - ProviderName = "postgres" - // DatabaseDSNEnv is the environment variable used for the PostgreSQL DSN. - DatabaseDSNEnv = "NUCLEUS_DATABASE_DSN" -) - -// PostgresConfig carries service-owned PostgreSQL configuration. -type PostgresConfig struct { - Provider string ` + "`json:\"provider\" yaml:\"provider\"`" + ` - Driver string ` + "`json:\"driver\" yaml:\"driver\"`" + ` - DSNEnv string ` + "`json:\"dsn_env\" yaml:\"dsn_env\"`" + ` - DSN string ` + "`json:\"-\" yaml:\"-\"`" + ` - MaxOpenConns int ` + "`json:\"max_open_conns\" yaml:\"max_open_conns\"`" + ` - MaxIdleConns int ` + "`json:\"max_idle_conns\" yaml:\"max_idle_conns\"`" + ` -} - -// DefaultPostgresConfig returns conservative database/sql pool defaults. -func DefaultPostgresConfig() PostgresConfig { - return PostgresConfig{ - Provider: ProviderName, - Driver: "postgres", - DSNEnv: DatabaseDSNEnv, - MaxOpenConns: 10, - MaxIdleConns: 5, - } -} - -// LoadPostgresConfigFromEnv loads secret DSN material from the process environment. -func LoadPostgresConfigFromEnv() PostgresConfig { - cfg := DefaultPostgresConfig() - cfg.DSN = os.Getenv(cfg.DSNEnv) - return cfg -} - -// Validate checks static database configuration without opening a connection. -func (c PostgresConfig) Validate() error { - if c.Provider == "" { - return fmt.Errorf("database provider is required") - } - if c.Driver == "" { - return fmt.Errorf("database driver is required") - } - if c.DSNEnv == "" { - return fmt.Errorf("database DSN environment name is required") - } - return nil -} - -// NewPostgresDB opens a PostgreSQL database handle when a DSN is configured. -func NewPostgresDB(config PostgresConfig) (*sql.DB, func(context.Context) error, error) { - if err := config.Validate(); err != nil { - return nil, nil, err - } - if config.DSN == "" { - return nil, func(context.Context) error { return nil }, nil - } - db, err := sql.Open(config.Driver, config.DSN) - if err != nil { - return nil, nil, err - } - db.SetMaxOpenConns(config.MaxOpenConns) - db.SetMaxIdleConns(config.MaxIdleConns) - cleanup := func(context.Context) error { - return db.Close() - } - return db, cleanup, nil -} - -// NewPostgresDBFromEnv opens a PostgreSQL database handle from environment configuration. -func NewPostgresDBFromEnv() (*sql.DB, func(context.Context) error, error) { - return NewPostgresDB(LoadPostgresConfigFromEnv()) -} -` -} - -func postgresAppTemplate(module string) string { - return fmt.Sprintf(`package app - -import ( - "context" - "database/sql" - - componentsql "%s/internal/component/sql" -) - -const ( - // SQLCapabilityName is the manifest capability name. - SQLCapabilityName = "sql" - // SQLCapabilityProvider is the manifest provider name. - SQLCapabilityProvider = "postgres" -) - -// SQLCapability owns the generated PostgreSQL database handle. -type SQLCapability struct { - DB *sql.DB - cleanup func(context.Context) error -} - -// NewSQLCapability creates the generated PostgreSQL database handle from environment configuration. -func NewSQLCapability() (*SQLCapability, error) { - db, cleanup, err := componentsql.NewPostgresDBFromEnv() - if err != nil { - return nil, err - } - return &SQLCapability{DB: db, cleanup: cleanup}, nil -} - -// Shutdown closes the generated database handle when it was opened. -func (c *SQLCapability) Shutdown(ctx context.Context) error { - if c == nil || c.cleanup == nil { - return nil - } - return c.cleanup(ctx) -} -`, module) -} - -func postgresRepositoryTemplate() string { - return `package postgres - -import ( - "context" - "database/sql" -) - -// Repository is a service-owned PostgreSQL repository base. -type Repository struct { - db *sql.DB -} - -// NewRepository creates a PostgreSQL repository base from an optional database handle. -func NewRepository(db *sql.DB) *Repository { - return &Repository{db: db} -} - -// DB returns the underlying database handle for service-owned query code. -func (r *Repository) DB() *sql.DB { - if r == nil { - return nil - } - return r.db -} - -// Ping verifies the database handle when it is configured. -func (r *Repository) Ping(ctx context.Context) error { - if r == nil || r.db == nil { - return nil - } - return r.db.PingContext(ctx) -} -` -} - -func postgresMigrationTemplate() string { - return `-- Generated by nucleus capability add sql --provider postgres. --- Add service-owned tables in a follow-up migration before applying it. -` -} - -func postgresDocsTemplate(spec capcatalog.Spec, provider string) string { - return fmt.Sprintf("# Capability: %s/%s\n\n"+ - "- Capability %q is declared in nucleus.yaml.\n"+ - "- Provider %q is recorded as explicit manifest metadata.\n"+ - "- The generated component uses database/sql and the PostgreSQL driver through an isolated service-side package.\n"+ - "- Runtime DSN is read from NUCLEUS_DATABASE_DSN and must not be committed.\n"+ - "- The generated database handle is optional: empty DSN keeps local tests and generated services runnable.\n", - spec.Name, provider, spec.Name, provider) -} - -func providerFileName(provider string) string { - if provider == "" { - return "noop" - } - return strings.NewReplacer("/", "_", "-", "_").Replace(provider) -} - -func goPackageName(value string) string { - value = strings.ToLower(value) - var builder strings.Builder - for _, ch := range value { - if ch >= 'a' && ch <= 'z' || ch >= '0' && ch <= '9' { - builder.WriteRune(ch) - } - } - if builder.Len() == 0 { - return "capability" - } - return builder.String() -} - -func goExportName(value string) string { - parts := strings.FieldsFunc(value, func(r rune) bool { - return r == '-' || r == '_' || r == '/' || r == '.' - }) - if len(parts) == 1 { - if initialism, ok := initialismName(strings.ToLower(parts[0])); ok { - return initialism - } - } - var builder strings.Builder - for _, part := range parts { - if part == "" { - continue - } - if initialism, ok := initialismName(strings.ToLower(part)); ok { - builder.WriteString(initialism) - continue - } - for index, ch := range part { - if index == 0 && ch >= 'a' && ch <= 'z' { - ch = ch - 'a' + 'A' - } - if ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z' || ch >= '0' && ch <= '9' { - builder.WriteRune(ch) - } - } - } - if builder.Len() == 0 { - return "Capability" - } - return builder.String() -} - -func initialismName(value string) (string, bool) { - switch value { - case "http": - return "HTTP", true - case "httpclient": - return "HTTPClient", true - case "grpc": - return "GRPC", true - case "sql": - return "SQL", true - case "dsn": - return "DSN", true - case "kv": - return "KV", true - case "mq": - return "MQ", true - case "redis": - return "Redis", true - case "mongo": - return "Mongo", true - case "errortracker": - return "ErrorTracker", true - default: - return "", false - } -} diff --git a/cmd/nucleus/internal/capcatalog/catalog.go b/cmd/nucleus/internal/capcatalog/catalog.go deleted file mode 100644 index 85ebfad..0000000 --- a/cmd/nucleus/internal/capcatalog/catalog.go +++ /dev/null @@ -1,322 +0,0 @@ -package capcatalog - -import ( - "sort" - "strings" -) - -// Provider describes a provider name accepted for a capability scaffold. -type Provider struct { - Name string - Description string -} - -// Spec describes a supported Nucleus capability scaffold. -type Spec struct { - Name string - DefaultProvider string - Providers []Provider - Description string - DSNEnv string - Planning bool -} - -var specs = []Spec{ - { - Name: "http", - DefaultProvider: "runtime/http", - Description: "inbound HTTP runtime", - Providers: []Provider{ - {Name: "runtime/http", Description: "Nucleus HTTP runtime"}, - }, - }, - { - Name: "grpc", - DefaultProvider: "runtime/grpc", - Description: "inbound gRPC runtime", - Providers: []Provider{ - {Name: "runtime/grpc", Description: "Nucleus gRPC runtime"}, - }, - }, - { - Name: "worker", - DefaultProvider: "runtime/worker", - Description: "worker runtime", - Providers: []Provider{ - {Name: "runtime/worker", Description: "Nucleus worker runtime"}, - }, - }, - { - Name: "log", - DefaultProvider: "zap", - Description: "structured logging", - Planning: true, - Providers: []Provider{ - {Name: "zap", Description: "zap-compatible logging provider"}, - }, - }, - { - Name: "trace", - DefaultProvider: "otel", - Description: "distributed tracing", - Planning: true, - Providers: []Provider{ - {Name: "otel", Description: "OpenTelemetry tracing provider"}, - }, - }, - { - Name: "config", - DefaultProvider: "file", - Description: "local or remote configuration", - Planning: true, - Providers: []Provider{ - {Name: "file", Description: "local file configuration"}, - {Name: "nacos", Description: "Nacos-compatible configuration"}, - {Name: "nacosofficial", Description: "official Nacos client configuration"}, - {Name: "configkv", Description: "key-value configuration"}, - {Name: "acm", Description: "ACM-compatible configuration"}, - }, - }, - { - Name: "httpclient", - DefaultProvider: "standard", - Description: "outbound HTTP client", - Planning: true, - Providers: []Provider{ - {Name: "standard", Description: "standard-library HTTP client"}, - }, - }, - { - Name: "transport", - DefaultProvider: "netdialer", - Description: "outbound transport policy", - Planning: true, - Providers: []Provider{ - {Name: "netdialer", Description: "standard-library network dialer"}, - }, - }, - { - Name: "discovery", - DefaultProvider: "nacos", - Description: "service discovery", - Planning: true, - Providers: []Provider{ - {Name: "nacos", Description: "Nacos-compatible discovery"}, - {Name: "nacosofficial", Description: "official Nacos client discovery"}, - }, - }, - { - Name: "metric", - DefaultProvider: "prometheus", - Description: "metrics", - Planning: true, - Providers: []Provider{ - {Name: "prometheus", Description: "Prometheus metrics"}, - {Name: "otel", Description: "OpenTelemetry metrics"}, - }, - }, - { - Name: "auth", - DefaultProvider: "security", - Description: "authentication and authorization", - Planning: true, - Providers: []Provider{ - {Name: "security", Description: "service-owned security provider"}, - }, - }, - { - Name: "health", - DefaultProvider: "noop", - Description: "health and readiness reporting", - Planning: true, - Providers: []Provider{ - {Name: "noop", Description: "no-op health provider"}, - }, - }, - { - Name: "sql", - DefaultProvider: "sql", - Description: "relational SQL persistence", - DSNEnv: "NUCLEUS_DATABASE_DSN", - Planning: true, - Providers: []Provider{ - {Name: "sql", Description: "database/sql-compatible metadata provider"}, - {Name: "postgres", Description: "PostgreSQL database/sql provider"}, - {Name: "mysql", Description: "MySQL database/sql provider"}, - {Name: "gorm", Description: "GORM-compatible provider"}, - }, - }, - { - Name: "redis", - DefaultProvider: "redis", - Description: "Redis cache or data structure client", - DSNEnv: "NUCLEUS_REDIS_DSN", - Planning: true, - Providers: []Provider{ - {Name: "redis", Description: "Redis-compatible provider"}, - {Name: "goredis", Description: "go-redis-compatible provider"}, - }, - }, - { - Name: "mongo", - DefaultProvider: "mongo", - Description: "MongoDB document persistence", - DSNEnv: "NUCLEUS_MONGO_DSN", - Planning: true, - Providers: []Provider{ - {Name: "mongo", Description: "MongoDB provider"}, - }, - }, - { - Name: "kv", - DefaultProvider: "kv", - Description: "key-value store", - Planning: true, - Providers: []Provider{ - {Name: "kv", Description: "key-value provider"}, - }, - }, - { - Name: "mq", - DefaultProvider: "kafka", - Description: "message producer and consumer", - Planning: true, - Providers: []Provider{ - {Name: "kafka", Description: "Kafka-compatible provider"}, - {Name: "sarama", Description: "Sarama Kafka provider"}, - {Name: "nats", Description: "NATS provider"}, - {Name: "amqp", Description: "AMQP-compatible provider"}, - }, - }, - { - Name: "store", - DefaultProvider: "memory", - Description: "generic store", - Planning: true, - Providers: []Provider{ - {Name: "memory", Description: "in-memory store"}, - {Name: "cache", Description: "cache store"}, - {Name: "bloom", Description: "Bloom filter store"}, - }, - }, - { - Name: "lock", - DefaultProvider: "memorylock", - Description: "distributed or local locking", - Planning: true, - Providers: []Provider{ - {Name: "memorylock", Description: "local memory lock"}, - {Name: "redislock", Description: "Redis-backed lock"}, - }, - }, - { - Name: "sentinel", - DefaultProvider: "sentinel", - Description: "rate limiting and circuit breaking", - Planning: true, - Providers: []Provider{ - {Name: "sentinel", Description: "Sentinel-compatible provider"}, - }, - }, - { - Name: "errortracker", - DefaultProvider: "sentry", - Description: "error reporting", - Planning: true, - Providers: []Provider{ - {Name: "sentry", Description: "Sentry-compatible provider"}, - }, - }, - { - Name: "profiler", - DefaultProvider: "pyroscope", - Description: "profiling", - Planning: true, - Providers: []Provider{ - {Name: "pyroscope", Description: "Pyroscope-compatible provider"}, - }, - }, -} - -// All returns every known capability scaffold spec sorted by capability name. -func All() []Spec { - values := make([]Spec, len(specs)) - copy(values, specs) - sort.Slice(values, func(i, j int) bool { - return values[i].Name < values[j].Name - }) - return values -} - -// Names returns every known capability name sorted lexicographically. -func Names() []string { - return namesMatching(func(Spec) bool { return true }) -} - -// PlanningNames returns capability names considered by natural-language plans. -func PlanningNames() []string { - return namesMatching(func(spec Spec) bool { return spec.Planning }) -} - -// Lookup returns the spec for name after normalizing whitespace and case. -func Lookup(name string) (Spec, bool) { - normalized := Normalize(name) - for _, spec := range specs { - if spec.Name == normalized { - return spec, true - } - } - return Spec{}, false -} - -// Normalize returns the canonical spelling for capability or provider values. -func Normalize(value string) string { - return strings.ToLower(strings.TrimSpace(value)) -} - -// DefaultProvider returns the default provider for a capability. -func DefaultProvider(capability string) string { - spec, ok := Lookup(capability) - if !ok { - return "" - } - return spec.DefaultProvider -} - -// ProviderSupported reports whether provider is accepted for spec. -func ProviderSupported(spec Spec, provider string) bool { - _, ok := spec.Provider(provider) - return ok -} - -// Provider returns the provider metadata for name. -func (s Spec) Provider(name string) (Provider, bool) { - normalized := Normalize(name) - for _, provider := range s.Providers { - if provider.Name == normalized { - return provider, true - } - } - return Provider{}, false -} - -// ProviderNames returns every provider name for the spec. -func (s Spec) ProviderNames() []string { - names := make([]string, 0, len(s.Providers)) - for _, provider := range s.Providers { - names = append(names, provider.Name) - } - sort.Strings(names) - return names -} - -func namesMatching(include func(Spec) bool) []string { - var names []string - for _, spec := range specs { - if include(spec) { - names = append(names, spec.Name) - } - } - sort.Strings(names) - return names -} diff --git a/cmd/nucleus/internal/capcatalog/catalog_test.go b/cmd/nucleus/internal/capcatalog/catalog_test.go deleted file mode 100644 index d2f690e..0000000 --- a/cmd/nucleus/internal/capcatalog/catalog_test.go +++ /dev/null @@ -1,46 +0,0 @@ -package capcatalog - -import ( - "testing" - - "github.com/nucleuskit/contract/inspect" -) - -func TestCatalogMatchesContractCapabilityModules(t *testing.T) { - for _, spec := range All() { - if module := inspect.CapabilityModule(spec.Name); module == "" { - t.Fatalf("capability %q is missing a contract inspect module mapping", spec.Name) - } - if spec.DefaultProvider == "" { - t.Fatalf("capability %q has empty default provider", spec.Name) - } - if !ProviderSupported(spec, spec.DefaultProvider) { - t.Fatalf("capability %q default provider %q is not in provider list %#v", spec.Name, spec.DefaultProvider, spec.ProviderNames()) - } - } -} - -func TestPlanningNamesAreCatalogCapabilities(t *testing.T) { - for _, name := range PlanningNames() { - spec, ok := Lookup(name) - if !ok { - t.Fatalf("planning capability %q is not in catalog", name) - } - if !spec.Planning { - t.Fatalf("planning capability %q has Planning=false", name) - } - } -} - -func TestLookupNormalizesNameAndProvider(t *testing.T) { - spec, ok := Lookup(" Redis ") - if !ok { - t.Fatal("Lookup Redis = false, want true") - } - if spec.Name != "redis" { - t.Fatalf("spec.Name = %q, want redis", spec.Name) - } - if _, ok := spec.Provider(" GoRedis "); !ok { - t.Fatalf("Provider GoRedis = false, want true") - } -} diff --git a/cmd/nucleus/internal/capcatalog/doc.go b/cmd/nucleus/internal/capcatalog/doc.go deleted file mode 100644 index c49f035..0000000 --- a/cmd/nucleus/internal/capcatalog/doc.go +++ /dev/null @@ -1,6 +0,0 @@ -// Package capcatalog centralizes CLI capability and provider metadata. -// -// The catalog keeps plan suggestions and capability scaffolding aligned while -// leaving public capability protocols in the cap module and provider adapters -// in bridge modules. -package capcatalog diff --git a/cmd/nucleus/internal/capvocab/capability-kinds.v1.json b/cmd/nucleus/internal/capvocab/capability-kinds.v1.json new file mode 100644 index 0000000..70fda37 --- /dev/null +++ b/cmd/nucleus/internal/capvocab/capability-kinds.v1.json @@ -0,0 +1,137 @@ +{ + "schema_version": "capability-kinds.v1", + "kinds": [ + { + "name": "http", + "description": "inbound HTTP contract surface", + "planning": false, + "aliases": ["http", "http api", "rest", "openapi", "接口"] + }, + { + "name": "grpc", + "description": "inbound gRPC contract surface", + "planning": false, + "aliases": ["grpc", "rpc", "proto"] + }, + { + "name": "worker", + "description": "background worker or job entrypoint", + "planning": false, + "aliases": ["worker", "job", "cron", "任务", "作业"] + }, + { + "name": "log", + "description": "structured logging capability", + "planning": true, + "aliases": ["log", "logging", "logger", "日志"] + }, + { + "name": "trace", + "description": "distributed tracing capability", + "planning": true, + "aliases": ["trace", "tracing", "span", "链路", "追踪"] + }, + { + "name": "config", + "description": "configuration source capability", + "planning": true, + "aliases": ["config", "configuration", "配置"] + }, + { + "name": "httpclient", + "description": "outbound HTTP client capability", + "planning": true, + "aliases": ["httpclient", "http client", "outbound http", "外部 http", "调用外部接口"] + }, + { + "name": "transport", + "description": "outbound transport policy capability", + "planning": true, + "aliases": ["transport", "net dialer", "network transport", "传输"] + }, + { + "name": "discovery", + "description": "service discovery capability", + "planning": true, + "aliases": ["discovery", "service discovery", "registry", "注册", "发现"] + }, + { + "name": "metric", + "description": "metrics capability", + "planning": true, + "aliases": ["metric", "metrics", "monitoring", "指标", "监控"] + }, + { + "name": "auth", + "description": "authentication or authorization capability", + "planning": true, + "aliases": ["auth", "authentication", "authorization", "认证", "鉴权", "权限"] + }, + { + "name": "health", + "description": "health check capability", + "planning": true, + "aliases": ["health", "healthz", "readiness", "健康检查", "就绪检查"] + }, + { + "name": "sql", + "description": "relational persistence capability", + "planning": true, + "aliases": ["sql", "database", "relational", "mysql", "postgres", "postgresql", "pg", "gorm", "xorm", "数据库", "关系型", "入库", "持久化"] + }, + { + "name": "redis", + "description": "Redis-compatible cache or data structure capability", + "planning": true, + "aliases": ["redis", "cache", "缓存"] + }, + { + "name": "mongo", + "description": "document persistence capability", + "planning": true, + "aliases": ["mongo", "mongodb", "document database", "文档库"] + }, + { + "name": "kv", + "description": "key-value persistence capability", + "planning": true, + "aliases": ["kv", "key value", "key-value", "键值"] + }, + { + "name": "mq", + "description": "message queue or stream capability", + "planning": true, + "aliases": ["mq", "queue", "message queue", "kafka", "sarama", "nats", "amqp", "rabbit", "消息", "队列"] + }, + { + "name": "store", + "description": "generic store capability", + "planning": true, + "aliases": ["store", "storage", "repository", "存储"] + }, + { + "name": "lock", + "description": "locking capability", + "planning": true, + "aliases": ["lock", "distributed lock", "锁", "分布式锁"] + }, + { + "name": "sentinel", + "description": "rate limiting or circuit breaking capability", + "planning": true, + "aliases": ["sentinel", "rate limit", "circuit breaker", "限流", "熔断"] + }, + { + "name": "errortracker", + "description": "error tracking capability", + "planning": true, + "aliases": ["errortracker", "error tracking", "sentry", "错误追踪"] + }, + { + "name": "profiler", + "description": "profiling capability", + "planning": true, + "aliases": ["profiler", "profiling", "pyroscope", "profile", "性能剖析"] + } + ] +} diff --git a/cmd/nucleus/internal/capvocab/vocab.go b/cmd/nucleus/internal/capvocab/vocab.go new file mode 100644 index 0000000..1e9ea72 --- /dev/null +++ b/cmd/nucleus/internal/capvocab/vocab.go @@ -0,0 +1,143 @@ +// Package capvocab loads advisory capability kind vocabulary from data. +package capvocab + +import ( + "embed" + "encoding/json" + "sort" + "strings" + "unicode" +) + +//go:embed capability-kinds.v1.json +var files embed.FS + +// Kind is an advisory capability kind entry. +type Kind struct { + Name string `json:"name"` + Description string `json:"description"` + Planning bool `json:"planning"` + Aliases []string `json:"aliases"` +} + +type document struct { + SchemaVersion string `json:"schema_version"` + Kinds []Kind `json:"kinds"` +} + +// Kinds returns all advisory capability kinds from the embedded vocabulary data. +func Kinds() []Kind { + doc := mustLoad() + kinds := append([]Kind{}, doc.Kinds...) + sort.Slice(kinds, func(i, j int) bool { + return kinds[i].Name < kinds[j].Name + }) + return kinds +} + +// PlanningNames returns capability kinds considered by natural-language plans. +func PlanningNames() []string { + var names []string + for _, kind := range Kinds() { + if kind.Planning { + names = append(names, kind.Name) + } + } + sort.Strings(names) + return names +} + +// MatchTask returns advisory capability kinds matched by name or aliases. +func MatchTask(task string) []string { + lowerTask := strings.ToLower(task) + var matches []string + for _, kind := range Kinds() { + if !kind.Planning { + continue + } + for _, alias := range aliases(kind) { + if aliasMatches(lowerTask, strings.ToLower(alias)) { + matches = append(matches, kind.Name) + break + } + } + } + return uniqueSorted(matches) +} + +func mustLoad() document { + data, err := files.ReadFile("capability-kinds.v1.json") + if err != nil { + panic(err) + } + var doc document + if err := json.Unmarshal(data, &doc); err != nil { + panic(err) + } + return doc +} + +func aliases(kind Kind) []string { + values := append([]string{kind.Name}, kind.Aliases...) + return values +} + +func aliasMatches(task string, alias string) bool { + alias = strings.TrimSpace(alias) + if alias == "" { + return false + } + if containsNonASCII(alias) { + return strings.Contains(task, alias) + } + if strings.Contains(alias, " ") || strings.Contains(alias, "-") { + return strings.Contains(task, alias) + } + return containsASCIIToken(task, alias) +} + +func containsASCIIToken(text string, token string) bool { + for index := 0; ; { + found := strings.Index(text[index:], token) + if found < 0 { + return false + } + start := index + found + end := start + len(token) + if asciiBoundary(text, start-1) && asciiBoundary(text, end) { + return true + } + index = end + } +} + +func asciiBoundary(text string, index int) bool { + if index < 0 || index >= len(text) { + return true + } + r := rune(text[index]) + return !(unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_') +} + +func containsNonASCII(value string) bool { + for _, r := range value { + if r > unicode.MaxASCII { + return true + } + } + return false +} + +func uniqueSorted(values []string) []string { + seen := map[string]bool{} + result := []string{} + for _, value := range values { + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + sort.Strings(result) + return result +} diff --git a/cmd/nucleus/internal/capvocab/vocab_test.go b/cmd/nucleus/internal/capvocab/vocab_test.go new file mode 100644 index 0000000..6e0e9fc --- /dev/null +++ b/cmd/nucleus/internal/capvocab/vocab_test.go @@ -0,0 +1,70 @@ +package capvocab + +import ( + "encoding/json" + "testing" +) + +func TestVocabularyHasNoProviderDecisions(t *testing.T) { + data, err := files.ReadFile("capability-kinds.v1.json") + if err != nil { + t.Fatal(err) + } + var raw map[string]any + if err := json.Unmarshal(data, &raw); err != nil { + t.Fatal(err) + } + kinds, ok := raw["kinds"].([]any) + if !ok || len(kinds) == 0 { + t.Fatalf("kinds = %#v, want non-empty array", raw["kinds"]) + } + for _, item := range kinds { + kind, ok := item.(map[string]any) + if !ok { + t.Fatalf("kind has type %T", item) + } + for _, forbidden := range []string{"provider", "providers", "default_provider", "driver", "library", "dsn_env"} { + if _, exists := kind[forbidden]; exists { + t.Fatalf("vocabulary leaked %s in %#v", forbidden, kind) + } + } + } +} + +func TestMatchTaskUsesVocabularyAliases(t *testing.T) { + matches := MatchTask("add mysql capability") + if !contains(matches, "sql") { + t.Fatalf("matches = %#v, want sql", matches) + } + + matches = MatchTask("move capability kind suggestions from Go catalog to vocab data") + if contains(matches, "log") { + t.Fatalf("catalog should not match log: %#v", matches) + } + + matches = MatchTask("接入消息队列并增加指标") + if !contains(matches, "mq") || !contains(matches, "metric") { + t.Fatalf("matches = %#v, want mq and metric", matches) + } +} + +func TestPlanningNamesComeFromVocabulary(t *testing.T) { + names := PlanningNames() + if !contains(names, "sql") || !contains(names, "log") { + t.Fatalf("planning names = %#v, want common capability kinds", names) + } + for _, forbidden := range []string{"zap", "gorm", "postgres", "kafka"} { + if contains(names, forbidden) { + t.Fatalf("planning names leaked provider %q: %#v", forbidden, names) + } + } +} + +func contains(values []string, want string) bool { + for _, value := range values { + if value == want { + return true + } + } + return false +} diff --git a/cmd/nucleus/internal/decision/command.go b/cmd/nucleus/internal/decision/command.go new file mode 100644 index 0000000..626ecf3 --- /dev/null +++ b/cmd/nucleus/internal/decision/command.go @@ -0,0 +1,128 @@ +package decision + +import ( + "errors" + + "github.com/spf13/cobra" +) + +// Config carries root-level flag values used by the decision command. +type Config struct { + Dir *string +} + +type options struct { + json bool + pretty bool + acceptedBy string + acceptedAt string +} + +var ErrDecisionInvalid = errors.New("decision validation failed") + +// NewCommand creates the decision command group. +func NewCommand(config Config) *cobra.Command { + cmd := &cobra.Command{ + Use: commandUseDecision, + Short: commandShortDecision, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.AddCommand(newValidateCommand(config)) + cmd.AddCommand(newAcceptCommand(config)) + cmd.AddCommand(newSupersedeCommand(config)) + return cmd +} + +func newValidateCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseValidate, + Short: commandShortValidate, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ArbitraryArgs, + RunE: func(cmd *cobra.Command, args []string) error { + output := validate(config, args) + if opts.json { + if err := renderJSON(cmd.OutOrStdout(), output, opts.pretty); err != nil { + return err + } + } else { + renderHuman(cmd.OutOrStdout(), output) + } + if !output.OK { + return ErrDecisionInvalid + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + return cmd +} + +func newAcceptCommand(config Config) *cobra.Command { + opts := &options{acceptedBy: "human"} + cmd := &cobra.Command{ + Use: commandUseAccept, + Short: commandShortAccept, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := accept(config, args[0], opts.acceptedBy, opts.acceptedAt) + if opts.json { + if err := renderActionJSON(cmd.OutOrStdout(), output, opts.pretty); err != nil { + return err + } + } else { + renderActionHuman(cmd.OutOrStdout(), output) + } + if !output.OK { + return ErrDecisionInvalid + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + cmd.Flags().StringVar(&opts.acceptedBy, flagAcceptedBy, "human", flagHelpAcceptedBy) + cmd.Flags().StringVar(&opts.acceptedAt, flagAcceptedAt, "", flagHelpAcceptedAt) + return cmd +} + +func newSupersedeCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseSupersede, + Short: commandShortSupersede, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := supersede(config, args[0]) + if opts.json { + if err := renderActionJSON(cmd.OutOrStdout(), output, opts.pretty); err != nil { + return err + } + } else { + renderActionHuman(cmd.OutOrStdout(), output) + } + if !output.OK { + return ErrDecisionInvalid + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + return cmd +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/cmd/nucleus/internal/decision/constants.go b/cmd/nucleus/internal/decision/constants.go new file mode 100644 index 0000000..efe5447 --- /dev/null +++ b/cmd/nucleus/internal/decision/constants.go @@ -0,0 +1,43 @@ +package decision + +const ( + commandUseDecision = "decision" + commandShortDecision = "validate structured Nucleus decisions" + commandUseValidate = "validate [path ...]" + commandShortValidate = "validate decision evidence files" + commandUseAccept = "accept " + commandShortAccept = "accept and lock one decision file" + commandUseSupersede = "supersede " + commandShortSupersede = "fill supersedes_hash for one supersede decision" + defaultDir = "." + defaultDecisionDir = ".nucleus/decisions" + resultKindDecision = "nucleus.decision_validate_result" + resultKindAccept = "nucleus.decision_accept_result" + resultKindSupersede = "nucleus.decision_supersede_result" + schemaVersionDecision = "decision-result.v1" + schemaVersionAction = "decision-result.v1" + schemaRefDecision = "contract/schema/decision-result.v1.schema.json" + jsonIndentPrefix = "" + jsonIndentValue = " " + decisionSchemaVersion = "decision.v1" + decisionHashAlgorithm = "sha256" + diagnosticPathField = "path" + decisionFileKind = "decision" + decisionStatusProposed = "proposed" + decisionStatusAccepted = "accepted" + decisionStatusSupersede = "superseded" +) + +const ( + flagJSON = "json" + flagPretty = "pretty" + flagAcceptedBy = "by" + flagAcceptedAt = "accepted-at" +) + +const ( + flagHelpJSON = "emit machine-readable decision result" + flagHelpPretty = "pretty-print JSON output" + flagHelpAcceptedBy = "accepted-by value for locked decisions" + flagHelpAcceptedAt = "RFC3339 accepted_at timestamp; defaults to current UTC time" +) diff --git a/cmd/nucleus/internal/decision/output.go b/cmd/nucleus/internal/decision/output.go new file mode 100644 index 0000000..9798ea6 --- /dev/null +++ b/cmd/nucleus/internal/decision/output.go @@ -0,0 +1,163 @@ +package decision + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/nucleuskit/contract/diagnostic" +) + +type result struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Summary summary `json:"summary"` + Decisions []fileSummary `json:"decisions"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +type summary struct { + Files int `json:"files"` + Valid int `json:"valid"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` +} + +// QualitySummary is a compact, report/verify friendly decision health view. +type QualitySummary struct { + Files int `json:"files"` + Valid int `json:"valid"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + AcceptedLocked int `json:"accepted_locked"` + Supersedes int `json:"supersedes"` + Drift int `json:"drift"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics,omitempty"` +} + +type fileSummary struct { + Path string `json:"path"` + ID string `json:"id,omitempty"` + Capability string `json:"capability,omitempty"` + Status string `json:"status,omitempty"` + Locked bool `json:"locked"` + Hash string `json:"hash,omitempty"` +} + +type actionResult struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Action string `json:"action"` + Path string `json:"path,omitempty"` + Changed bool `json:"changed"` + Decision fileSummary `json:"decision,omitempty"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +func renderJSON(writer io.Writer, output result, pretty bool) error { + output = normalizeResult(output) + encoder := json.NewEncoder(writer) + if pretty { + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + } + return encoder.Encode(output) +} + +func renderActionJSON(writer io.Writer, output actionResult, pretty bool) error { + output = normalizeActionResult(output) + encoder := json.NewEncoder(writer) + if pretty { + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + } + return encoder.Encode(output) +} + +func renderActionHuman(writer io.Writer, output actionResult) { + output = normalizeActionResult(output) + if output.OK { + _, _ = fmt.Fprintf(writer, "OK decision %s\n", output.Action) + } else { + _, _ = fmt.Fprintf(writer, "FAILED decision %s\n", output.Action) + } + if output.Path != "" { + _, _ = fmt.Fprintf(writer, "path: %s\n", output.Path) + } + if output.Decision.Hash != "" { + _, _ = fmt.Fprintf(writer, "hash: %s\n", output.Decision.Hash) + } + for _, item := range output.Diagnostics { + _, _ = fmt.Fprintf(writer, " - %s %s %s: %s\n", item.Severity, item.Path, item.Code, item.Message) + } +} + +func renderHuman(writer io.Writer, output result) { + output = normalizeResult(output) + if output.OK { + _, _ = fmt.Fprintln(writer, "OK decisions") + } else { + _, _ = fmt.Fprintln(writer, "FAILED decisions") + } + _, _ = fmt.Fprintf(writer, "files: %d\n", output.Summary.Files) + _, _ = fmt.Fprintf(writer, "diagnostics: %d errors, %d warnings\n", output.Summary.Errors, output.Summary.Warnings) + for _, item := range output.Diagnostics { + _, _ = fmt.Fprintf(writer, " - %s %s %s: %s\n", item.Severity, item.Path, item.Code, item.Message) + } +} + +func normalizeResult(output result) result { + if output.Decisions == nil { + output.Decisions = []fileSummary{} + } + if output.Diagnostics == nil { + output.Diagnostics = diagnostic.Diagnostics{} + } + output.ResultKind = resultKindDecision + output.SchemaVersion = schemaVersionDecision + output.SchemaRef = schemaRefDecision + output.OK = !output.Diagnostics.Failed() + output.Summary = summary{ + Files: len(output.Decisions), + Valid: validDecisionCount(output.Decisions, output.Diagnostics), + Errors: output.Diagnostics.Count(diagnostic.SeverityError), + Warnings: output.Diagnostics.Count(diagnostic.SeverityWarning), + } + return output +} + +func normalizeActionResult(output actionResult) actionResult { + if output.Diagnostics == nil { + output.Diagnostics = diagnostic.Diagnostics{} + } + if output.ResultKind == "" { + switch output.Action { + case "accept": + output.ResultKind = resultKindAccept + case "supersede": + output.ResultKind = resultKindSupersede + } + } + output.SchemaVersion = schemaVersionAction + output.SchemaRef = schemaRefDecision + output.OK = !output.Diagnostics.Failed() + return output +} + +func validDecisionCount(decisions []fileSummary, diagnostics diagnostic.Diagnostics) int { + invalid := map[string]bool{} + for _, item := range diagnostics { + if item.Severity == diagnostic.SeverityError && item.Path != "" { + invalid[item.Path] = true + } + } + count := 0 + for _, decision := range decisions { + if !invalid[decision.Path] { + count++ + } + } + return count +} diff --git a/cmd/nucleus/internal/decision/run.go b/cmd/nucleus/internal/decision/run.go new file mode 100644 index 0000000..25146b4 --- /dev/null +++ b/cmd/nucleus/internal/decision/run.go @@ -0,0 +1,808 @@ +package decision + +import ( + "bytes" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/contract/manifest" + "go.yaml.in/yaml/v3" +) + +type document struct { + SchemaVersion string `yaml:"schema_version" json:"schema_version"` + ID string `yaml:"id" json:"id"` + Capability string `yaml:"capability" json:"capability"` + Decision choice `yaml:"decision" json:"decision"` + DecisionHash string `yaml:"decision_hash" json:"decision_hash,omitempty"` + Supersedes string `yaml:"supersedes" json:"supersedes,omitempty"` + SupersedesHash string `yaml:"supersedes_hash" json:"supersedes_hash,omitempty"` + Reason []string `yaml:"reason" json:"reason"` + Impact impact `yaml:"impact" json:"impact,omitempty"` + Verification verification `yaml:"verification" json:"verification"` + Alternatives []alternative `yaml:"alternatives" json:"alternatives,omitempty"` +} + +type choice struct { + Provider string `yaml:"provider" json:"provider,omitempty"` + Library string `yaml:"library" json:"library,omitempty"` + Driver string `yaml:"driver" json:"driver,omitempty"` + Status string `yaml:"status" json:"status"` + Locked *bool `yaml:"locked" json:"locked"` + AcceptedBy string `yaml:"accepted_by" json:"accepted_by,omitempty"` + AcceptedAt string `yaml:"accepted_at" json:"accepted_at,omitempty"` +} + +type impact struct { + Symbols []string `yaml:"symbols" json:"symbols,omitempty"` + Files []string `yaml:"files" json:"files,omitempty"` +} + +type verification struct { + Commands []string `yaml:"commands" json:"commands"` +} + +type alternative struct { + Provider string `yaml:"provider" json:"provider,omitempty"` + Library string `yaml:"library" json:"library,omitempty"` + Driver string `yaml:"driver" json:"driver,omitempty"` + Reason string `yaml:"reason" json:"reason,omitempty"` +} + +type loadedDecision struct { + path string + doc document +} + +// LockedChoice is the plan-facing view of an accepted locked decision. +type LockedChoice struct { + Path string `json:"path"` + ID string `json:"id"` + Capability string `json:"capability"` + Provider string `json:"provider,omitempty"` + Library string `json:"library,omitempty"` + Driver string `json:"driver,omitempty"` + Hash string `json:"hash"` +} + +// PlanState contains decision facts that plan can use without mutating files. +type PlanState struct { + Locked []LockedChoice `json:"locked"` + Supersedes map[string]bool `json:"supersedes"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +func validate(config Config, args []string) result { + dir := stringValue(config.Dir, defaultDir) + files, diagnostics := collectDecisionFiles(dir, args) + decisions := make([]fileSummary, 0, len(files)) + loaded := make([]loadedDecision, 0, len(files)) + for _, file := range files { + doc, parseDiagnostics, ok := loadDecisionFile(file.fullPath, file.relPath) + diagnostics = append(diagnostics, parseDiagnostics...) + if !ok { + continue + } + loaded = append(loaded, loadedDecision{path: file.relPath, doc: doc}) + decisions = append(decisions, summarizeDecision(file.relPath, doc)) + } + + m, manifestDiagnostics, manifestLoaded := loadManifest(dir) + diagnostics = append(diagnostics, manifestDiagnostics...) + description, describeDiagnostics, descriptionLoaded := loadDescription(dir) + diagnostics = append(diagnostics, describeDiagnostics...) + + byID := decisionsByID(loaded) + for _, item := range loaded { + diagnostics = append(diagnostics, validateDocument(item, m, manifestLoaded, description, descriptionLoaded, byID)...) + } + diagnostics.Sort() + return normalizeResult(result{Decisions: decisions, Diagnostics: diagnostics}) +} + +func accept(config Config, path string, acceptedBy string, acceptedAt string) actionResult { + dir := stringValue(config.Dir, defaultDir) + rel, full, diagnostics := resolveSingleDecisionFile(dir, path) + if diagnostics.Failed() { + return normalizeActionResult(actionResult{Action: "accept", Path: rel, Diagnostics: diagnostics}) + } + doc, loadDiagnostics, ok := loadDecisionFile(full, rel) + diagnostics = append(diagnostics, loadDiagnostics...) + if !ok { + return normalizeActionResult(actionResult{Action: "accept", Path: rel, Diagnostics: diagnostics}) + } + acceptedAtValue, timeDiagnostics := normalizeAcceptedAt(acceptedAt) + diagnostics = append(diagnostics, timeDiagnostics...) + if strings.TrimSpace(acceptedBy) == "" { + diagnostics = append(diagnostics, errorDiagnostic("decision.accepted_by_required", rel, "--by must not be empty")) + } + if diagnostics.Failed() { + return normalizeActionResult(actionResult{Action: "accept", Path: rel, Decision: summarizeDecision(rel, doc), Diagnostics: diagnostics}) + } + locked := true + doc.Decision.Status = decisionStatusAccepted + doc.Decision.Locked = &locked + doc.Decision.AcceptedBy = strings.TrimSpace(acceptedBy) + doc.Decision.AcceptedAt = acceptedAtValue + doc.DecisionHash = canonicalDecisionHash(doc) + diagnostics = append(diagnostics, writeDecisionDocument(full, rel, doc)...) + if diagnostics.Failed() { + return normalizeActionResult(actionResult{Action: "accept", Path: rel, Decision: summarizeDecision(rel, doc), Diagnostics: diagnostics}) + } + validation := validate(config, []string{rel}) + diagnostics = append(diagnostics, validation.Diagnostics...) + diagnostics.Sort() + return normalizeActionResult(actionResult{Action: "accept", Path: rel, Changed: !diagnostics.Failed(), Decision: summarizeDecision(rel, doc), Diagnostics: diagnostics}) +} + +func supersede(config Config, path string) actionResult { + dir := stringValue(config.Dir, defaultDir) + rel, full, diagnostics := resolveSingleDecisionFile(dir, path) + if diagnostics.Failed() { + return normalizeActionResult(actionResult{Action: "supersede", Path: rel, Diagnostics: diagnostics}) + } + doc, loadDiagnostics, ok := loadDecisionFile(full, rel) + diagnostics = append(diagnostics, loadDiagnostics...) + if !ok { + return normalizeActionResult(actionResult{Action: "supersede", Path: rel, Diagnostics: diagnostics}) + } + if strings.TrimSpace(doc.Supersedes) == "" { + diagnostics = append(diagnostics, errorDiagnostic("decision.supersedes_required", rel, "supersede command requires supersedes")) + return normalizeActionResult(actionResult{Action: "supersede", Path: rel, Decision: summarizeDecision(rel, doc), Diagnostics: diagnostics}) + } + previous, previousDiagnostics, found := loadDecisionByID(dir, doc.Supersedes) + diagnostics = append(diagnostics, previousDiagnostics...) + if !found { + diagnostics = append(diagnostics, errorDiagnostic("decision.supersedes_not_found", rel, "superseded decision was not found in .nucleus/decisions")) + return normalizeActionResult(actionResult{Action: "supersede", Path: rel, Decision: summarizeDecision(rel, doc), Diagnostics: diagnostics}) + } + doc.SupersedesHash = decisionHash(previous.doc) + diagnostics = append(diagnostics, writeDecisionDocument(full, rel, doc)...) + if diagnostics.Failed() { + return normalizeActionResult(actionResult{Action: "supersede", Path: rel, Decision: summarizeDecision(rel, doc), Diagnostics: diagnostics}) + } + validation := validate(config, []string{defaultDecisionDir}) + diagnostics = append(diagnostics, validation.Diagnostics...) + diagnostics.Sort() + return normalizeActionResult(actionResult{Action: "supersede", Path: rel, Changed: !diagnostics.Failed(), Decision: summarizeDecision(rel, doc), Diagnostics: diagnostics}) +} + +// ValidateForMCP returns the same structured decision validation result used by the CLI. +func ValidateForMCP(dir string, args []string) any { + return validate(Config{Dir: &dir}, args) +} + +// QualityForDir returns decision health without requiring every project to have +// a decisions directory. +func QualityForDir(dir string) QualitySummary { + files, diagnostics := collectDecisionFiles(dir, []string{defaultDecisionDir}) + if len(files) == 0 { + diagnostics = suppressDecisionDirMissing(diagnostics) + diagnostics.Sort() + return normalizeQualitySummary(QualitySummary{Diagnostics: diagnostics}) + } + output := validate(Config{Dir: &dir}, []string{defaultDecisionDir}) + summary := QualitySummary{ + Files: output.Summary.Files, + Valid: output.Summary.Valid, + Errors: output.Summary.Errors, + Warnings: output.Summary.Warnings, + AcceptedLocked: acceptedLockedDecisionCount(output.Decisions), + Supersedes: supersedingDecisionCount(dir), + Drift: decisionDriftCount(output.Diagnostics), + Diagnostics: output.Diagnostics, + } + return normalizeQualitySummary(summary) +} + +// PlanStateForDir returns locked choices and valid supersede facts for plan. +func PlanStateForDir(dir string) PlanState { + files, diagnostics := collectDecisionFiles(dir, []string{defaultDecisionDir}) + var loaded []loadedDecision + for _, file := range files { + doc, itemDiagnostics, ok := loadDecisionFile(file.fullPath, file.relPath) + diagnostics = append(diagnostics, itemDiagnostics...) + if ok { + loaded = append(loaded, loadedDecision{path: file.relPath, doc: doc}) + } + } + m, manifestDiagnostics, manifestLoaded := loadManifest(dir) + diagnostics = append(diagnostics, manifestDiagnostics...) + description, describeDiagnostics, descriptionLoaded := loadDescription(dir) + diagnostics = append(diagnostics, describeDiagnostics...) + byID := decisionsByID(loaded) + pathHasError := map[string]bool{} + for _, item := range loaded { + itemDiagnostics := validateDocument(item, m, manifestLoaded, description, descriptionLoaded, byID) + for _, item := range itemDiagnostics { + if item.Severity == diagnostic.SeverityError && item.Path != "" { + pathHasError[item.Path] = true + } + } + diagnostics = append(diagnostics, itemDiagnostics...) + } + state := PlanState{Supersedes: map[string]bool{}} + for _, item := range loaded { + if pathHasError[item.path] { + continue + } + if strings.TrimSpace(item.doc.Supersedes) != "" { + state.Supersedes[item.doc.Supersedes] = true + } + if isAcceptedLocked(item.doc) { + state.Locked = append(state.Locked, LockedChoice{ + Path: item.path, + ID: item.doc.ID, + Capability: item.doc.Capability, + Provider: item.doc.Decision.Provider, + Library: item.doc.Decision.Library, + Driver: item.doc.Decision.Driver, + Hash: decisionHash(item.doc), + }) + } + } + diagnostics.Sort() + state.Diagnostics = suppressDecisionDirMissing(diagnostics) + if state.Locked == nil { + state.Locked = []LockedChoice{} + } + return state +} + +type decisionFile struct { + relPath string + fullPath string +} + +func collectDecisionFiles(dir string, args []string) ([]decisionFile, diagnostic.Diagnostics) { + if len(args) == 0 { + args = []string{defaultDecisionDir} + } + var diagnostics diagnostic.Diagnostics + seen := map[string]bool{} + var files []decisionFile + for _, arg := range args { + rel, full, err := resolveDecisionPath(dir, arg) + if err != nil { + diagnostics = append(diagnostics, errorDiagnostic("decision.path_invalid", arg, err.Error())) + continue + } + info, err := os.Stat(full) + if err != nil { + if os.IsNotExist(err) && rel == defaultDecisionDir { + diagnostics = append(diagnostics, warningDiagnostic("decision.dir_missing", rel, "decision directory does not exist")) + continue + } + diagnostics = append(diagnostics, errorDiagnostic("decision.path_read_failed", rel, safeError(err))) + continue + } + if !info.IsDir() { + if !isDecisionFile(rel) { + diagnostics = append(diagnostics, errorDiagnostic("decision.path_unsupported", rel, "decision path must be a .yaml, .yml, or .json file")) + continue + } + if !seen[rel] { + seen[rel] = true + files = append(files, decisionFile{relPath: rel, fullPath: full}) + } + continue + } + walkDiagnostics := collectDecisionDir(full, rel, seen, &files) + diagnostics = append(diagnostics, walkDiagnostics...) + } + sort.Slice(files, func(i, j int) bool { return files[i].relPath < files[j].relPath }) + return files, diagnostics +} + +func collectDecisionDir(root string, rootRel string, seen map[string]bool, files *[]decisionFile) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + diagnostics = append(diagnostics, warningDiagnostic("decision.path_read_failed", rootRel, safeError(err))) + return nil + } + if entry.IsDir() { + return nil + } + rel := filepath.ToSlash(filepath.Join(rootRel, strings.TrimPrefix(path, root))) + rel = strings.TrimPrefix(rel, "/") + if !isDecisionFile(rel) { + return nil + } + if seen[rel] { + return nil + } + seen[rel] = true + *files = append(*files, decisionFile{relPath: rel, fullPath: path}) + return nil + }) + if err != nil { + diagnostics = append(diagnostics, errorDiagnostic("decision.path_read_failed", rootRel, safeError(err))) + } + return diagnostics +} + +func resolveDecisionPath(dir string, value string) (string, string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", "", fmt.Errorf("decision path must not be empty") + } + value = filepath.ToSlash(value) + if filepath.IsAbs(value) || strings.HasPrefix(value, "/") { + return "", "", fmt.Errorf("decision path must be relative to --dir") + } + clean := filepath.ToSlash(filepath.Clean(value)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", "", fmt.Errorf("decision path must stay inside --dir") + } + return clean, filepath.Join(dir, filepath.FromSlash(clean)), nil +} + +func isDecisionFile(path string) bool { + switch strings.ToLower(filepath.Ext(path)) { + case ".yaml", ".yml", ".json": + return true + default: + return false + } +} + +func loadDecisionFile(path string, rel string) (document, diagnostic.Diagnostics, bool) { + data, err := os.ReadFile(path) + if err != nil { + return document{}, diagnostic.Diagnostics{errorDiagnostic("decision.read_failed", rel, safeError(err))}, false + } + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + var doc document + if err := decoder.Decode(&doc); err != nil { + return document{}, diagnostic.Diagnostics{errorDiagnostic("decision.parse_failed", rel, safeError(err))}, false + } + return doc, nil, true +} + +func summarizeDecision(path string, doc document) fileSummary { + locked := false + if doc.Decision.Locked != nil { + locked = *doc.Decision.Locked + } + return fileSummary{ + Path: path, + ID: doc.ID, + Capability: doc.Capability, + Status: doc.Decision.Status, + Locked: locked, + Hash: decisionHash(doc), + } +} + +func decisionHash(doc document) string { + if strings.TrimSpace(doc.DecisionHash) != "" { + return doc.DecisionHash + } + return canonicalDecisionHash(doc) +} + +func resolveSingleDecisionFile(dir string, value string) (string, string, diagnostic.Diagnostics) { + rel, full, err := resolveDecisionPath(dir, value) + if err != nil { + return rel, full, diagnostic.Diagnostics{errorDiagnostic("decision.path_invalid", value, err.Error())} + } + info, err := os.Stat(full) + if err != nil { + return rel, full, diagnostic.Diagnostics{errorDiagnostic("decision.path_read_failed", rel, safeError(err))} + } + if info.IsDir() { + return rel, full, diagnostic.Diagnostics{errorDiagnostic("decision.path_must_be_file", rel, "decision action requires one file")} + } + if !isDecisionFile(rel) { + return rel, full, diagnostic.Diagnostics{errorDiagnostic("decision.path_unsupported", rel, "decision path must be a .yaml, .yml, or .json file")} + } + return rel, full, nil +} + +func writeDecisionDocument(path string, rel string, doc document) diagnostic.Diagnostics { + var data []byte + var err error + if strings.EqualFold(filepath.Ext(rel), ".json") { + data, err = json.MarshalIndent(doc, "", " ") + if err == nil { + data = append(data, '\n') + } + } else { + data, err = yaml.Marshal(doc) + } + if err != nil { + return diagnostic.Diagnostics{errorDiagnostic("decision.encode_failed", rel, safeError(err))} + } + if err := os.WriteFile(path, data, 0o600); err != nil { + return diagnostic.Diagnostics{errorDiagnostic("decision.write_failed", rel, safeError(err))} + } + return nil +} + +func normalizeAcceptedAt(value string) (string, diagnostic.Diagnostics) { + value = strings.TrimSpace(value) + if value == "" { + return time.Now().UTC().Format(time.RFC3339), nil + } + parsed, err := time.Parse(time.RFC3339, value) + if err != nil { + return "", diagnostic.Diagnostics{errorDiagnostic("decision.accepted_at_invalid", "", "accepted-at must be RFC3339")} + } + return parsed.UTC().Format(time.RFC3339), nil +} + +func loadDecisionByID(dir string, id string) (loadedDecision, diagnostic.Diagnostics, bool) { + files, diagnostics := collectDecisionFiles(dir, []string{defaultDecisionDir}) + for _, file := range files { + doc, itemDiagnostics, ok := loadDecisionFile(file.fullPath, file.relPath) + diagnostics = append(diagnostics, itemDiagnostics...) + if ok && doc.ID == id { + return loadedDecision{path: file.relPath, doc: doc}, diagnostics, true + } + } + return loadedDecision{}, diagnostics, false +} + +func isAcceptedLocked(doc document) bool { + return strings.TrimSpace(doc.Decision.Status) == decisionStatusAccepted && doc.Decision.Locked != nil && *doc.Decision.Locked +} + +func suppressDecisionDirMissing(diagnostics diagnostic.Diagnostics) diagnostic.Diagnostics { + var filtered diagnostic.Diagnostics + for _, item := range diagnostics { + if item.Code == "decision.dir_missing" { + continue + } + filtered = append(filtered, item) + } + if filtered == nil { + return diagnostic.Diagnostics{} + } + return filtered +} + +func normalizeQualitySummary(summary QualitySummary) QualitySummary { + if summary.Diagnostics == nil { + summary.Diagnostics = diagnostic.Diagnostics{} + } + summary.Diagnostics.Sort() + summary.Errors = summary.Diagnostics.Count(diagnostic.SeverityError) + summary.Warnings = summary.Diagnostics.Count(diagnostic.SeverityWarning) + return summary +} + +func acceptedLockedDecisionCount(decisions []fileSummary) int { + count := 0 + for _, item := range decisions { + if item.Locked && item.Status == decisionStatusAccepted { + count++ + } + } + return count +} + +func supersedingDecisionCount(dir string) int { + files, diagnostics := collectDecisionFiles(dir, []string{defaultDecisionDir}) + if diagnostics.Failed() { + return 0 + } + count := 0 + for _, file := range files { + doc, _, ok := loadDecisionFile(file.fullPath, file.relPath) + if ok && strings.TrimSpace(doc.Supersedes) != "" { + count++ + } + } + return count +} + +func decisionDriftCount(diagnostics diagnostic.Diagnostics) int { + count := 0 + for _, item := range diagnostics { + if isDecisionDriftDiagnostic(item.Code) { + count++ + } + } + return count +} + +func isDecisionDriftDiagnostic(code string) bool { + switch code { + case "decision.hash_required", + "decision.hash_invalid", + "decision.hash_mismatch", + "decision.supersedes_hash_required", + "decision.supersedes_hash_invalid", + "decision.supersedes_hash_mismatch": + return true + default: + return false + } +} + +func loadManifest(dir string) (manifest.Manifest, diagnostic.Diagnostics, bool) { + m, err := manifest.Load(dir) + if err != nil { + if os.IsNotExist(err) { + return manifest.Manifest{}, diagnostic.Diagnostics{errorDiagnostic("decision.manifest_read_failed", "nucleus.yaml", "nucleus.yaml is required for decision validation")}, false + } + return manifest.Manifest{}, diagnostic.Diagnostics{errorDiagnostic("decision.manifest_read_failed", "nucleus.yaml", "nucleus.yaml could not be loaded")}, false + } + diagnostics := manifest.ValidateDiagnostics(m) + return m, diagnostics, true +} + +func loadDescription(dir string) (inspect.Description, diagnostic.Diagnostics, bool) { + description, err := inspect.Describe(dir) + if err != nil { + return inspect.Description{}, diagnostic.Diagnostics{errorDiagnostic("decision.describe_failed", ".", safeError(err))}, false + } + return description, nil, true +} + +func decisionsByID(decisions []loadedDecision) map[string]loadedDecision { + byID := map[string]loadedDecision{} + for _, item := range decisions { + if strings.TrimSpace(item.doc.ID) != "" { + byID[item.doc.ID] = item + } + } + return byID +} + +func validateDocument(item loadedDecision, m manifest.Manifest, manifestLoaded bool, description inspect.Description, descriptionLoaded bool, byID map[string]loadedDecision) diagnostic.Diagnostics { + doc := item.doc + path := item.path + var diagnostics diagnostic.Diagnostics + if strings.TrimSpace(doc.SchemaVersion) != decisionSchemaVersion { + diagnostics = append(diagnostics, errorDiagnostic("decision.schema_version_invalid", path, "schema_version must be decision.v1")) + } + if strings.TrimSpace(doc.ID) == "" { + diagnostics = append(diagnostics, errorDiagnostic("decision.id_required", path, "id is required")) + } + if strings.TrimSpace(doc.Capability) == "" { + diagnostics = append(diagnostics, errorDiagnostic("decision.capability_required", path, "capability is required")) + } else if manifestLoaded && !manifest.HasCapability(m.Capabilities, doc.Capability) { + diagnostics = append(diagnostics, errorDiagnostic("decision.capability_missing", path, "capability is not declared in nucleus.yaml")) + } + diagnostics = append(diagnostics, validateChoice(path, doc)...) + diagnostics = append(diagnostics, validateReason(path, doc.Reason)...) + diagnostics = append(diagnostics, validateImpact(path, doc.Impact, description, descriptionLoaded)...) + diagnostics = append(diagnostics, validateVerification(path, doc.Verification)...) + diagnostics = append(diagnostics, validateHash(path, doc, byID)...) + return diagnostics +} + +func validateChoice(path string, doc document) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + if strings.TrimSpace(doc.Decision.Provider) == "" && strings.TrimSpace(doc.Decision.Library) == "" && strings.TrimSpace(doc.Decision.Driver) == "" { + diagnostics = append(diagnostics, errorDiagnostic("decision.evidence_missing", path, "decision requires provider, library, or driver evidence")) + } + switch strings.TrimSpace(doc.Decision.Status) { + case decisionStatusProposed, decisionStatusAccepted, decisionStatusSupersede: + default: + diagnostics = append(diagnostics, errorDiagnostic("decision.status_invalid", path, "decision.status must be proposed, accepted, or superseded")) + } + if doc.Decision.Locked == nil { + diagnostics = append(diagnostics, errorDiagnostic("decision.locked_required", path, "decision.locked is required")) + } + if doc.Decision.Locked != nil && *doc.Decision.Locked && strings.TrimSpace(doc.DecisionHash) == "" { + diagnostics = append(diagnostics, errorDiagnostic("decision.hash_required", path, "locked decision requires decision_hash")) + } + return diagnostics +} + +func validateReason(path string, reason []string) diagnostic.Diagnostics { + if len(reason) == 0 { + return diagnostic.Diagnostics{errorDiagnostic("decision.reason_required", path, "reason requires at least one item")} + } + var diagnostics diagnostic.Diagnostics + for _, item := range reason { + if strings.TrimSpace(item) == "" { + diagnostics = append(diagnostics, errorDiagnostic("decision.reason_empty", path, "reason entries must not be empty")) + } + } + return diagnostics +} + +func validateImpact(path string, impact impact, description inspect.Description, loaded bool) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + for _, file := range impact.Files { + if invalidRelativePath(file) { + diagnostics = append(diagnostics, errorDiagnostic("decision.impact_file_invalid", path, "impact.files entries must be relative paths inside the service directory")) + continue + } + if loaded { + surface := classifySurface(file, description.EditSurfaces) + if surface != "allowed" { + diagnostics = append(diagnostics, errorDiagnostic("decision.impact_file_outside_edit_surface", path, "impact file "+file+" is "+surface)) + } + } + } + return diagnostics +} + +func validateVerification(path string, verification verification) diagnostic.Diagnostics { + if len(verification.Commands) == 0 { + return diagnostic.Diagnostics{errorDiagnostic("decision.verification_required", path, "verification.commands requires at least one command")} + } + var diagnostics diagnostic.Diagnostics + for _, command := range verification.Commands { + if strings.TrimSpace(command) == "" { + diagnostics = append(diagnostics, errorDiagnostic("decision.verification_command_empty", path, "verification.commands entries must not be empty")) + } + } + return diagnostics +} + +func validateHash(path string, doc document, byID map[string]loadedDecision) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + if doc.DecisionHash != "" { + if !validHash(doc.DecisionHash) { + diagnostics = append(diagnostics, errorDiagnostic("decision.hash_invalid", path, "decision_hash must use algorithm:value format")) + } else if expected := canonicalDecisionHash(doc); doc.DecisionHash != expected { + diagnostics = append(diagnostics, errorDiagnostic("decision.hash_mismatch", path, "decision_hash does not match canonical decision payload")) + } + } + if strings.TrimSpace(doc.Supersedes) == "" { + return diagnostics + } + if strings.TrimSpace(doc.SupersedesHash) == "" { + diagnostics = append(diagnostics, errorDiagnostic("decision.supersedes_hash_required", path, "supersedes requires supersedes_hash")) + return diagnostics + } + if !validHash(doc.SupersedesHash) { + diagnostics = append(diagnostics, errorDiagnostic("decision.supersedes_hash_invalid", path, "supersedes_hash must use algorithm:value format")) + return diagnostics + } + previous, ok := byID[doc.Supersedes] + if !ok { + diagnostics = append(diagnostics, warningDiagnostic("decision.supersedes_not_loaded", path, "superseded decision was not part of this validation input")) + return diagnostics + } + expected := previous.doc.DecisionHash + if expected == "" { + expected = canonicalDecisionHash(previous.doc) + } + if doc.SupersedesHash != expected { + diagnostics = append(diagnostics, errorDiagnostic("decision.supersedes_hash_mismatch", path, "supersedes_hash does not match superseded decision")) + } + return diagnostics +} + +func canonicalDecisionHash(doc document) string { + payload := map[string]any{ + "capability": strings.TrimSpace(doc.Capability), + "decision": map[string]any{ + "provider": strings.TrimSpace(doc.Decision.Provider), + "library": strings.TrimSpace(doc.Decision.Library), + "driver": strings.TrimSpace(doc.Decision.Driver), + "status": strings.TrimSpace(doc.Decision.Status), + "locked": boolValue(doc.Decision.Locked), + "accepted_by": strings.TrimSpace(doc.Decision.AcceptedBy), + }, + "reason": cleanStrings(doc.Reason), + "impact": map[string]any{"symbols": cleanStrings(doc.Impact.Symbols), "files": cleanStrings(doc.Impact.Files)}, + "verification": map[string]any{"commands": cleanStrings(doc.Verification.Commands)}, + "alternatives": canonicalAlternatives(doc.Alternatives), + } + data, _ := json.Marshal(payload) + sum := sha256.Sum256(data) + return decisionHashAlgorithm + ":" + base64.RawURLEncoding.EncodeToString(sum[:]) +} + +func canonicalAlternatives(alternatives []alternative) []map[string]string { + result := make([]map[string]string, 0, len(alternatives)) + for _, item := range alternatives { + result = append(result, map[string]string{ + "provider": strings.TrimSpace(item.Provider), + "library": strings.TrimSpace(item.Library), + "driver": strings.TrimSpace(item.Driver), + "reason": strings.TrimSpace(item.Reason), + }) + } + return result +} + +func boolValue(value *bool) bool { + if value == nil { + return false + } + return *value +} + +func cleanStrings(values []string) []string { + result := make([]string, 0, len(values)) + for _, value := range values { + result = append(result, strings.TrimSpace(value)) + } + return result +} + +func validHash(value string) bool { + parts := strings.SplitN(value, ":", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return false + } + for _, r := range parts[0] { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') { + return false + } + } + return true +} + +func invalidRelativePath(value string) bool { + value = strings.TrimSpace(value) + if value == "" || filepath.IsAbs(value) || strings.HasPrefix(value, "/") { + return true + } + clean := filepath.ToSlash(filepath.Clean(strings.ReplaceAll(value, "\\", "/"))) + return clean == "." || clean == ".." || strings.HasPrefix(clean, "../") || strings.Contains(clean, "/../") +} + +func classifySurface(path string, surfaces inspect.EditSurfaces) string { + switch { + case matchesAnySurface(path, surfaces.Forbidden): + return "forbidden" + case matchesAnySurface(path, surfaces.Readonly): + return "readonly" + case matchesAnySurface(path, surfaces.Allowed): + return "allowed" + default: + return "manual" + } +} + +func matchesAnySurface(path string, patterns []string) bool { + for _, pattern := range patterns { + if surfaceMatch(path, pattern) { + return true + } + } + return false +} + +func surfaceMatch(path string, pattern string) bool { + pattern = strings.TrimPrefix(strings.ReplaceAll(pattern, "\\", "/"), "./") + path = strings.TrimPrefix(strings.ReplaceAll(path, "\\", "/"), "./") + if pattern == path || pattern == "**" { + return true + } + if strings.HasSuffix(pattern, "/**") { + prefix := strings.TrimSuffix(pattern, "/**") + return path == prefix || strings.HasPrefix(path, prefix+"/") + } + if strings.HasSuffix(pattern, "/*") { + prefix := strings.TrimSuffix(pattern, "/*") + "/" + rest := strings.TrimPrefix(path, prefix) + return strings.HasPrefix(path, prefix) && rest != "" && !strings.Contains(rest, "/") + } + return false +} + +func safeError(err error) string { + if err == nil { + return "" + } + return err.Error() +} + +func errorDiagnostic(code string, path string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: code, Path: path, Message: message} +} + +func warningDiagnostic(code string, path string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{Severity: diagnostic.SeverityWarning, Code: code, Path: path, Message: message} +} diff --git a/cmd/nucleus/internal/decision/run_test.go b/cmd/nucleus/internal/decision/run_test.go new file mode 100644 index 0000000..1b329a0 --- /dev/null +++ b/cmd/nucleus/internal/decision/run_test.go @@ -0,0 +1,388 @@ +package decision + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestValidateDecisionAcceptsStructuredEvidence(t *testing.T) { + dir := t.TempDir() + writeDecisionFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +capabilities: + - id: order_store + kind: relational_store +ai: + intent: test + allowed_changes: + - nucleus.yaml + - .nucleus/** + - internal/** +`) + writeDecisionFile(t, dir, ".nucleus/decisions/order-store.yaml", validDecisionYAML("")) + + output := validate(Config{Dir: &dir}, []string{".nucleus/decisions/order-store.yaml"}) + if !output.OK { + t.Fatalf("ok = false, diagnostics = %#v", output.Diagnostics) + } + if output.Summary.Files != 1 || output.Summary.Valid != 1 { + t.Fatalf("summary = %#v", output.Summary) + } + if output.Decisions[0].Hash == "" { + t.Fatalf("decision hash missing: %#v", output.Decisions[0]) + } +} + +func TestValidateDecisionReportsManifestAndSurfaceFailures(t *testing.T) { + dir := t.TempDir() + writeDecisionFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +capabilities: [] +ai: + intent: test + allowed_changes: + - nucleus.yaml + - .nucleus/** +`) + writeDecisionFile(t, dir, ".nucleus/decisions/order-store.yaml", validDecisionYAML("")) + + output := validate(Config{Dir: &dir}, []string{".nucleus/decisions/order-store.yaml"}) + if output.OK { + t.Fatalf("ok = true, want failure") + } + assertDecisionDiagnostic(t, output, "decision.capability_missing") + assertDecisionDiagnostic(t, output, "decision.impact_file_outside_edit_surface") +} + +func TestValidateDecisionChecksLockedHash(t *testing.T) { + dir := t.TempDir() + writeDecisionFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +capabilities: + - id: order_store + kind: relational_store +ai: + intent: test + allowed_changes: + - internal/** +`) + writeDecisionFile(t, dir, ".nucleus/decisions/order-store.yaml", validDecisionYAML("sha256:bad")) + + output := validate(Config{Dir: &dir}, []string{".nucleus/decisions/order-store.yaml"}) + if output.OK { + t.Fatalf("ok = true, want failure") + } + assertDecisionDiagnostic(t, output, "decision.hash_mismatch") +} + +func TestValidateDecisionChecksSupersedesHash(t *testing.T) { + dir := t.TempDir() + writeDecisionFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +capabilities: + - id: order_store + kind: relational_store +ai: + intent: test + allowed_changes: + - internal/** +`) + writeDecisionFile(t, dir, ".nucleus/decisions/original.yaml", validDecisionYAML("")) + writeDecisionFile(t, dir, ".nucleus/decisions/supersede.yaml", `schema_version: "decision.v1" +id: order-store-provider-v2 +capability: order_store +supersedes: order-store-provider +supersedes_hash: sha256:not-the-original +decision: + provider: database/sql + library: database/sql + status: proposed + locked: false +reason: + - replace gorm with standard library database sql +impact: + files: + - internal/order/store.go +verification: + commands: + - go test ./internal/order +`) + + output := validate(Config{Dir: &dir}, []string{".nucleus/decisions"}) + if output.OK { + t.Fatalf("ok = true, want failure") + } + assertDecisionDiagnostic(t, output, "decision.supersedes_hash_mismatch") +} + +func TestValidateDecisionAllowsUnknownProviderChoices(t *testing.T) { + dir := t.TempDir() + writeDecisionFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +capabilities: + - id: order_store + kind: project_specific_store_kind +ai: + intent: test + allowed_changes: + - internal/** +`) + writeDecisionFile(t, dir, ".nucleus/decisions/custom-provider.yaml", `schema_version: "decision.v1" +id: custom-provider +capability: order_store +decision: + provider: private-infra-store + library: example.com/private/store + driver: team-owned-driver + status: proposed + locked: false +reason: + - project owns the integration details +impact: + files: + - internal/order/store.go +verification: + commands: + - go test ./internal/order +alternatives: + - provider: database/sql + reason: standard library option +`) + + output := validate(Config{Dir: &dir}, []string{".nucleus/decisions/custom-provider.yaml"}) + if !output.OK { + t.Fatalf("ok = false, diagnostics = %#v", output.Diagnostics) + } +} + +func TestAcceptDecisionLocksAndWritesHash(t *testing.T) { + dir := t.TempDir() + writeDecisionFile(t, dir, "nucleus.yaml", decisionManifestYAML()) + writeDecisionFile(t, dir, ".nucleus/decisions/order-store.yaml", proposedDecisionYAML("order-store-provider", "gorm", "gorm.io/gorm", "")) + + output := accept(Config{Dir: &dir}, ".nucleus/decisions/order-store.yaml", "human", "2026-07-03T00:00:00Z") + if !output.OK { + t.Fatalf("accept failed: %#v", output.Diagnostics) + } + if !output.Changed || output.Decision.Hash == "" { + t.Fatalf("accept output = %#v", output) + } + doc, diagnostics, ok := loadDecisionFile(filepath.Join(dir, ".nucleus/decisions/order-store.yaml"), ".nucleus/decisions/order-store.yaml") + if !ok || diagnostics.Failed() { + t.Fatalf("load accepted decision: ok=%v diagnostics=%#v", ok, diagnostics) + } + if doc.Decision.Status != decisionStatusAccepted || doc.Decision.Locked == nil || !*doc.Decision.Locked { + t.Fatalf("decision not accepted+locked: %#v", doc.Decision) + } + if doc.Decision.AcceptedBy != "human" || doc.Decision.AcceptedAt != "2026-07-03T00:00:00Z" { + t.Fatalf("accepted metadata = %#v", doc.Decision) + } + if doc.DecisionHash == "" || doc.DecisionHash != canonicalDecisionHash(doc) { + t.Fatalf("decision hash = %q, want canonical %q", doc.DecisionHash, canonicalDecisionHash(doc)) + } +} + +func TestQualityForDirSummarizesLockedDecisionsAndDrift(t *testing.T) { + dir := t.TempDir() + writeDecisionFile(t, dir, "nucleus.yaml", decisionManifestYAML()) + writeDecisionFile(t, dir, ".nucleus/decisions/order-store.yaml", proposedDecisionYAML("order-store-provider", "gorm", "gorm.io/gorm", "")) + + accepted := accept(Config{Dir: &dir}, ".nucleus/decisions/order-store.yaml", "human", "2026-07-03T00:00:00Z") + if !accepted.OK { + t.Fatalf("accept failed: %#v", accepted.Diagnostics) + } + quality := QualityForDir(dir) + if quality.Files != 1 || quality.Valid != 1 || quality.AcceptedLocked != 1 || quality.Drift != 0 { + t.Fatalf("quality = %#v, want one valid locked decision without drift", quality) + } + + writeDecisionFile(t, dir, ".nucleus/decisions/order-store.yaml", validDecisionYAML("sha256:bad")) + quality = QualityForDir(dir) + if quality.Drift != 1 || quality.Errors == 0 { + t.Fatalf("quality = %#v, want drift error", quality) + } +} + +func TestQualityForDirIgnoresMissingDecisionDirectory(t *testing.T) { + quality := QualityForDir(t.TempDir()) + if quality.Files != 0 || quality.Errors != 0 || quality.Warnings != 0 || len(quality.Diagnostics) != 0 { + t.Fatalf("quality = %#v, want empty optional decision summary", quality) + } +} + +func TestSupersedeDecisionWritesPreviousHash(t *testing.T) { + dir := t.TempDir() + writeDecisionFile(t, dir, "nucleus.yaml", decisionManifestYAML()) + writeDecisionFile(t, dir, ".nucleus/decisions/original.yaml", proposedDecisionYAML("order-store-provider", "gorm", "gorm.io/gorm", "")) + accepted := accept(Config{Dir: &dir}, ".nucleus/decisions/original.yaml", "human", "2026-07-03T00:00:00Z") + if !accepted.OK { + t.Fatalf("accept original: %#v", accepted.Diagnostics) + } + writeDecisionFile(t, dir, ".nucleus/decisions/supersede.yaml", proposedDecisionYAML("order-store-provider-v2", "xorm", "xorm.io/xorm", "order-store-provider")) + + output := supersede(Config{Dir: &dir}, ".nucleus/decisions/supersede.yaml") + if !output.OK { + t.Fatalf("supersede failed: %#v", output.Diagnostics) + } + doc, diagnostics, ok := loadDecisionFile(filepath.Join(dir, ".nucleus/decisions/supersede.yaml"), ".nucleus/decisions/supersede.yaml") + if !ok || diagnostics.Failed() { + t.Fatalf("load supersede decision: ok=%v diagnostics=%#v", ok, diagnostics) + } + if doc.SupersedesHash != accepted.Decision.Hash { + t.Fatalf("supersedes_hash = %q, want %q", doc.SupersedesHash, accepted.Decision.Hash) + } + validation := validate(Config{Dir: &dir}, []string{".nucleus/decisions"}) + if !validation.OK { + t.Fatalf("validate supersede flow: %#v", validation.Diagnostics) + } +} + +func TestCommandRendersJSONBeforeReturningValidationError(t *testing.T) { + dir := t.TempDir() + writeDecisionFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +capabilities: [] +ai: + intent: test +`) + writeDecisionFile(t, dir, ".nucleus/decisions/bad.yaml", `schema_version: "decision.v1" +id: bad +capability: missing +decision: + status: proposed + locked: false +reason: + - missing provider evidence +verification: + commands: + - go test ./... +`) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"validate", ".nucleus/decisions/bad.yaml", "--json", "--pretty"}) + + err := cmd.Execute() + if !errors.Is(err, ErrDecisionInvalid) { + t.Fatalf("execute error = %v, want ErrDecisionInvalid", err) + } + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if output["result_kind"] != resultKindDecision || output["ok"] != false { + t.Fatalf("unexpected output: %#v", output) + } +} + +func decisionManifestYAML() string { + return `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +capabilities: + - id: order_store + kind: sql +ai: + intent: test + allowed_changes: + - internal/** + - .nucleus/** +` +} + +func proposedDecisionYAML(id string, provider string, library string, supersedes string) string { + supersedesLine := "" + if supersedes != "" { + supersedesLine = "supersedes: " + supersedes + "\n" + } + return `schema_version: "decision.v1" +id: ` + id + ` +capability: order_store +` + supersedesLine + `decision: + provider: ` + provider + ` + library: ` + library + ` + status: proposed + locked: false +reason: + - project needs explicit provider evidence +impact: + files: + - internal/order/store.go +verification: + commands: + - go test ./internal/order +` +} + +func validDecisionYAML(hash string) string { + locked := "false" + hashLine := "" + if hash != "" { + locked = "true" + hashLine = "decision_hash: " + hash + "\n" + } + return `schema_version: "decision.v1" +id: order-store-provider +capability: order_store +decision: + provider: gorm + library: gorm.io/gorm + status: accepted + locked: ` + locked + ` + accepted_by: human + accepted_at: "2026-07-03T00:00:00Z" +` + hashLine + `reason: + - project already uses gorm in storage package +impact: + symbols: + - OrderStore + files: + - internal/order/store.go +verification: + commands: + - go test ./internal/order +alternatives: + - provider: database/sql + reason: lower dependency footprint +` +} + +func writeDecisionFile(t *testing.T, dir string, name string, content string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatalf("mkdir %s: %v", name, err) + } + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write %s: %v", name, err) + } +} + +func assertDecisionDiagnostic(t *testing.T, output result, code string) { + t.Helper() + for _, item := range output.Diagnostics { + if item.Code == code { + return + } + } + t.Fatalf("diagnostic %q not found in %#v", code, output.Diagnostics) +} diff --git a/cmd/nucleus/internal/describe/command.go b/cmd/nucleus/internal/describe/command.go index 9985837..1e49800 100644 --- a/cmd/nucleus/internal/describe/command.go +++ b/cmd/nucleus/internal/describe/command.go @@ -8,8 +8,7 @@ import ( // Config carries root-level flag values used by the describe command. type Config struct { - Dir *string - SchemaOverride *string + Dir *string } type options struct { @@ -45,9 +44,8 @@ func NewCommand(config Config) *cobra.Command { // buildOptions builds an OutputOptions struct from the root command's flag values. func buildOptions(config Config, opts *options) OutputOptions { return OutputOptions{ - Dir: stringValue(config.Dir, defaultDir), - SchemaOverride: stringValue(config.SchemaOverride, ""), - IncludeFlow: opts.flow, + Dir: stringValue(config.Dir, defaultDir), + IncludeFlow: opts.flow, } } diff --git a/cmd/nucleus/internal/describe/constants.go b/cmd/nucleus/internal/describe/constants.go index 1323d6e..9d5c5d5 100644 --- a/cmd/nucleus/internal/describe/constants.go +++ b/cmd/nucleus/internal/describe/constants.go @@ -1,7 +1,5 @@ package describe -const defaultSchemaVersion = "1.1" - const ( commandUseDescribe = "describe" commandShortSummary = "describe service metadata as JSON" @@ -22,22 +20,34 @@ const ( flagHelpFlow = "include conservative flow graph" ) +const ( + resultKindDescribe = "nucleus.describe_result" + schemaVersionDescribe = "describe-result.v1" + schemaRefDescribe = "contract/schema/describe-result.v1.schema.json" +) + const ( outputFieldSchemaVersion = "schema_version" + outputFieldResultKind = "result_kind" + outputFieldSchemaRef = "schema_ref" + outputFieldOK = "ok" outputFieldVerification = "verification" + outputFieldDiagnostics = "diagnostics" ) const ( verificationFieldCommands = "commands" verificationFieldPipeline = "pipeline" + verificationFieldProjectSource = "project_commands_source" verificationFieldResultKind = "result_kind" verificationFieldEvidenceSchema = "evidence_schema" verificationFieldOptional = "optional_evidence" ) const ( - verificationResultKind = "nucleus.verify_result" - verificationEvidenceSchema = "contract/schema/evidence.schema.json" + verificationResultKind = "nucleus.verify_result" + verificationEvidenceSchema = "contract/schema/evidence.v1.schema.json" + verificationProjectCommandSource = "nucleus.yaml verify.commands" ) const ( @@ -50,26 +60,21 @@ const ( ) const ( - commandValidate = "nucleus validate --dir ." - commandLintStrict = "nucleus lint --dir . --strict" - commandVerifyJSON = "nucleus verify --dir . --json" - commandDescribeJSON = "nucleus describe --dir . --json" - commandScenarioPlanJSON = "nucleus scenario --dir . --json" - commandScenarioRunJSON = "nucleus scenario --dir . --run-http --base-url --json" - commandGoModTidy = "go mod tidy" - commandGoListAll = "go list ./..." - commandGoTestCompileOnly = "go test ./... -run ^$" - commandGoTestAll = "go test ./..." + commandValidate = "nucleus validate --dir ." + commandLintStrict = "nucleus lint --dir . --strict" + commandDecisionValidate = "nucleus decision validate --dir ." + commandVerifyJSON = "nucleus verify --dir . --json" + commandDescribeJSON = "nucleus describe --dir . --json" + commandScenarioPlanJSON = "nucleus scenario --dir . --json" + commandScenarioRunJSON = "nucleus scenario --dir . --run-http --base-url --json" ) const ( phaseValidate = "validate" phaseLint = "lint" + phaseDecision = "decision" phaseGeneratedFreshness = "generated_freshness" - phaseTidy = "tidy" - phaseImport = "import" - phaseBuild = "build" - phaseTest = "test" + phaseScenario = "scenario" ) const ( diff --git a/cmd/nucleus/internal/describe/output.go b/cmd/nucleus/internal/describe/output.go index c14ae16..5ae9892 100644 --- a/cmd/nucleus/internal/describe/output.go +++ b/cmd/nucleus/internal/describe/output.go @@ -3,14 +3,14 @@ package describe import ( "encoding/json" + "github.com/nucleuskit/contract/diagnostic" "github.com/nucleuskit/contract/inspect" ) // OutputOptions controls the describe JSON payload. type OutputOptions struct { - Dir string - SchemaOverride string - IncludeFlow bool + Dir string + IncludeFlow bool } // BuildOutput describes a service directory and adds CLI verification metadata. @@ -30,19 +30,19 @@ func BuildOutput(opts OutputOptions) (map[string]any, error) { if err != nil { return nil, err } - data[outputFieldSchemaVersion] = schemaVersion(opts.SchemaOverride) + diagnostics := description.Diagnostics + if diagnostics == nil { + diagnostics = diagnostic.Diagnostics{} + } + data[outputFieldResultKind] = resultKindDescribe + data[outputFieldSchemaVersion] = schemaVersionDescribe + data[outputFieldSchemaRef] = schemaRefDescribe + data[outputFieldOK] = !diagnostics.Failed() + data[outputFieldDiagnostics] = diagnostics data[outputFieldVerification] = verificationContract() return data, nil } -// schemaVersion returns the schema version to use for the describe output. -func schemaVersion(override string) string { - if override != "" { - return override - } - return defaultSchemaVersion -} - // descriptionAsMap converts a Description to a map[string]any. func descriptionAsMap(description inspect.Description) (map[string]any, error) { data, err := json.Marshal(description) diff --git a/cmd/nucleus/internal/describe/output_test.go b/cmd/nucleus/internal/describe/output_test.go index 4d4b7bd..fbcb48f 100644 --- a/cmd/nucleus/internal/describe/output_test.go +++ b/cmd/nucleus/internal/describe/output_test.go @@ -9,15 +9,23 @@ import ( func TestBuildOutputAddsDescribeMetadata(t *testing.T) { dir := newDescribeFixture(t) output, err := BuildOutput(OutputOptions{ - Dir: dir, - SchemaOverride: "9.9", - IncludeFlow: true, + Dir: dir, + IncludeFlow: true, }) if err != nil { t.Fatalf("BuildOutput() error = %v", err) } - if got := output["schema_version"]; got != "9.9" { - t.Fatalf("schema_version = %v, want 9.9", got) + if got := output["result_kind"]; got != resultKindDescribe { + t.Fatalf("result_kind = %v, want %s", got, resultKindDescribe) + } + if got := output["schema_version"]; got != schemaVersionDescribe { + t.Fatalf("schema_version = %v, want %s", got, schemaVersionDescribe) + } + if got := output["schema_ref"]; got != schemaRefDescribe { + t.Fatalf("schema_ref = %v, want %s", got, schemaRefDescribe) + } + if got := output["ok"]; got != true { + t.Fatalf("ok = %v, want true", got) } if output["flow_graph"] == nil { t.Fatal("flow_graph missing") @@ -32,6 +40,9 @@ func TestBuildOutputAddsDescribeMetadata(t *testing.T) { if got := verification["evidence_schema"]; got != verificationEvidenceSchema { t.Fatalf("verification.evidence_schema = %v, want %s", got, verificationEvidenceSchema) } + if got := verification["project_commands_source"]; got != verificationProjectCommandSource { + t.Fatalf("verification.project_commands_source = %v, want %s", got, verificationProjectCommandSource) + } schemaPath := filepath.Join("..", "..", "..", "..", filepath.FromSlash(verificationEvidenceSchema)) if _, err := os.Stat(schemaPath); err != nil { t.Fatalf("verification evidence schema %s is not readable: %v", schemaPath, err) @@ -54,8 +65,8 @@ func TestBuildOutputUsesDefaultSchemaVersion(t *testing.T) { if err != nil { t.Fatalf("BuildOutput() error = %v", err) } - if got := output["schema_version"]; got != defaultSchemaVersion { - t.Fatalf("schema_version = %v, want %s", got, defaultSchemaVersion) + if got := output["schema_version"]; got != schemaVersionDescribe { + t.Fatalf("schema_version = %v, want %s", got, schemaVersionDescribe) } if output["flow_graph"] != nil { t.Fatal("flow_graph present without IncludeFlow") @@ -65,12 +76,11 @@ func TestBuildOutputUsesDefaultSchemaVersion(t *testing.T) { func newDescribeFixture(t *testing.T) string { t.Helper() dir := t.TempDir() - manifest := []byte(`schema_version: "1.0" + manifest := []byte(`schema_version: "2.0" service: name: fixture version: "0.1.0" ai: {} -nucleus: {} `) if err := os.WriteFile(filepath.Join(dir, "nucleus.yaml"), manifest, 0o600); err != nil { t.Fatalf("write nucleus.yaml: %v", err) @@ -86,11 +96,8 @@ func assertVerificationPipeline(t *testing.T, pipeline []map[string]any) { }{ {phaseValidate, commandValidate}, {phaseLint, commandLintStrict}, + {phaseDecision, commandDecisionValidate}, {phaseGeneratedFreshness, commandDescribeJSON}, - {phaseTidy, commandGoModTidy}, - {phaseImport, commandGoListAll}, - {phaseBuild, commandGoTestCompileOnly}, - {phaseTest, commandGoTestAll}, } if len(pipeline) != len(want) { t.Fatalf("pipeline length = %d, want %d", len(pipeline), len(want)) diff --git a/cmd/nucleus/internal/describe/verification.go b/cmd/nucleus/internal/describe/verification.go index d059049..4061a6a 100644 --- a/cmd/nucleus/internal/describe/verification.go +++ b/cmd/nucleus/internal/describe/verification.go @@ -2,8 +2,6 @@ package describe func verificationCommands() []string { return []string{ - commandValidate, - commandLintStrict, commandVerifyJSON, } } @@ -13,6 +11,7 @@ func verificationContract() map[string]any { return map[string]any{ verificationFieldCommands: verificationCommands(), verificationFieldPipeline: verificationPipeline(), + verificationFieldProjectSource: verificationProjectCommandSource, verificationFieldResultKind: verificationResultKind, verificationFieldEvidenceSchema: verificationEvidenceSchema, verificationFieldOptional: optionalEvidence(), @@ -28,11 +27,8 @@ func verificationPipeline() []map[string]any { }{ {phaseValidate, phaseValidate, commandValidate}, {phaseLint, phaseLint, commandLintStrict}, + {phaseDecision, phaseDecision, commandDecisionValidate}, {phaseGeneratedFreshness, phaseGeneratedFreshness, commandDescribeJSON}, - {phaseTidy, phaseTidy, commandGoModTidy}, - {phaseImport, phaseImport, commandGoListAll}, - {phaseBuild, phaseBuild, commandGoTestCompileOnly}, - {phaseTest, phaseTest, commandGoTestAll}, } pipeline := make([]map[string]any, 0, len(steps)) for index, step := range steps { @@ -52,7 +48,7 @@ func optionalEvidence() []map[string]any { return []map[string]any{ { pipelineFieldID: "scenario_plan", - pipelineFieldPhase: phaseTest, + pipelineFieldPhase: phaseScenario, pipelineFieldCommand: commandScenarioPlanJSON, pipelineFieldSchemaRef: "", pipelineFieldProduces: "nucleus.scenario_plan_result", @@ -60,7 +56,7 @@ func optionalEvidence() []map[string]any { }, { pipelineFieldID: "http_scenario", - pipelineFieldPhase: phaseTest, + pipelineFieldPhase: phaseScenario, pipelineFieldCommand: commandScenarioRunJSON, pipelineFieldSchemaRef: verificationEvidenceSchema, pipelineFieldProduces: evidenceKindHTTPScenario, diff --git a/cmd/nucleus/internal/execute/command_test.go b/cmd/nucleus/internal/execute/command_test.go index b4b0c35..3bacf16 100644 --- a/cmd/nucleus/internal/execute/command_test.go +++ b/cmd/nucleus/internal/execute/command_test.go @@ -37,18 +37,18 @@ func TestCommandJSONBlockedReturnsSentinel(t *testing.T) { } var output struct { - Kind string `json:"kind"` - Pass bool `json:"pass"` - Status string `json:"status"` - Steps []map[string]any `json:"steps"` + ResultKind string `json:"result_kind"` + OK bool `json:"ok"` + Status string `json:"status"` + Steps []map[string]any `json:"steps"` } if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) } - if output.Kind != "nucleus.executor_evidence" || output.Pass || output.Status != "failed" { + if output.ResultKind != resultKindExecutorEvidence || output.OK || output.Status != "failed" { t.Fatalf("unexpected executor evidence: %#v", output) } - if len(output.Steps) != 1 || output.Steps[0]["status"] != "blocked" { + if len(output.Steps) != 1 || output.Steps[0]["ok"] != false || output.Steps[0]["status"] != "blocked" { t.Fatalf("steps = %#v, want blocked command", output.Steps) } } diff --git a/cmd/nucleus/internal/execute/constants.go b/cmd/nucleus/internal/execute/constants.go index a056191..933e6b4 100644 --- a/cmd/nucleus/internal/execute/constants.go +++ b/cmd/nucleus/internal/execute/constants.go @@ -24,3 +24,16 @@ const ( jsonIndentPrefix = "" jsonIndentValue = " " ) + +const ( + resultKindExecutorEvidence = "nucleus.executor_evidence" + schemaVersionEvidence = "evidence.v1" + schemaRefEvidence = "contract/schema/evidence.v1.schema.json" +) + +const ( + statusPassed = "passed" + statusFailed = "failed" + statusBlocked = "blocked" + statusTimeout = "timeout" +) diff --git a/cmd/nucleus/internal/execute/executor.go b/cmd/nucleus/internal/execute/executor.go index 028deea..4ff678c 100644 --- a/cmd/nucleus/internal/execute/executor.go +++ b/cmd/nucleus/internal/execute/executor.go @@ -60,7 +60,7 @@ func ExecutePlanCommands(dir string, planPath string, allowlist []string) (map[s pass := true for index, command := range plan.Commands { step, logEntry, exitCode := executePlanCommand(dir, command, index, allowedCommands) - if stepPass, _ := step["pass"].(bool); !stepPass { + if stepOK, _ := step["ok"].(bool); !stepOK { pass = false } steps = append(steps, step) @@ -73,11 +73,13 @@ func ExecutePlanCommands(dir string, planPath string, allowlist []string) (map[s status = "failed" } return map[string]any{ - "schema_version": "evidence.v1", - "kind": "nucleus.executor_evidence", - "pass": pass, + "result_kind": resultKindExecutorEvidence, + "schema_version": schemaVersionEvidence, + "schema_ref": schemaRefEvidence, + "ok": pass, "status": status, "steps": steps, + "diagnostics": []map[string]any{}, "logs": logs, "exit_codes": exitCodes, "assertion_results": assertionResults(plan.Assertions, steps), @@ -111,24 +113,24 @@ func executePlanCommand(dir string, command planCommand, index int, allowlist ma } args, err := splitCommand(command.Command) if err != nil || len(args) == 0 { - step["pass"] = false - step["status"] = "failed" + step["ok"] = false + step["status"] = statusFailed step["exit_code"] = 1 step["stderr"] = redact(errorString(err, "empty command")) return step, logEntry(id, "stderr", step["stderr"].(string)), exitCodeEntry(id, command.Command, 1) } commandName := filepath.Base(args[0]) if !allowlist[commandName] { - step["pass"] = false - step["status"] = "blocked" + step["ok"] = false + step["status"] = statusBlocked step["exit_code"] = 126 step["command_name"] = commandName step["stderr"] = "command is not in executor allowlist" return step, logEntry(id, "stderr", step["stderr"].(string)), exitCodeEntry(id, command.Command, 126) } if command.Allowed != nil && !*command.Allowed { - step["pass"] = false - step["status"] = "blocked" + step["ok"] = false + step["status"] = statusBlocked step["exit_code"] = 126 step["command_name"] = commandName step["stderr"] = "plan command is marked as not allowed" @@ -136,8 +138,8 @@ func executePlanCommand(dir string, command planCommand, index int, allowlist ma } cwd, err := resolveCWD(dir, command) if err != nil { - step["pass"] = false - step["status"] = "failed" + step["ok"] = false + step["status"] = statusFailed step["exit_code"] = 1 step["command_name"] = commandName step["stderr"] = redact(err.Error()) @@ -162,22 +164,22 @@ func executePlanCommand(dir string, command planCommand, index int, allowlist ma step["command_name"] = commandName step["log_summary"] = summary if ctx.Err() == context.DeadlineExceeded { - step["pass"] = false - step["status"] = "timeout" + step["ok"] = false + step["status"] = statusTimeout step["exit_code"] = 124 step["stderr"] = fmt.Sprintf("command timed out after %d seconds", timeout) return step, logEntry(id, "combined", summary), exitCodeEntry(id, command.Command, 124) } if err != nil { exitCode := commandExitCode(err) - step["pass"] = false - step["status"] = "failed" + step["ok"] = false + step["status"] = statusFailed step["exit_code"] = exitCode step["stderr"] = redact(err.Error()) return step, logEntry(id, "combined", summary), exitCodeEntry(id, command.Command, exitCode) } - step["pass"] = true - step["status"] = "passed" + step["ok"] = true + step["status"] = statusPassed step["exit_code"] = 0 return step, logEntry(id, "combined", summary), exitCodeEntry(id, command.Command, 0) } diff --git a/cmd/nucleus/internal/execute/executor_test.go b/cmd/nucleus/internal/execute/executor_test.go index 87843cf..aca38c4 100644 --- a/cmd/nucleus/internal/execute/executor_test.go +++ b/cmd/nucleus/internal/execute/executor_test.go @@ -42,7 +42,7 @@ func TestExecutePlanCommandsAllowsCommandSuccess(t *testing.T) { if err != nil { t.Fatalf("ExecutePlanCommands returned error: %v", err) } - if evidence["kind"] != "nucleus.executor_evidence" || evidence["pass"] != true || evidence["status"] != "passed" { + if evidence["result_kind"] != resultKindExecutorEvidence || evidence["schema_ref"] != schemaRefEvidence || evidence["ok"] != true || evidence["status"] != "passed" { t.Fatalf("unexpected passing evidence: %#v", evidence) } steps := evidence["steps"].([]map[string]any) @@ -72,11 +72,11 @@ func TestExecutePlanCommandsRejectsCommandOutsideAllowlist(t *testing.T) { if err != nil { t.Fatalf("allowlist rejection should be represented as evidence, got error: %v", err) } - if evidence["pass"] != false || evidence["status"] != "failed" { + if evidence["ok"] != false || evidence["status"] != "failed" { t.Fatalf("unexpected rejection evidence: %#v", evidence) } steps := evidence["steps"].([]map[string]any) - if steps[0]["status"] != "blocked" || steps[0]["exit_code"] != 126 { + if steps[0]["ok"] != false || steps[0]["status"] != "blocked" || steps[0]["exit_code"] != 126 { t.Fatalf("non-allowlisted command should be blocked: %#v", steps[0]) } } @@ -102,7 +102,7 @@ func TestExecutePlanCommandsRecordsFailureEvidence(t *testing.T) { t.Fatalf("command failure should be represented as evidence, got error: %v", err) } steps := evidence["steps"].([]map[string]any) - if evidence["pass"] != false || steps[0]["status"] != "failed" || steps[0]["exit_code"] != 7 { + if evidence["ok"] != false || steps[0]["ok"] != false || steps[0]["status"] != "failed" || steps[0]["exit_code"] != 7 { t.Fatalf("unexpected failure evidence: %#v", evidence) } } @@ -128,7 +128,7 @@ func TestExecutePlanCommandsRecordsTimeoutEvidence(t *testing.T) { t.Fatalf("timeout should be represented as evidence, got error: %v", err) } steps := evidence["steps"].([]map[string]any) - if evidence["pass"] != false || steps[0]["status"] != "timeout" || steps[0]["exit_code"] != 124 { + if evidence["ok"] != false || steps[0]["ok"] != false || steps[0]["status"] != "timeout" || steps[0]["exit_code"] != 124 { t.Fatalf("unexpected timeout evidence: %#v", evidence) } } diff --git a/cmd/nucleus/internal/execute/output.go b/cmd/nucleus/internal/execute/output.go index 9718f0f..44afb1e 100644 --- a/cmd/nucleus/internal/execute/output.go +++ b/cmd/nucleus/internal/execute/output.go @@ -32,8 +32,8 @@ func renderJSON(writer io.Writer, evidence map[string]any, pretty bool) error { } func evidencePass(evidence map[string]any) bool { - pass, _ := evidence["pass"].(bool) - return pass + ok, _ := evidence["ok"].(bool) + return ok } func stringField(evidence map[string]any, key string) string { @@ -60,13 +60,13 @@ func failedSteps(evidence map[string]any) int { case []any: for _, value := range steps { step, ok := value.(map[string]any) - if ok && step["pass"] == false { + if ok && step["ok"] == false { failed++ } } case []map[string]any: for _, step := range steps { - if step["pass"] == false { + if step["ok"] == false { failed++ } } diff --git a/cmd/nucleus/internal/gen/command_test.go b/cmd/nucleus/internal/gen/command_test.go index 48fe56b..922ed93 100644 --- a/cmd/nucleus/internal/gen/command_test.go +++ b/cmd/nucleus/internal/gen/command_test.go @@ -68,7 +68,7 @@ func TestCommandJSONSuccess(t *testing.T) { func TestCommandJSONValidationFailure(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: version: "0.1.0" ai: @@ -187,7 +187,7 @@ func TestCommandJSONUnsupportedClientLanguage(t *testing.T) { func writeService(t *testing.T, dir string) { t.Helper() - writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -198,7 +198,8 @@ ai: - internal/adapter/http/gen - sdk/typescript capabilities: - - http + - id: http + kind: http `) writeFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 paths: diff --git a/cmd/nucleus/internal/gen/constants.go b/cmd/nucleus/internal/gen/constants.go index bd1a725..8e66d8c 100644 --- a/cmd/nucleus/internal/gen/constants.go +++ b/cmd/nucleus/internal/gen/constants.go @@ -32,6 +32,8 @@ const ( const ( resultKindGen = "nucleus.gen_result" + schemaVersionGen = "gen-result.v1" + schemaRefGen = "contract/schema/gen-result.v1.schema.json" jsonIndentPrefix = "" jsonIndentValue = " " ) diff --git a/cmd/nucleus/internal/gen/output.go b/cmd/nucleus/internal/gen/output.go index 0b573f8..5742498 100644 --- a/cmd/nucleus/internal/gen/output.go +++ b/cmd/nucleus/internal/gen/output.go @@ -10,12 +10,14 @@ import ( ) type genResult struct { - ResultKind string `json:"result_kind"` - OK bool `json:"ok"` - SourceHash string `json:"source_hash,omitempty"` - Summary genSummary `json:"summary"` - Files []string `json:"files"` - Diagnostics diagnostic.Diagnostics `json:"diagnostics"` + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + SourceHash string `json:"source_hash,omitempty"` + Summary genSummary `json:"summary"` + Files []string `json:"files"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` } func renderHuman(stdout io.Writer, stderr io.Writer, result genResult) { @@ -46,6 +48,8 @@ func renderHuman(stdout io.Writer, stderr io.Writer, result genResult) { func renderJSON(writer io.Writer, result genResult, pretty bool) error { result.ResultKind = resultKindGen + result.SchemaVersion = schemaVersionGen + result.SchemaRef = schemaRefGen if result.Files == nil { result.Files = []string{} } diff --git a/cmd/nucleus/internal/gen/output_test.go b/cmd/nucleus/internal/gen/output_test.go index 02cd74e..d987fac 100644 --- a/cmd/nucleus/internal/gen/output_test.go +++ b/cmd/nucleus/internal/gen/output_test.go @@ -25,6 +25,12 @@ func TestRenderJSONUsesStableEmptyArrays(t *testing.T) { if got := output["result_kind"]; got != resultKindGen { t.Fatalf("result_kind = %v, want %s", got, resultKindGen) } + if got := output["schema_version"]; got != schemaVersionGen { + t.Fatalf("schema_version = %v, want %s", got, schemaVersionGen) + } + if got := output["schema_ref"]; got != schemaRefGen { + t.Fatalf("schema_ref = %v, want %s", got, schemaRefGen) + } if _, ok := output["files"].([]any); !ok { t.Fatalf("files has type %T, want []any", output["files"]) } diff --git a/cmd/nucleus/internal/graphquery/query.go b/cmd/nucleus/internal/graphquery/query.go new file mode 100644 index 0000000..789d0b7 --- /dev/null +++ b/cmd/nucleus/internal/graphquery/query.go @@ -0,0 +1,168 @@ +package graphquery + +import ( + "sort" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" +) + +const ( + EdgeKindCalls = "calls" + EdgeKindTests = "tests" +) + +// SymbolResolution is the result of resolving a user-supplied symbol query. +type SymbolResolution struct { + Node inspect.SymbolNode + Candidates []inspect.SymbolNode + Diagnostic diagnostic.Diagnostics + OK bool +} + +// ResolveSymbol resolves stable symbol IDs and short names without guessing on ambiguity. +func ResolveSymbol(graph inspect.SymbolGraph, query string) SymbolResolution { + query = strings.TrimSpace(query) + if query == "" { + return SymbolResolution{Diagnostic: diagnostic.Diagnostics{errorDiagnostic("graph.symbol_query_required", "symbol query is required")}} + } + if strings.HasPrefix(query, "go://") { + for _, node := range graph.Nodes { + if node.ID == query { + return SymbolResolution{Node: node, OK: true} + } + } + return SymbolResolution{Diagnostic: diagnostic.Diagnostics{errorDiagnostic("graph.symbol_not_found", "symbol id was not found")}} + } + var candidates []inspect.SymbolNode + for _, node := range graph.Nodes { + if node.Name == query || strings.HasSuffix(node.ID, "#"+query) { + candidates = append(candidates, node) + } + } + sortNodes(candidates) + switch len(candidates) { + case 0: + return SymbolResolution{Diagnostic: diagnostic.Diagnostics{errorDiagnostic("graph.symbol_not_found", "symbol name was not found")}} + case 1: + return SymbolResolution{Node: candidates[0], OK: true} + default: + return SymbolResolution{ + Candidates: candidates, + Diagnostic: diagnostic.Diagnostics{errorDiagnostic("graph.symbol_ambiguous", "symbol name matched multiple candidates; rerun with a stable symbol id")}, + } + } +} + +// IncomingEdges returns sorted graph edges pointing to id. When kinds are provided, only those kinds are returned. +func IncomingEdges(graph inspect.SymbolGraph, id string, kinds ...string) []inspect.SymbolEdge { + allowed := edgeKindSet(kinds) + var result []inspect.SymbolEdge + for _, edge := range graph.Edges { + if edge.To == id && (len(allowed) == 0 || allowed[edge.Kind]) { + result = append(result, edge) + } + } + sortEdges(result) + return result +} + +// OutgoingEdges returns sorted graph edges leaving id. When kinds are provided, only those kinds are returned. +func OutgoingEdges(graph inspect.SymbolGraph, id string, kinds ...string) []inspect.SymbolEdge { + allowed := edgeKindSet(kinds) + var result []inspect.SymbolEdge + for _, edge := range graph.Edges { + if edge.From == id && (len(allowed) == 0 || allowed[edge.Kind]) { + result = append(result, edge) + } + } + sortEdges(result) + return result +} + +// NodesForEdges returns stable unique nodes referenced by edges plus optional explicit ids. +func NodesForEdges(graph inspect.SymbolGraph, edges []inspect.SymbolEdge, ids ...string) []inspect.SymbolNode { + nodeByID := map[string]inspect.SymbolNode{} + for _, node := range graph.Nodes { + nodeByID[node.ID] = node + } + seen := map[string]bool{} + var result []inspect.SymbolNode + add := func(id string) { + if seen[id] { + return + } + node, ok := nodeByID[id] + if !ok { + return + } + seen[id] = true + result = append(result, node) + } + for _, id := range ids { + add(id) + } + for _, edge := range edges { + add(edge.From) + add(edge.To) + } + sortNodes(result) + return result +} + +// FilesForNodes returns sorted files attached to symbol nodes. +func FilesForNodes(nodes []inspect.SymbolNode) []string { + seen := map[string]bool{} + var result []string + for _, node := range nodes { + if node.File == "" || seen[node.File] { + continue + } + seen[node.File] = true + result = append(result, node.File) + } + sort.Strings(result) + return result +} + +// NodeByFile returns file nodes that match the relative file path. +func NodeByFile(graph inspect.SymbolGraph, path string) []inspect.SymbolNode { + path = strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(path), "\\", "/"), "./") + var result []inspect.SymbolNode + for _, node := range graph.Nodes { + if node.File == path && node.Kind == "file" { + result = append(result, node) + } + } + sortNodes(result) + return result +} + +func edgeKindSet(kinds []string) map[string]bool { + set := map[string]bool{} + for _, kind := range kinds { + set[kind] = true + } + return set +} + +func sortNodes(nodes []inspect.SymbolNode) { + sort.Slice(nodes, func(i, j int) bool { return nodes[i].ID < nodes[j].ID }) +} + +func sortEdges(edges []inspect.SymbolEdge) { + sort.Slice(edges, func(i, j int) bool { + if edges[i].From == edges[j].From { + if edges[i].Kind == edges[j].Kind { + return edges[i].To < edges[j].To + } + return edges[i].Kind < edges[j].Kind + } + return edges[i].From < edges[j].From + }) +} + +func errorDiagnostic(code string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: code, Message: message} +} diff --git a/cmd/nucleus/internal/impact/command.go b/cmd/nucleus/internal/impact/command.go new file mode 100644 index 0000000..2a970c2 --- /dev/null +++ b/cmd/nucleus/internal/impact/command.go @@ -0,0 +1,110 @@ +package impact + +import ( + "errors" + + "github.com/spf13/cobra" +) + +// Config carries root-level flag values used by the impact command. +type Config struct { + Dir *string +} + +type options struct { + json bool + pretty bool +} + +var ErrImpactFailed = errors.New("impact failed") + +// NewCommand creates the impact command group. +func NewCommand(config Config) *cobra.Command { + cmd := &cobra.Command{ + Use: commandUseImpact, + Short: commandShortImpact, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.AddCommand(newSymbolCommand(config)) + cmd.AddCommand(newFileCommand(config)) + cmd.AddCommand(newContractCommand(config)) + return cmd +} + +func newSymbolCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseSymbol, + Short: commandShortSymbol, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := impactSymbol(config, args[0]) + return renderAndReturn(cmd, output, opts) + }, + } + addFlags(cmd, opts) + return cmd +} + +func newFileCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseFile, + Short: commandShortFile, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := impactFile(config, args[0]) + return renderAndReturn(cmd, output, opts) + }, + } + addFlags(cmd, opts) + return cmd +} + +func newContractCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseContract, + Short: commandShortContract, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := impactContract(config, args[0]) + return renderAndReturn(cmd, output, opts) + }, + } + addFlags(cmd, opts) + return cmd +} + +func addFlags(cmd *cobra.Command, opts *options) { + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) +} + +func renderAndReturn(cmd *cobra.Command, output result, opts *options) error { + if opts.json { + if err := renderJSON(cmd.OutOrStdout(), output, opts.pretty); err != nil { + return err + } + } else { + renderHuman(cmd.OutOrStdout(), output) + } + if !output.OK { + return ErrImpactFailed + } + return nil +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/cmd/nucleus/internal/impact/command_test.go b/cmd/nucleus/internal/impact/command_test.go new file mode 100644 index 0000000..bacd9f2 --- /dev/null +++ b/cmd/nucleus/internal/impact/command_test.go @@ -0,0 +1,128 @@ +package impact + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestImpactSymbolReturnsFilesAndTests(t *testing.T) { + dir := t.TempDir() + writeImpactFile(t, dir, "go.mod", "module example.com/orders\n\ngo 1.26.3\n") + writeImpactFile(t, dir, "order/service.go", `package order + +func CreateOrder() { validateOrder() } +func validateOrder() {} +func Caller() { CreateOrder() } +`) + writeImpactFile(t, dir, "order/service_test.go", `package order + +func TestCreateOrder(t *testing.T) {} +`) + + output := impactSymbol(Config{Dir: &dir}, "CreateOrder") + if !output.OK { + t.Fatalf("ok=false diagnostics=%#v", output.Diagnostics) + } + if len(output.AffectedFiles) != 2 { + t.Fatalf("affected files = %#v", output.AffectedFiles) + } + if len(output.AffectedTests) != 1 || output.AffectedTests[0].Name != "TestCreateOrder" { + t.Fatalf("affected tests = %#v", output.AffectedTests) + } + if len(output.Edges) == 0 { + t.Fatal("impact edges missing") + } +} + +func TestImpactFileExpandsDeclaredSymbols(t *testing.T) { + dir := t.TempDir() + writeImpactFile(t, dir, "go.mod", "module example.com/orders\n\ngo 1.26.3\n") + writeImpactFile(t, dir, "order/service.go", `package order + +func CreateOrder() {} +func Caller() { CreateOrder() } +`) + + output := impactFile(Config{Dir: &dir}, "order/service.go") + if !output.OK { + t.Fatalf("ok=false diagnostics=%#v", output.Diagnostics) + } + if len(output.AffectedSymbols) < 3 { + t.Fatalf("affected symbols = %#v", output.AffectedSymbols) + } + assertImpactFile(t, output, "order/service.go") +} + +func TestImpactContractReturnsRoutes(t *testing.T) { + dir := t.TempDir() + writeImpactFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 +paths: + /orders: + post: + operationId: createOrder + responses: + "200": + description: ok +`) + writeImpactFile(t, dir, "api/errors.yaml", `errors: + - code: 0 + message: ok + http_status: 200 +`) + + output := impactContract(Config{Dir: &dir}, "api/openapi.yaml") + if !output.OK { + t.Fatalf("ok=false diagnostics=%#v", output.Diagnostics) + } + if len(output.AffectedRoutes) != 1 { + t.Fatalf("routes = %#v", output.AffectedRoutes) + } + assertImpactFile(t, output, "api/openapi.yaml") +} + +func TestImpactCommandRendersJSONBeforeFailure(t *testing.T) { + dir := t.TempDir() + writeImpactFile(t, dir, "go.mod", "module example.com/empty\n\ngo 1.26.3\n") + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"symbol", "Missing", "--json"}) + + err := cmd.Execute() + if !errors.Is(err, ErrImpactFailed) { + t.Fatalf("execute error = %v, want ErrImpactFailed", err) + } + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if output["ok"] != false || output["result_kind"] != resultKindImpact { + t.Fatalf("unexpected output: %#v", output) + } +} + +func writeImpactFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func assertImpactFile(t *testing.T, output result, want string) { + t.Helper() + for _, file := range output.AffectedFiles { + if file == want { + return + } + } + t.Fatalf("file %q not found in %#v", want, output.AffectedFiles) +} diff --git a/cmd/nucleus/internal/impact/constants.go b/cmd/nucleus/internal/impact/constants.go new file mode 100644 index 0000000..a056779 --- /dev/null +++ b/cmd/nucleus/internal/impact/constants.go @@ -0,0 +1,29 @@ +package impact + +const ( + commandUseImpact = "impact" + commandShortImpact = "query change impact over the symbol graph" + commandUseSymbol = "symbol " + commandShortSymbol = "show impact for a symbol" + commandUseFile = "file " + commandShortFile = "show impact for a file" + commandUseContract = "contract " + commandShortContract = "show impact for a contract file" + defaultDir = "." + resultKindImpact = "nucleus.impact_result" + schemaVersionImpact = "impact-result.v1" + schemaRefImpact = "contract/schema/impact-result.v1.schema.json" + jsonIndentPrefix = "" + jsonIndentValue = " " + routeNodeKind = "route" +) + +const ( + flagJSON = "json" + flagPretty = "pretty" +) + +const ( + flagHelpJSON = "emit machine-readable impact result" + flagHelpPretty = "pretty-print JSON output" +) diff --git a/cmd/nucleus/internal/impact/output.go b/cmd/nucleus/internal/impact/output.go new file mode 100644 index 0000000..8602866 --- /dev/null +++ b/cmd/nucleus/internal/impact/output.go @@ -0,0 +1,107 @@ +package impact + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" +) + +type result struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Query query `json:"query"` + Summary summary `json:"summary"` + Target *inspect.SymbolNode `json:"target,omitempty"` + AffectedSymbols []inspect.SymbolNode `json:"affected_symbols"` + AffectedFiles []string `json:"affected_files"` + AffectedTests []inspect.SymbolNode `json:"affected_tests"` + AffectedRoutes []inspect.FlowNode `json:"affected_routes"` + Edges []inspect.SymbolEdge `json:"edges"` + FlowEdges []inspect.FlowEdge `json:"flow_edges"` + Candidates []inspect.SymbolNode `json:"candidates,omitempty"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +type query struct { + Kind string `json:"kind"` + Value string `json:"value"` + ResolvedID string `json:"resolved_id,omitempty"` +} + +type summary struct { + Symbols int `json:"symbols"` + Files int `json:"files"` + Tests int `json:"tests"` + Routes int `json:"routes"` + Edges int `json:"edges"` +} + +func renderJSON(writer io.Writer, output result, pretty bool) error { + output = normalizeResult(output) + encoder := json.NewEncoder(writer) + if pretty { + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + } + return encoder.Encode(output) +} + +func renderHuman(writer io.Writer, output result) { + output = normalizeResult(output) + if output.OK { + _, _ = fmt.Fprintln(writer, "OK impact") + } else { + _, _ = fmt.Fprintln(writer, "FAILED impact") + } + _, _ = fmt.Fprintf(writer, "query: %s %s\n", output.Query.Kind, output.Query.Value) + _, _ = fmt.Fprintf(writer, "symbols: %d\n", len(output.AffectedSymbols)) + _, _ = fmt.Fprintf(writer, "files: %d\n", len(output.AffectedFiles)) + _, _ = fmt.Fprintf(writer, "tests: %d\n", len(output.AffectedTests)) + _, _ = fmt.Fprintf(writer, "routes: %d\n", len(output.AffectedRoutes)) + for _, item := range output.Diagnostics { + _, _ = fmt.Fprintf(writer, " - %s %s: %s\n", item.Severity, item.Code, item.Message) + } +} + +func normalizeResult(output result) result { + if output.AffectedSymbols == nil { + output.AffectedSymbols = []inspect.SymbolNode{} + } + if output.AffectedFiles == nil { + output.AffectedFiles = []string{} + } + if output.AffectedTests == nil { + output.AffectedTests = []inspect.SymbolNode{} + } + if output.AffectedRoutes == nil { + output.AffectedRoutes = []inspect.FlowNode{} + } + if output.Edges == nil { + output.Edges = []inspect.SymbolEdge{} + } + if output.FlowEdges == nil { + output.FlowEdges = []inspect.FlowEdge{} + } + if output.Candidates == nil { + output.Candidates = []inspect.SymbolNode{} + } + if output.Diagnostics == nil { + output.Diagnostics = diagnostic.Diagnostics{} + } + output.ResultKind = resultKindImpact + output.SchemaVersion = schemaVersionImpact + output.SchemaRef = schemaRefImpact + output.OK = !output.Diagnostics.Failed() + output.Summary = summary{ + Symbols: len(output.AffectedSymbols), + Files: len(output.AffectedFiles), + Tests: len(output.AffectedTests), + Routes: len(output.AffectedRoutes), + Edges: len(output.Edges) + len(output.FlowEdges), + } + return output +} diff --git a/cmd/nucleus/internal/impact/run.go b/cmd/nucleus/internal/impact/run.go new file mode 100644 index 0000000..acd3a42 --- /dev/null +++ b/cmd/nucleus/internal/impact/run.go @@ -0,0 +1,170 @@ +package impact + +import ( + "sort" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/graphquery" +) + +func impactSymbol(config Config, value string) result { + dir := stringValue(config.Dir, defaultDir) + description, err := inspect.Describe(dir) + if err != nil { + return normalizeResult(result{Query: query{Kind: "symbol", Value: value}, Diagnostics: diagnostic.Diagnostics{errorDiagnostic("impact.describe_failed", err.Error())}}) + } + resolved := graphquery.ResolveSymbol(description.SymbolGraph, value) + if !resolved.OK { + return normalizeResult(result{Query: query{Kind: "symbol", Value: value}, Candidates: resolved.Candidates, Diagnostics: resolved.Diagnostic}) + } + return impactResolvedSymbol(description.SymbolGraph, resolved.Node, query{Kind: "symbol", Value: value, ResolvedID: resolved.Node.ID}) +} + +// SymbolForMCP returns the same structured impact result used by the CLI. +func SymbolForMCP(dir string, value string) any { + return impactSymbol(Config{Dir: &dir}, value) +} + +func impactFile(config Config, value string) result { + dir := stringValue(config.Dir, defaultDir) + description, err := inspect.Describe(dir) + if err != nil { + return normalizeResult(result{Query: query{Kind: "file", Value: value}, Diagnostics: diagnostic.Diagnostics{errorDiagnostic("impact.describe_failed", err.Error())}}) + } + fileNodes := graphquery.NodeByFile(description.SymbolGraph, value) + if len(fileNodes) == 0 { + return normalizeResult(result{Query: query{Kind: "file", Value: value}, Diagnostics: diagnostic.Diagnostics{errorDiagnostic("impact.file_not_found", "file was not found in symbol graph")}}) + } + edges := append([]inspect.SymbolEdge{}, graphquery.OutgoingEdges(description.SymbolGraph, fileNodes[0].ID, "declares")...) + var symbols []inspect.SymbolNode + for _, edge := range edges { + symbols = append(symbols, graphquery.NodesForEdges(description.SymbolGraph, []inspect.SymbolEdge{edge})...) + } + expandedEdges := append([]inspect.SymbolEdge{}, edges...) + for _, node := range symbols { + if node.ID == fileNodes[0].ID { + continue + } + expandedEdges = append(expandedEdges, symbolImpactEdges(description.SymbolGraph, node.ID)...) + } + affected := graphquery.NodesForEdges(description.SymbolGraph, expandedEdges, fileNodes[0].ID) + tests := testNodes(affected) + return normalizeResult(result{ + Query: query{Kind: "file", Value: value, ResolvedID: fileNodes[0].ID}, + AffectedSymbols: affected, + AffectedFiles: graphquery.FilesForNodes(affected), + AffectedTests: tests, + Edges: uniqueSymbolEdges(expandedEdges), + }) +} + +// FileForMCP returns the same structured file impact result used by the CLI. +func FileForMCP(dir string, value string) any { + return impactFile(Config{Dir: &dir}, value) +} + +func impactContract(config Config, value string) result { + dir := stringValue(config.Dir, defaultDir) + graph, err := inspect.BuildFlowGraphFromDir(dir) + if err != nil { + return normalizeResult(result{Query: query{Kind: "contract", Value: value}, Diagnostics: diagnostic.Diagnostics{errorDiagnostic("impact.flow_graph_failed", err.Error())}}) + } + routes := routeNodes(graph) + edges := graph.Edges + if len(routes) == 0 { + return normalizeResult(result{Query: query{Kind: "contract", Value: value}, AffectedRoutes: []inspect.FlowNode{}, FlowEdges: []inspect.FlowEdge{}, Diagnostics: diagnostic.Diagnostics{warningDiagnostic("impact.contract_no_routes", "contract produced no route facts")}}) + } + return normalizeResult(result{ + Query: query{Kind: "contract", Value: value}, + AffectedFiles: []string{normalizePath(value)}, + AffectedRoutes: routes, + FlowEdges: edges, + }) +} + +// ContractForMCP returns the same structured contract impact result used by the CLI. +func ContractForMCP(dir string, value string) any { + return impactContract(Config{Dir: &dir}, value) +} + +func impactResolvedSymbol(graph inspect.SymbolGraph, target inspect.SymbolNode, q query) result { + edges := symbolImpactEdges(graph, target.ID) + nodes := graphquery.NodesForEdges(graph, edges, target.ID) + tests := testNodes(nodes) + return normalizeResult(result{ + Query: q, + Target: &target, + AffectedSymbols: nodes, + AffectedFiles: graphquery.FilesForNodes(nodes), + AffectedTests: tests, + Edges: uniqueSymbolEdges(edges), + }) +} + +func symbolImpactEdges(graph inspect.SymbolGraph, id string) []inspect.SymbolEdge { + var edges []inspect.SymbolEdge + edges = append(edges, graphquery.IncomingEdges(graph, id, graphquery.EdgeKindCalls, graphquery.EdgeKindTests)...) + edges = append(edges, graphquery.OutgoingEdges(graph, id, graphquery.EdgeKindCalls)...) + edges = append(edges, graphquery.IncomingEdges(graph, id, "implements")...) + edges = append(edges, graphquery.OutgoingEdges(graph, id, "implements")...) + return uniqueSymbolEdges(edges) +} + +func uniqueSymbolEdges(edges []inspect.SymbolEdge) []inspect.SymbolEdge { + seen := map[string]bool{} + var result []inspect.SymbolEdge + for _, edge := range edges { + key := edge.From + "\x00" + edge.To + "\x00" + edge.Kind + if seen[key] { + continue + } + seen[key] = true + result = append(result, edge) + } + sort.Slice(result, func(i, j int) bool { + if result[i].From == result[j].From { + if result[i].Kind == result[j].Kind { + return result[i].To < result[j].To + } + return result[i].Kind < result[j].Kind + } + return result[i].From < result[j].From + }) + return result +} + +func testNodes(nodes []inspect.SymbolNode) []inspect.SymbolNode { + var result []inspect.SymbolNode + for _, node := range nodes { + if node.Kind == "function" && strings.HasPrefix(node.Name, "Test") { + result = append(result, node) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func routeNodes(graph inspect.FlowGraph) []inspect.FlowNode { + var result []inspect.FlowNode + for _, node := range graph.Nodes { + if node.Kind == routeNodeKind { + result = append(result, node) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func normalizePath(value string) string { + return strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "./") +} + +func errorDiagnostic(code string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: code, Message: message} +} + +func warningDiagnostic(code string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{Severity: diagnostic.SeverityWarning, Code: code, Message: message} +} diff --git a/cmd/nucleus/internal/initcmd/agent.go b/cmd/nucleus/internal/initcmd/agent.go deleted file mode 100644 index 064bcb3..0000000 --- a/cmd/nucleus/internal/initcmd/agent.go +++ /dev/null @@ -1,86 +0,0 @@ -package initcmd - -import ( - "os" - "path/filepath" -) - -func initAgentTemplateFiles(templateType string, agent string) (map[string]string, error) { - switch agent { - case "", agentCodex: - agents, err := readAgentTemplate("AGENTS.md") - if err != nil { - agents = codexAgentsFallback(templateType) - } - return map[string]string{"AGENTS.md": agents}, nil - default: - return nil, nil - } -} - -func readAgentTemplate(name string) (string, error) { - for _, dir := range agentTemplateSearchDirs() { - data, err := os.ReadFile(filepath.Join(dir, "template", "agent", "codex", name)) - if err == nil { - return string(data), nil - } - if err != nil && !os.IsNotExist(err) { - return "", err - } - } - return "", os.ErrNotExist -} - -func agentTemplateSearchDirs() []string { - var dirs []string - if cwd, err := os.Getwd(); err == nil { - for dir := cwd; ; dir = filepath.Dir(dir) { - dirs = append(dirs, dir) - next := filepath.Dir(dir) - if next == dir { - break - } - } - } - return dirs -} - -func codexAgentsFallback(templateType string) string { - dirs := topLevelDirectories(templateType) - text := `# AGENTS.md - -This repository is generated by Nucleus. Keep changes contract-first, manifest-first, and easy to verify. - -## Communication - -- Communicate with the project owner in their preferred language. -- Keep public code comments, exported API docs, README files, and commit messages in clear English unless the file already uses another language. - -## Change Loop - -- Inspect service facts with ` + "`nucleus describe --dir . --json`" + ` before editing behavior. -- Plan safe edits with ` + "`nucleus plan --dir . --task \"\"`" + ` before broad changes. -- Regenerate contract artifacts with ` + "`nucleus gen --dir .`" + ` after contract changes. -- Before handoff, run ` + "`nucleus verify --dir . --json`" + `. - -## Top-Level Directories -` - if len(dirs) == 0 { - return text + "\nNo top-level directories are generated for this template.\n" - } - for _, dir := range dirs { - text += "\n- `" + dir + "`" - } - return text + "\n" -} - -func topLevelDirectories(templateType string) []string { - switch templateType { - case templateService: - return []string{"api", "cmd", "configs", "contract", "deploy", "docs", "internal", "test"} - case templateWorker: - return []string{"cmd", "internal"} - default: - return nil - } -} diff --git a/cmd/nucleus/internal/initcmd/command.go b/cmd/nucleus/internal/initcmd/command.go deleted file mode 100644 index 2b98b8e..0000000 --- a/cmd/nucleus/internal/initcmd/command.go +++ /dev/null @@ -1,67 +0,0 @@ -package initcmd - -import ( - "errors" - - "github.com/spf13/cobra" -) - -// Config carries root-level flag values used by the init command. -type Config struct { - Dir *string -} - -type options struct { - name string - module string - template string - agent string - json bool - human bool - pretty bool -} - -// ErrInitFailed is returned when initialization cannot produce a valid project. -var ErrInitFailed = errors.New("init failed") - -// NewCommand creates the init subcommand. -func NewCommand(config Config) *cobra.Command { - opts := &options{template: defaultTemplate} - cmd := &cobra.Command{ - Use: commandUseInit, - Short: commandShortInit, - SilenceUsage: true, - SilenceErrors: true, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - result, err := run(config, opts) - if opts.human && err != nil { - renderError(cmd.ErrOrStderr(), err) - return err - } - if opts.human { - renderHuman(cmd.OutOrStdout(), result) - return err - } - if renderErr := renderJSON(cmd.OutOrStdout(), result, opts.pretty); renderErr != nil { - return renderErr - } - return err - }, - } - cmd.Flags().StringVar(&opts.name, flagName, "", flagHelpName) - cmd.Flags().StringVar(&opts.module, flagModule, "", flagHelpModule) - cmd.Flags().StringVar(&opts.template, flagTemplate, defaultTemplate, flagHelpTemplate) - cmd.Flags().StringVar(&opts.agent, flagAgent, "", flagHelpAgent) - cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) - cmd.Flags().BoolVar(&opts.human, flagHuman, false, flagHelpHuman) - cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) - return cmd -} - -func stringValue(value *string, fallback string) string { - if value == nil { - return fallback - } - return *value -} diff --git a/cmd/nucleus/internal/initcmd/command_test.go b/cmd/nucleus/internal/initcmd/command_test.go deleted file mode 100644 index 0f7f01e..0000000 --- a/cmd/nucleus/internal/initcmd/command_test.go +++ /dev/null @@ -1,284 +0,0 @@ -package initcmd - -import ( - "bytes" - "encoding/json" - "os" - "os/exec" - "path/filepath" - "strings" - "testing" - - contractlint "github.com/nucleuskit/contract/lint" - "github.com/nucleuskit/contract/validation" -) - -func TestCommandServiceTemplateProducesVerifiableProject(t *testing.T) { - dir := t.TempDir() - - output := executeInitCommand(t, dir, "--name", "demo", "--module", "example.com/demo", "--template", "service") - if output.ResultKind != resultKindInit || !output.OK || output.Template != "service" { - t.Fatalf("unexpected output: %#v", output) - } - assertContains(t, output.Files, "nucleus.yaml") - assertContains(t, output.Files, "contract/gen/.nucleus-source.sha256") - assertContains(t, output.Files, "internal/adapter/http/gen/.nucleus-source.sha256") - assertFileContains(t, dir, "go.mod", "github.com/nucleuskit/http v0.1.0-alpha.2") - assertFileContains(t, dir, "go.mod", "github.com/nucleuskit/cap v0.1.0-alpha.2 // indirect") - assertFileContains(t, dir, "go.mod", "github.com/nucleuskit/core v0.1.0-alpha.2 // indirect") - assertFileNotContains(t, dir, "go.mod", "replace ") - assertFileNotContains(t, dir, "api/errors.yaml", "code: 0") - assertFileContains(t, dir, "internal/adapter/http/gen/routes.gen.go", `runtimehttp "github.com/nucleuskit/http"`) - assertValidationClean(t, dir) - assertStrictLintClean(t, dir) - runGoTest(t, dir) -} - -func TestCommandWorkerTemplateProducesVerifiableProject(t *testing.T) { - dir := t.TempDir() - - output := executeInitCommand(t, dir, "--name", "jobs", "--module", "example.com/jobs", "--template", "worker", "--json") - if output.ResultKind != resultKindInit || !output.OK || output.Template != "worker" { - t.Fatalf("unexpected output: %#v", output) - } - assertContains(t, output.Files, "cmd/jobs/main.go") - assertContains(t, output.Files, "internal/worker/handler.go") - assertFileNotExists(t, dir, "go.sum") - assertFileNotExists(t, dir, "api/openapi.yaml") - assertFileNotExists(t, dir, "api/errors.yaml") - assertFileContains(t, dir, "nucleus.yaml", "provider: local") - assertValidationClean(t, dir) - assertStrictLintClean(t, dir) - runGoTest(t, dir) -} - -func TestCommandLibraryTemplateProducesVerifiableProject(t *testing.T) { - dir := t.TempDir() - - output := executeInitCommand(t, dir, "--name", "util-lib", "--module", "example.com/util-lib", "--template", "library", "--json") - if output.ResultKind != resultKindInit || !output.OK || output.Template != "library" { - t.Fatalf("unexpected output: %#v", output) - } - assertContains(t, output.Files, "util_lib.go") - assertFileNotExists(t, dir, "api/openapi.yaml") - assertFileNotExists(t, dir, "api/errors.yaml") - assertFileContains(t, dir, "nucleus.yaml", "capabilities: []") - assertValidationClean(t, dir) - assertStrictLintClean(t, dir) - runGoTest(t, dir) -} - -func TestCommandRejectsUnsafeInputs(t *testing.T) { - dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "README.md"), []byte("occupied\n"), 0o600); err != nil { - t.Fatal(err) - } - - cmd := NewCommand(Config{Dir: &dir}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--name", "demo", "--module", "example.com/demo"}) - if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "target directory is not empty") { - t.Fatalf("execute error = %v, want non-empty directory failure", err) - } - - empty := t.TempDir() - cmd = NewCommand(Config{Dir: &empty}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--name", "../demo", "--module", "example.com/demo"}) - if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "service name") { - t.Fatalf("execute error = %v, want service name validation failure", err) - } - - for _, module := range []string{ - "example.com/demo@v1", - "example.com/demo\"bad", - "example.com//demo", - "example.com/../demo", - "example.com/demo!", - } { - empty := t.TempDir() - cmd = NewCommand(Config{Dir: &empty}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--name", "demo", "--module", module}) - if err := cmd.Execute(); err == nil || !strings.Contains(err.Error(), "module path") { - t.Fatalf("execute with module %q error = %v, want module path validation failure", module, err) - } - } -} - -func TestCommandDefaultFailureWritesStructuredJSON(t *testing.T) { - dir := t.TempDir() - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs([]string{"--template", "service"}) - - err := cmd.Execute() - if err == nil || !strings.Contains(err.Error(), "--name is required") { - t.Fatalf("execute error = %v, want missing name", err) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty for structured output", stderr.String()) - } - var output initCommandOutput - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) - } - if !strings.Contains(stdout.String(), `"files":[]`) { - t.Fatalf("stdout = %q, want empty files array", stdout.String()) - } - if output.OK { - t.Fatalf("ok = true, want false: %#v", output) - } - if output.ResultKind != resultKindInit { - t.Fatalf("result_kind = %q, want %q", output.ResultKind, resultKindInit) - } - if len(output.Diagnostics) != 1 || output.Diagnostics[0].Code != "init.name_required" { - t.Fatalf("diagnostics = %#v, want init.name_required", output.Diagnostics) - } -} - -func TestCommandHumanFailureWritesError(t *testing.T) { - dir := t.TempDir() - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs([]string{"--template", "service", "--human"}) - - err := cmd.Execute() - if err == nil || !strings.Contains(err.Error(), "--name is required") { - t.Fatalf("execute error = %v, want missing name", err) - } - if stdout.Len() != 0 { - t.Fatalf("stdout = %q, want empty", stdout.String()) - } - for _, want := range []string{"FAILED", "--name is required"} { - if !strings.Contains(stderr.String(), want) { - t.Fatalf("stderr = %q, want %q", stderr.String(), want) - } - } -} - -func TestCommandPrettyJSONOutput(t *testing.T) { - dir := t.TempDir() - - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--name", "demo", "--module", "example.com/demo", "--pretty"}) - if err := cmd.Execute(); err != nil { - t.Fatalf("execute init: %v", err) - } - if !strings.Contains(stdout.String(), "\n \"result_kind\"") { - t.Fatalf("stdout = %q, want indented JSON", stdout.String()) - } -} - -type initCommandOutput struct { - ResultKind string `json:"result_kind"` - OK bool `json:"ok"` - Template string `json:"template"` - ServiceName string `json:"service_name"` - Module string `json:"module"` - Files []string - Generated []string `json:"generated,omitempty"` - Diagnostics []struct { - Severity string `json:"severity"` - Code string `json:"code"` - Message string `json:"message"` - } `json:"diagnostics,omitempty"` -} - -func executeInitCommand(t *testing.T, dir string, args ...string) initCommandOutput { - t.Helper() - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs(args) - if err := cmd.Execute(); err != nil { - t.Fatalf("execute init: %v\nstderr=%s\nstdout=%s", err, stderr.String(), stdout.String()) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } - var output initCommandOutput - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) - } - return output -} - -func assertValidationClean(t *testing.T, dir string) { - t.Helper() - diagnostics := validation.ValidateService(dir) - if diagnostics.Failed() { - t.Fatalf("validation diagnostics = %#v", diagnostics) - } -} - -func assertStrictLintClean(t *testing.T, dir string) { - t.Helper() - findings := contractlint.Run(dir, true) - if len(findings) != 0 { - t.Fatalf("strict lint findings = %#v", findings) - } -} - -func runGoTest(t *testing.T, dir string) { - t.Helper() - cmd := exec.Command("go", "test", "./...") - cmd.Dir = dir - output, err := cmd.CombinedOutput() - if err != nil { - t.Fatalf("go test ./... failed: %v\n%s", err, string(output)) - } -} - -func assertContains(t *testing.T, values []string, want string) { - t.Helper() - for _, value := range values { - if value == want { - return - } - } - t.Fatalf("values = %#v, want %q", values, want) -} - -func assertFileContains(t *testing.T, dir string, name string, want string) { - t.Helper() - data, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - t.Fatal(err) - } - if !strings.Contains(string(data), want) { - t.Fatalf("%s = %q, want %q", name, string(data), want) - } -} - -func assertFileNotContains(t *testing.T, dir string, name string, unwanted string) { - t.Helper() - data, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - t.Fatal(err) - } - if strings.Contains(string(data), unwanted) { - t.Fatalf("%s = %q, did not want %q", name, string(data), unwanted) - } -} - -func assertFileNotExists(t *testing.T, dir string, name string) { - t.Helper() - if _, err := os.Stat(filepath.Join(dir, name)); err == nil || !os.IsNotExist(err) { - t.Fatalf("%s exists or stat failed with %v, want not exist", name, err) - } -} diff --git a/cmd/nucleus/internal/initcmd/constants.go b/cmd/nucleus/internal/initcmd/constants.go deleted file mode 100644 index 899975e..0000000 --- a/cmd/nucleus/internal/initcmd/constants.go +++ /dev/null @@ -1,46 +0,0 @@ -package initcmd - -const ( - commandUseInit = "init" - commandShortInit = "initialize a Nucleus service, worker, or library template" - - defaultDir = "." - defaultTemplate = "service" - - flagName = "name" - flagModule = "module" - flagTemplate = "template" - flagAgent = "agent" - flagJSON = "json" - flagHuman = "human" - flagPretty = "pretty" - - flagHelpName = "service name" - flagHelpModule = "Go module path" - flagHelpTemplate = "template type: service, worker, or library" - flagHelpAgent = "agent pack to generate: codex" - flagHelpJSON = "emit JSON output (default; kept for compatibility)" - flagHelpHuman = "emit human-readable output" - flagHelpPretty = "pretty-print JSON output" - - templateService = "service" - templateWorker = "worker" - templateLibrary = "library" - - agentCodex = "codex" - - resultKindInit = "nucleus.init_result" - - nucleusHTTPModule = "github.com/nucleuskit/http" - nucleusHTTPVersion = "v0.1.0-alpha.2" - nucleusCapModule = "github.com/nucleuskit/cap" - nucleusCapVersion = "v0.1.0-alpha.2" - nucleusCoreModule = "github.com/nucleuskit/core" - nucleusCoreVersion = "v0.1.0-alpha.2" - contractGenTarget = "contract/gen" - httpAdapterGenTarget = "internal/adapter/http/gen" - generatedFreshnessFile = ".nucleus-source.sha256" - defaultServiceVersion = "0.1.0" - defaultGoVersion = "1.26.3" - defaultHTTPAddress = "127.0.0.1:8080" -) diff --git a/cmd/nucleus/internal/initcmd/doc.go b/cmd/nucleus/internal/initcmd/doc.go deleted file mode 100644 index 5d9e610..0000000 --- a/cmd/nucleus/internal/initcmd/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package initcmd implements the nucleus init CLI subcommand. -// -// The package owns template assembly and CLI output while reusing the public -// contract generator for contract-derived artifacts. -package initcmd diff --git a/cmd/nucleus/internal/initcmd/output.go b/cmd/nucleus/internal/initcmd/output.go deleted file mode 100644 index 72cbd3e..0000000 --- a/cmd/nucleus/internal/initcmd/output.go +++ /dev/null @@ -1,68 +0,0 @@ -package initcmd - -import ( - "encoding/json" - "fmt" - "io" - - "github.com/nucleuskit/contract/diagnostic" -) - -type initResult struct { - ResultKind string `json:"result_kind"` - OK bool `json:"ok"` - Template string `json:"template,omitempty"` - ServiceName string `json:"service_name,omitempty"` - Module string `json:"module,omitempty"` - Summary initSummary `json:"summary"` - Files []string `json:"files"` - Generated []string `json:"generated,omitempty"` - Diagnostics diagnostic.Diagnostics `json:"diagnostics"` -} - -type initSummary struct { - Files int `json:"files"` - Generated int `json:"generated"` - Errors int `json:"errors"` - Warnings int `json:"warnings"` -} - -func renderJSON(writer io.Writer, result initResult, pretty bool) error { - result = normalizeResult(result) - encoder := json.NewEncoder(writer) - if pretty { - encoder.SetIndent("", " ") - } - return encoder.Encode(result) -} - -func normalizeResult(result initResult) initResult { - if result.Files == nil { - result.Files = []string{} - } - if result.Diagnostics == nil { - result.Diagnostics = diagnostic.Diagnostics{} - } - return result -} - -func renderHuman(writer io.Writer, result initResult) { - _, _ = fmt.Fprintf(writer, "OK initialized %s %s\n", result.Template, result.ServiceName) - _, _ = fmt.Fprintf(writer, "module: %s\n", result.Module) - _, _ = fmt.Fprintln(writer, "files:") - for _, file := range result.Files { - _, _ = fmt.Fprintf(writer, " - %s\n", file) - } - if len(result.Generated) == 0 { - return - } - _, _ = fmt.Fprintln(writer, "generated:") - for _, target := range result.Generated { - _, _ = fmt.Fprintf(writer, " - %s\n", target) - } -} - -func renderError(writer io.Writer, err error) { - _, _ = fmt.Fprintln(writer, "FAILED") - _, _ = fmt.Fprintf(writer, "error: %v\n", err) -} diff --git a/cmd/nucleus/internal/initcmd/run.go b/cmd/nucleus/internal/initcmd/run.go deleted file mode 100644 index 2490870..0000000 --- a/cmd/nucleus/internal/initcmd/run.go +++ /dev/null @@ -1,234 +0,0 @@ -package initcmd - -import ( - "fmt" - "os" - "path/filepath" - "regexp" - "sort" - "strings" - - "github.com/nucleuskit/contract/diagnostic" - contractgen "github.com/nucleuskit/contract/gen" -) - -var ( - serviceNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]*$`) - modulePathPattern = regexp.MustCompile(`^[a-z0-9][a-z0-9._~-]*(/[a-z0-9][a-z0-9._~-]*)+$`) -) - -func run(config Config, opts *options) (initResult, error) { - normalized, err := normalizeOptions(opts) - if err != nil { - return failedResult(opts, optionDiagnostic(err)), err - } - dir := stringValue(config.Dir, defaultDir) - if err := ensureEmptyDir(dir); err != nil { - wrapped := fmt.Errorf("%w: %v", ErrInitFailed, err) - return failedResult(normalized, errorDiagnostic("init.target_unavailable", err.Error())), wrapped - } - - files, generated, err := templateFiles(normalized.template, normalized.name, normalized.module) - if err != nil { - wrapped := fmt.Errorf("%w: %v", ErrInitFailed, err) - return failedResult(normalized, errorDiagnostic("init.template_failed", err.Error())), wrapped - } - agentFiles, err := initAgentTemplateFiles(normalized.template, normalized.agent) - if err != nil { - wrapped := fmt.Errorf("%w: %v", ErrInitFailed, err) - return failedResult(normalized, errorDiagnostic("init.agent_failed", err.Error())), wrapped - } - for name, data := range agentFiles { - files[name] = data - } - - written := make([]string, 0, len(files)) - for _, name := range sortedMapKeys(files) { - if err := writeTemplateFile(filepath.Join(dir, filepath.FromSlash(name)), files[name]); err != nil { - wrapped := fmt.Errorf("%w: %v", ErrInitFailed, err) - return failedResult(normalized, errorDiagnostic("init.write_failed", err.Error())), wrapped - } - written = append(written, name) - } - - if normalized.template == templateService { - result, err := contractgen.GenerateWithOptions(dir, contractgen.Options{HTTP: true, Errors: true}) - if err != nil { - wrapped := fmt.Errorf("%w: %v", ErrInitFailed, err) - return failedResult(normalized, errorDiagnostic("init.generate_failed", err.Error())), wrapped - } - for _, file := range result.Files { - written = append(written, relativeFile(dir, file)) - } - } - - written = appendUniqueStrings(nil, written...) - sort.Strings(written) - sort.Strings(generated) - return initResult{ - ResultKind: resultKindInit, - OK: true, - Template: normalized.template, - ServiceName: normalized.name, - Module: normalized.module, - Summary: initSummary{ - Files: len(written), - Generated: len(generated), - }, - Files: written, - Generated: generated, - Diagnostics: diagnostic.Diagnostics{}, - }, nil -} - -func normalizeOptions(opts *options) (*options, error) { - normalized := *opts - normalized.name = strings.TrimSpace(normalized.name) - normalized.module = strings.TrimSpace(normalized.module) - normalized.template = strings.TrimSpace(normalized.template) - normalized.agent = strings.TrimSpace(normalized.agent) - if normalized.template == "" { - normalized.template = defaultTemplate - } - if normalized.name == "" { - return nil, fmt.Errorf("--%s is required", flagName) - } - if !serviceNamePattern.MatchString(normalized.name) { - return nil, fmt.Errorf("service name must match %s", serviceNamePattern.String()) - } - if normalized.module == "" { - return nil, fmt.Errorf("--%s is required", flagModule) - } - if invalidModulePath(normalized.module) { - return nil, fmt.Errorf("module path must be a slash-separated import path") - } - switch normalized.template { - case templateService, templateWorker, templateLibrary: - default: - return nil, fmt.Errorf("unknown template %q", normalized.template) - } - switch normalized.agent { - case "", agentCodex: - default: - return nil, fmt.Errorf("unknown agent %q", normalized.agent) - } - return &normalized, nil -} - -func invalidModulePath(module string) bool { - if !modulePathPattern.MatchString(module) { - return true - } - first, _, _ := strings.Cut(module, "/") - if !strings.Contains(first, ".") { - return true - } - return false -} - -func failedResult(opts *options, diagnostics diagnostic.Diagnostics) initResult { - if opts == nil { - return initResult{ResultKind: resultKindInit, OK: false, Summary: summaryForDiagnostics(diagnostics), Files: []string{}, Diagnostics: diagnostics} - } - return initResult{ - ResultKind: resultKindInit, - OK: false, - Template: opts.template, - ServiceName: opts.name, - Module: opts.module, - Summary: summaryForDiagnostics(diagnostics), - Files: []string{}, - Diagnostics: diagnostics, - } -} - -func optionDiagnostic(err error) diagnostic.Diagnostics { - message := err.Error() - code := "init.invalid_options" - switch { - case strings.Contains(message, "--"+flagName): - code = "init.name_required" - case strings.Contains(message, "service name"): - code = "init.name_invalid" - case strings.Contains(message, "--"+flagModule): - code = "init.module_required" - case strings.Contains(message, "module path"): - code = "init.module_invalid" - case strings.Contains(message, "unknown template"): - code = "init.template_unknown" - case strings.Contains(message, "unknown agent"): - code = "init.agent_unknown" - } - return errorDiagnostic(code, message) -} - -func errorDiagnostic(code string, message string) diagnostic.Diagnostics { - return diagnostic.Diagnostics{{ - Severity: diagnostic.SeverityError, - Code: code, - Message: message, - }} -} - -func summaryForDiagnostics(diagnostics diagnostic.Diagnostics) initSummary { - return initSummary{ - Errors: diagnostics.Count(diagnostic.SeverityError), - Warnings: diagnostics.Count(diagnostic.SeverityWarning), - } -} - -func ensureEmptyDir(dir string) error { - entries, err := os.ReadDir(dir) - if os.IsNotExist(err) { - return os.MkdirAll(dir, 0o755) - } - if err != nil { - return err - } - if len(entries) > 0 { - return fmt.Errorf("target directory is not empty: %s", dir) - } - return nil -} - -func writeTemplateFile(path string, data string) error { - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - return os.WriteFile(path, []byte(data), 0o644) -} - -func sortedMapKeys(values map[string]string) []string { - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - -func relativeFile(dir string, path string) string { - rel, err := filepath.Rel(dir, path) - if err != nil { - return filepath.ToSlash(path) - } - return filepath.ToSlash(rel) -} - -func appendUniqueStrings(values []string, next ...string) []string { - seen := map[string]struct{}{} - for _, value := range values { - seen[value] = struct{}{} - } - for _, value := range next { - if value == "" { - continue - } - if _, ok := seen[value]; ok { - continue - } - values = append(values, value) - seen[value] = struct{}{} - } - return values -} diff --git a/cmd/nucleus/internal/initcmd/templates.go b/cmd/nucleus/internal/initcmd/templates.go deleted file mode 100644 index 5ea2cc0..0000000 --- a/cmd/nucleus/internal/initcmd/templates.go +++ /dev/null @@ -1,743 +0,0 @@ -package initcmd - -import ( - "fmt" - "path/filepath" - "strings" -) - -func templateFiles(templateType string, name string, module string) (map[string]string, []string, error) { - switch templateType { - case templateService: - return serviceTemplateFiles(name, module), []string{contractGenTarget, httpAdapterGenTarget}, nil - case templateWorker: - return workerTemplateFiles(name, module), nil, nil - case templateLibrary: - return libraryTemplateFiles(name, module), nil, nil - default: - return nil, nil, fmt.Errorf("unknown template %q", templateType) - } -} - -func serviceTemplateFiles(name string, module string) map[string]string { - runtimeRequirements := []moduleRequirement{ - {Path: nucleusHTTPModule, Version: nucleusHTTPVersion}, - {Path: nucleusCapModule, Version: nucleusCapVersion, Indirect: true}, - {Path: nucleusCoreModule, Version: nucleusCoreVersion, Indirect: true}, - } - return map[string]string{ - "go.mod": goModTemplate(module, runtimeRequirements), - "go.sum": goSumTemplate([]string{nucleusHTTPModule, nucleusCapModule, nucleusCoreModule}), - "nucleus.yaml": serviceManifestTemplate(name), - "api/openapi.yaml": openAPITemplate(name), - "api/errors.yaml": serviceErrorsTemplate(), - "configs/config.example.yaml": serviceConfigExampleTemplate(), - "configs/README.md": serviceConfigReadmeTemplate(), - "docs/architecture.md": serviceArchitectureDocTemplate(name), - "docs/api.md": serviceAPIDocTemplate(), - "deploy/Dockerfile": serviceDockerfileTemplate(name), - "test/integration/README.md": serviceIntegrationReadmeTemplate(), - "Makefile": serviceMakefileTemplate(name), - filepath.ToSlash(filepath.Join("cmd", name, "main.go")): serviceMainTemplate(module), - "internal/app/app.go": serviceAppTemplate(module), - "internal/app/providers.go": serviceProvidersTemplate(module), - "internal/app/routes.go": serviceRoutesTemplate(module), - "internal/config/config.go": serviceConfigTemplate(), - "internal/config/loader.go": serviceConfigLoaderTemplate(), - "internal/config/validate.go": serviceConfigValidateTemplate(), - "internal/domain/health/model.go": serviceDomainHealthModelTemplate(), - "internal/domain/health/service.go": serviceDomainHealthServiceTemplate(), - "internal/domain/health/repository.go": serviceDomainHealthRepositoryTemplate(), - "internal/domain/health/errors.go": serviceDomainHealthErrorsTemplate(), - "internal/domain/health/events.go": serviceDomainHealthEventsTemplate(), - "internal/usecase/health/usecase.go": serviceUsecaseHealthTemplate(module), - "internal/adapter/http/handler.go": serviceHTTPHandlerTemplate(module), - "internal/adapter/http/mapper.go": serviceHTTPMapperTemplate(), - "internal/adapter/http/validation.go": serviceHTTPValidationTemplate(), - "internal/adapter/http/errors.go": serviceHTTPErrorsTemplate(), - "internal/adapter/store/memory/repository.go": serviceMemoryRepositoryTemplate(module), - "internal/component/clock/component.go": serviceClockComponentTemplate(), - "internal/server/http.go": serviceHTTPServerTemplate(), - } -} - -func workerTemplateFiles(name string, module string) map[string]string { - return map[string]string{ - "go.mod": goModTemplate(module, nil), - "nucleus.yaml": workerManifestTemplate(name), - filepath.ToSlash(filepath.Join("cmd", name, "main.go")): workerMainTemplate(module), - "internal/worker/handler.go": workerHandlerTemplate(), - } -} - -func libraryTemplateFiles(name string, module string) map[string]string { - packageName := packageNameFromServiceName(name) - return map[string]string{ - "go.mod": goModTemplate(module, nil), - "nucleus.yaml": libraryManifestTemplate(name), - packageName + ".go": libraryTemplate(packageName, name), - } -} - -type moduleRequirement struct { - Path string - Version string - Indirect bool -} - -func goModTemplate(module string, requires []moduleRequirement) string { - var builder strings.Builder - builder.WriteString("module " + module + "\n\n") - builder.WriteString("go " + defaultGoVersion + "\n") - if len(requires) == 0 { - return builder.String() - } - if len(requires) == 1 { - item := requires[0] - builder.WriteString("\nrequire " + formatModuleRequirement(item) + "\n") - return builder.String() - } - builder.WriteString("\nrequire (\n") - for _, item := range requires { - builder.WriteString("\t" + formatModuleRequirement(item) + "\n") - } - builder.WriteString(")\n") - return builder.String() -} - -func formatModuleRequirement(item moduleRequirement) string { - line := item.Path + " " + item.Version - if item.Indirect { - line += " // indirect" - } - return line -} - -func goSumTemplate(modules []string) string { - entries := map[string]string{ - nucleusHTTPModule: `github.com/nucleuskit/http v0.1.0-alpha.2 h1:YedMdKWg/YkouBOHu3hQvi4Vd8S6YNbdJFPUrzja+Og= -github.com/nucleuskit/http v0.1.0-alpha.2/go.mod h1:SxCsV2Ag3rtX82BbyIGN7ByiioUePbesFbIKIQQbeRs= -`, - nucleusCapModule: `github.com/nucleuskit/cap v0.1.0-alpha.2 h1:UnBp5ezoi+UrgT92DwpTdQynrEnUNnsf3rQKB0pNfPw= -github.com/nucleuskit/cap v0.1.0-alpha.2/go.mod h1:DLSQmS/6irYQfpWGJjce9+STvqG33yZMCxkwvdLf7XM= -`, - nucleusCoreModule: `github.com/nucleuskit/core v0.1.0-alpha.2 h1:CT4RJvCYtVNo0+Gqyf5zx4T90dBiXq4JVhQxEKBXyJ8= -github.com/nucleuskit/core v0.1.0-alpha.2/go.mod h1:fwIlIS28wLh/VQ/jhR4TgrZcrN1nOgrAxSYYYWRdEYk= -`, - } - var builder strings.Builder - for _, module := range modules { - builder.WriteString(entries[module]) - } - return builder.String() -} - -func serviceErrorsTemplate() string { - return `errors: - - code: 1000 - message: internal error - http_status: 500 -` -} - -func openAPITemplate(name string) string { - return fmt.Sprintf(`openapi: 3.0.3 -info: - title: %s - version: %s -paths: - /healthz: - get: - operationId: getHealthz - responses: - "200": - description: ok -`, name, defaultServiceVersion) -} - -func serviceManifestTemplate(name string) string { - return fmt.Sprintf(`schema_version: "1.0" - -service: - name: %s - version: "%s" - tier: internal - description: "Generated Nucleus service" - -ai: - intent: "Generated by nucleus init for a contract-first HTTP service" - allowed_changes: - - nucleus.yaml - - go.mod - - go.sum - - api/** - - cmd/** - - configs/** - - deploy/** - - docs/** - - internal/app/** - - internal/config/** - - internal/domain/** - - internal/usecase/** - - internal/component/** - - internal/adapter/http/** - - internal/adapter/store/** - - internal/server/** - - test/** - - Makefile - readonly: - - contract/gen/** - - internal/adapter/http/gen/** - forbidden: - - configs/*.local.yaml - - configs/*secret*.yaml - - bridge/legacy/** - generated: - - contract/gen - - internal/adapter/http/gen - -capabilities: - - http -`, name, defaultServiceVersion) -} - -func workerManifestTemplate(name string) string { - return fmt.Sprintf(`schema_version: "1.0" - -service: - name: %s - version: "%s" - tier: internal - description: "Generated Nucleus worker" - -ai: - intent: "Generated by nucleus init for an explicit worker entrypoint" - allowed_changes: - - nucleus.yaml - - go.mod - - cmd/** - - internal/** - readonly: [] - forbidden: - - configs/*.local.yaml - - configs/*secret*.yaml - -nucleus: - providers: - worker: - provider: local - -capabilities: - - worker -`, name, defaultServiceVersion) -} - -func libraryManifestTemplate(name string) string { - return fmt.Sprintf(`schema_version: "1.0" - -service: - name: %s - version: "%s" - tier: internal - description: "Generated Nucleus library" - -ai: - intent: "Generated by nucleus init for a small reusable Go library" - allowed_changes: - - nucleus.yaml - - go.mod - - "*.go" - readonly: [] - forbidden: [] - -capabilities: [] -`, name, defaultServiceVersion) -} - -func serviceMainTemplate(module string) string { - return fmt.Sprintf(`package main - -import ( - "context" - "log" - "os/signal" - "syscall" - - "%s/internal/app" - "%s/internal/config" -) - -func main() { - ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer stop() - - cfg, err := config.Load() - if err != nil { - log.Fatal(err) - } - - application := app.New(cfg) - if err := application.Run(ctx); err != nil { - log.Fatal(err) - } -} -`, module, module) -} - -func serviceAppTemplate(module string) string { - return fmt.Sprintf(`package app - -import ( - "context" - "errors" - "net/http" - "time" - - runtimehttp "github.com/nucleuskit/http" - - "%s/internal/config" - serverpkg "%s/internal/server" -) - -type App struct { - config config.Config - router *runtimehttp.Server - server *http.Server -} - -func New(cfg config.Config) *App { - router := runtimehttp.NewServer() - handler := NewHTTPHandler(NewHealthUsecase()) - RegisterHTTPRoutes(router, handler) - return &App{ - config: cfg, - router: router, - server: serverpkg.NewHTTPServer(cfg.Server.Addr, router.Handler()), - } -} - -func (a *App) Run(ctx context.Context) error { - errc := make(chan error, 1) - go func() { - errc <- a.server.ListenAndServe() - }() - select { - case <-ctx.Done(): - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - return a.Shutdown(shutdownCtx) - case err := <-errc: - if errors.Is(err, http.ErrServerClosed) { - return nil - } - return err - } -} - -func (a *App) Shutdown(ctx context.Context) error { - return a.server.Shutdown(ctx) -} -`, module, module) -} - -func serviceProvidersTemplate(module string) string { - return fmt.Sprintf(`package app - -import ( - httpadapter "%s/internal/adapter/http" - domainhealth "%s/internal/domain/health" - healthusecase "%s/internal/usecase/health" -) - -func NewHealthUsecase() *healthusecase.Usecase { - return healthusecase.NewUsecase(domainhealth.NewService()) -} - -func NewHTTPHandler(health *healthusecase.Usecase) *httpadapter.Handler { - return httpadapter.NewHandler(health) -} -`, module, module, module) -} - -func serviceRoutesTemplate(module string) string { - return fmt.Sprintf(`package app - -import ( - runtimehttp "github.com/nucleuskit/http" - - httpadapter "%s/internal/adapter/http" - httpgen "%s/internal/adapter/http/gen" -) - -func RegisterHTTPRoutes(server *runtimehttp.Server, handler *httpadapter.Handler) { - httpgen.RegisterRoutes(server, handler) -} -`, module, module) -} - -func serviceConfigTemplate() string { - return `package config - -type Config struct { - Server ServerConfig ` + "`json:\"server\" yaml:\"server\"`" + ` -} - -type ServerConfig struct { - Addr string ` + "`json:\"addr\" yaml:\"addr\"`" + ` -} - -func Default() Config { - return Config{Server: ServerConfig{Addr: "` + defaultHTTPAddress + `"}} -} -` -} - -func serviceConfigLoaderTemplate() string { - return `package config - -import "os" - -func Load() (Config, error) { - cfg := Default() - if value := os.Getenv("NUCLEUS_HTTP_ADDR"); value != "" { - cfg.Server.Addr = value - } - return cfg, cfg.Validate() -} -` -} - -func serviceConfigValidateTemplate() string { - return `package config - -import "fmt" - -func (c Config) Validate() error { - if c.Server.Addr == "" { - return fmt.Errorf("server.addr is required") - } - return nil -} -` -} - -func serviceDomainHealthModelTemplate() string { - return `package health - -type Status struct { - Status string ` + "`json:\"status\"`" + ` -} -` -} - -func serviceDomainHealthServiceTemplate() string { - return `package health - -import "context" - -type Service struct{} - -func NewService() Service { - return Service{} -} - -func (Service) Check(context.Context) (Status, error) { - return Status{Status: "ok"}, nil -} -` -} - -func serviceDomainHealthRepositoryTemplate() string { - return `package health - -import "context" - -type Repository interface { - Status(context.Context) (Status, error) -} -` -} - -func serviceDomainHealthErrorsTemplate() string { - return `package health - -const ErrUnavailable = "health unavailable" -` -} - -func serviceDomainHealthEventsTemplate() string { - return `package health - -type CheckedEvent struct { - Status Status -} -` -} - -func serviceUsecaseHealthTemplate(module string) string { - return fmt.Sprintf(`package health - -import ( - "context" - - domainhealth "%s/internal/domain/health" -) - -type Usecase struct { - service domainhealth.Service -} - -func NewUsecase(service domainhealth.Service) *Usecase { - return &Usecase{service: service} -} - -func (u *Usecase) Get(ctx context.Context) (domainhealth.Status, error) { - return u.service.Check(ctx) -} -`, module) -} - -func serviceHTTPHandlerTemplate(module string) string { - return fmt.Sprintf(`package http - -import ( - "net/http" - - healthusecase "%s/internal/usecase/health" -) - -type Handler struct { - health *healthusecase.Usecase -} - -func NewHandler(health *healthusecase.Usecase) *Handler { - return &Handler{health: health} -} - -func (h *Handler) GetHealthz(request *http.Request) (any, error) { - return h.health.Get(request.Context()) -} -`, module) -} - -func serviceHTTPMapperTemplate() string { - return `package http - -type HealthResponse struct { - Status string ` + "`json:\"status\"`" + ` -} -` -} - -func serviceHTTPValidationTemplate() string { - return `package http - -func validateHealthRequest() error { - return nil -} -` -} - -func serviceHTTPErrorsTemplate() string { - return `package http - -func mapError(err error) error { - return err -} -` -} - -func serviceMemoryRepositoryTemplate(module string) string { - return fmt.Sprintf(`package memory - -import ( - "context" - - domainhealth "%s/internal/domain/health" -) - -type HealthRepository struct{} - -func NewHealthRepository() *HealthRepository { - return &HealthRepository{} -} - -func (HealthRepository) Status(context.Context) (domainhealth.Status, error) { - return domainhealth.Status{Status: "ok"}, nil -} -`, module) -} - -func serviceClockComponentTemplate() string { - return `package clock - -import "time" - -type Clock struct{} - -func (Clock) Now() time.Time { - return time.Now() -} -` -} - -func serviceHTTPServerTemplate() string { - return `package server - -import "net/http" - -func NewHTTPServer(addr string, handler http.Handler) *http.Server { - return &http.Server{ - Addr: addr, - Handler: handler, - } -} -` -} - -func serviceConfigExampleTemplate() string { - return `server: - addr: ${NUCLEUS_HTTP_ADDR:-` + defaultHTTPAddress + `} -` -} - -func serviceConfigReadmeTemplate() string { - return `# Config - -` + "`configs/config.example.yaml`" + ` is safe to commit and must only contain placeholders. -Local files such as ` + "`configs/config.local.yaml`" + ` are forbidden by ` + "`nucleus.yaml`" + ` edit surfaces. -` -} - -func serviceArchitectureDocTemplate(name string) string { - return fmt.Sprintf(`# %s Architecture - -This service is generated by Nucleus. - -- Contracts live in `+"`api/`"+`. -- Handwritten business code lives in `+"`internal/domain`"+` and `+"`internal/usecase`"+`. -- Protocol adapters live in `+"`internal/adapter`"+`. -- Generated HTTP glue lives in `+"`internal/adapter/http/gen`"+`. -`, name) -} - -func serviceAPIDocTemplate() string { - return `# API - -The HTTP API contract is ` + "`api/openapi.yaml`" + `. Run ` + "`nucleus gen --dir .`" + ` after changing contracts. -` -} - -func serviceDockerfileTemplate(name string) string { - return fmt.Sprintf(`FROM golang:1.26 AS build -WORKDIR /src -COPY . . -RUN go build -o /out/%s ./cmd/%s - -FROM gcr.io/distroless/base-debian12 -COPY --from=build /out/%s /%s -ENTRYPOINT ["/%s"] -`, name, name, name, name, name) -} - -func serviceIntegrationReadmeTemplate() string { - return `# Integration Tests - -Put external-dependency tests here and guard them with explicit build tags. -` -} - -func serviceMakefileTemplate(name string) string { - return fmt.Sprintf(`SERVICE := %s - -.PHONY: run gen lint verify test - -run: - go run ./cmd/$(SERVICE) - -gen: - nucleus gen --dir . - -lint: - nucleus lint --dir . --strict - -verify: - nucleus verify --dir . --json - -test: - go test ./... -`, name) -} - -func workerMainTemplate(module string) string { - return fmt.Sprintf(`package main - -import ( - "context" - "fmt" - "log" - - localworker "%s/internal/worker" -) - -func main() { - handler := localworker.HandlerFunc(func(context.Context, localworker.Message) error { - return nil - }) - if err := handler.Handle(context.Background(), localworker.Message{ID: "startup", Name: "startup"}); err != nil { - log.Fatal(err) - } - fmt.Println("nucleus worker initialized") -} -`, module) -} - -func workerHandlerTemplate() string { - return `package worker - -import ( - "context" - "errors" -) - -// ErrNilHandler is returned when a nil HandlerFunc is invoked. -var ErrNilHandler = errors.New("worker handler is nil") - -// Message is the transport-neutral input delivered to a worker handler. -type Message struct { - ID string - Name string - Payload []byte - Attributes map[string]string -} - -// Handler processes one worker message. -type Handler interface { - Handle(context.Context, Message) error -} - -// HandlerFunc adapts a function to the Handler interface. -type HandlerFunc func(context.Context, Message) error - -// Handle processes one message or returns ErrNilHandler for a nil function. -func (fn HandlerFunc) Handle(ctx context.Context, message Message) error { - if fn == nil { - return ErrNilHandler - } - return fn(ctx, message) -} -` -} - -func libraryTemplate(packageName string, name string) string { - return fmt.Sprintf(`package %s - -func Name() string { - return %q -} -`, packageName, name) -} - -func packageNameFromServiceName(name string) string { - return strings.NewReplacer("-", "_", ".", "_").Replace(name) -} diff --git a/cmd/nucleus/internal/lint/constants.go b/cmd/nucleus/internal/lint/constants.go index 9248669..d839100 100644 --- a/cmd/nucleus/internal/lint/constants.go +++ b/cmd/nucleus/internal/lint/constants.go @@ -1,12 +1,14 @@ package lint const ( - commandUseLint = "lint" - commandShortLint = "run Nucleus architecture lint rules" - defaultDir = "." - resultKindLint = "nucleus.lint_result" - jsonIndentPrefix = "" - jsonIndentValue = " " + commandUseLint = "lint" + commandShortLint = "run Nucleus architecture lint rules" + defaultDir = "." + resultKindLint = "nucleus.lint_result" + schemaVersionLint = "lint-result.v1" + schemaRefLint = "contract/schema/lint-result.v1.schema.json" + jsonIndentPrefix = "" + jsonIndentValue = " " ) const ( diff --git a/cmd/nucleus/internal/lint/output.go b/cmd/nucleus/internal/lint/output.go index bdf1f13..a776b69 100644 --- a/cmd/nucleus/internal/lint/output.go +++ b/cmd/nucleus/internal/lint/output.go @@ -6,15 +6,19 @@ import ( "io" "strings" + "github.com/nucleuskit/contract/diagnostic" contractlint "github.com/nucleuskit/contract/lint" ) type lintResult struct { - ResultKind string `json:"result_kind"` - OK bool `json:"ok"` - Summary lintSummary `json:"summary"` - Findings []contractlint.Finding `json:"findings"` - Strict bool `json:"strict"` + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Summary lintSummary `json:"summary"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` + Findings []contractlint.Finding `json:"findings"` + Strict bool `json:"strict"` } func renderHuman(stdout io.Writer, stderr io.Writer, findings []contractlint.Finding, summary lintSummary) { @@ -46,11 +50,14 @@ func renderJSON(writer io.Writer, findings []contractlint.Finding, summary lintS findings = []contractlint.Finding{} } result := lintResult{ - ResultKind: resultKindLint, - OK: len(findings) == 0, - Summary: summary, - Findings: findings, - Strict: strict, + ResultKind: resultKindLint, + SchemaVersion: schemaVersionLint, + SchemaRef: schemaRefLint, + OK: len(findings) == 0, + Summary: summary, + Diagnostics: diagnostic.Diagnostics{}, + Findings: findings, + Strict: strict, } encoder := json.NewEncoder(writer) if pretty { diff --git a/cmd/nucleus/internal/lint/output_test.go b/cmd/nucleus/internal/lint/output_test.go index 20fa142..5bf8010 100644 --- a/cmd/nucleus/internal/lint/output_test.go +++ b/cmd/nucleus/internal/lint/output_test.go @@ -12,7 +12,7 @@ import ( func TestCommandJSONSuccess(t *testing.T) { dir := t.TempDir() - writeManifest(t, dir, `schema_version: "1.0" + writeManifest(t, dir, `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -36,11 +36,14 @@ capabilities: [] } var output struct { - ResultKind string `json:"result_kind"` - OK bool `json:"ok"` - Summary lintSummary `json:"summary"` - Findings []any `json:"findings"` - Strict bool `json:"strict"` + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Summary lintSummary `json:"summary"` + Diagnostics []any `json:"diagnostics"` + Findings []any `json:"findings"` + Strict bool `json:"strict"` } if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) @@ -48,9 +51,18 @@ capabilities: [] if output.ResultKind != resultKindLint { t.Fatalf("result_kind = %q, want %q", output.ResultKind, resultKindLint) } + if output.SchemaVersion != schemaVersionLint { + t.Fatalf("schema_version = %q, want %q", output.SchemaVersion, schemaVersionLint) + } + if output.SchemaRef != schemaRefLint { + t.Fatalf("schema_ref = %q, want %q", output.SchemaRef, schemaRefLint) + } if !output.OK { t.Fatal("ok = false, want true") } + if output.Diagnostics == nil { + t.Fatal("diagnostics = nil, want empty array") + } if output.Strict { t.Fatal("strict = true, want false") } @@ -76,7 +88,7 @@ capabilities: [] func TestCommandJSONFailureReturnsSentinel(t *testing.T) { dir := t.TempDir() - writeManifest(t, dir, `schema_version: "1.0" + writeManifest(t, dir, `schema_version: "2.0" service: version: "0.1.0" ai: @@ -125,7 +137,7 @@ ai: func TestCommandJSONStrictUsesStrictRules(t *testing.T) { dir := t.TempDir() - writeManifest(t, dir, `schema_version: "1.0" + writeManifest(t, dir, `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -174,7 +186,7 @@ dependencies: func TestCommandHumanSuccessOutput(t *testing.T) { dir := t.TempDir() - writeManifest(t, dir, `schema_version: "1.0" + writeManifest(t, dir, `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -211,7 +223,7 @@ capabilities: [] func TestCommandHumanFailureOutputMatchesValidateStyle(t *testing.T) { dir := t.TempDir() - writeManifest(t, dir, `schema_version: "1.0" + writeManifest(t, dir, `schema_version: "2.0" service: version: "0.1.0" ai: @@ -240,7 +252,7 @@ ai: func TestCommandHumanStrictSuccessOutputIncludesStrictScope(t *testing.T) { dir := t.TempDir() - writeManifest(t, dir, `schema_version: "1.0" + writeManifest(t, dir, `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -275,7 +287,7 @@ capabilities: [] func TestCommandPrettyJSONOutput(t *testing.T) { dir := t.TempDir() - writeManifest(t, dir, `schema_version: "1.0" + writeManifest(t, dir, `schema_version: "2.0" service: name: demo version: "0.1.0" diff --git a/cmd/nucleus/internal/mark/command.go b/cmd/nucleus/internal/mark/command.go new file mode 100644 index 0000000..b4c0894 --- /dev/null +++ b/cmd/nucleus/internal/mark/command.go @@ -0,0 +1,119 @@ +package mark + +import ( + "errors" + + "github.com/spf13/cobra" +) + +// Config carries root-level flag values used by the mark command. +type Config struct { + Dir *string +} + +type options struct { + kind string + path string + symbols []string + intent string + json bool + pretty bool +} + +var ErrMarkFailed = errors.New("mark failed") + +// NewCommand creates the mark command group. +func NewCommand(config Config) *cobra.Command { + cmd := &cobra.Command{ + Use: commandUseMark, + Short: commandShortMark, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.AddCommand(newContractCommand(config)) + cmd.AddCommand(newCapabilityCommand(config)) + cmd.AddCommand(newVerifyCommand(config)) + return cmd +} + +func newContractCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseContract, + Short: commandShortContract, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := markContract(config, args[0], opts) + return renderAndReturn(cmd, output, opts) + }, + } + cmd.Flags().StringVar(&opts.kind, flagKind, "", flagHelpKind) + cmd.Flags().StringVar(&opts.path, flagPath, "", flagHelpPath) + addOutputFlags(cmd, opts) + return cmd +} + +func newCapabilityCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseCapability, + Short: commandShortCapability, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := markCapability(config, args[0], opts) + return renderAndReturn(cmd, output, opts) + }, + } + cmd.Flags().StringVar(&opts.kind, flagKind, "", flagHelpKind) + cmd.Flags().StringArrayVar(&opts.symbols, flagSymbol, nil, flagHelpSymbol) + cmd.Flags().StringVar(&opts.intent, flagIntent, "", flagHelpIntent) + addOutputFlags(cmd, opts) + return cmd +} + +func newVerifyCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseVerify, + Short: commandShortVerify, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := markVerify(config, args[0]) + return renderAndReturn(cmd, output, opts) + }, + } + addOutputFlags(cmd, opts) + return cmd +} + +func addOutputFlags(cmd *cobra.Command, opts *options) { + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) +} + +func renderAndReturn(cmd *cobra.Command, output result, opts *options) error { + if opts.json { + if err := renderJSON(cmd.OutOrStdout(), output, opts.pretty); err != nil { + return err + } + } else { + renderHuman(cmd.OutOrStdout(), output) + } + if !output.OK { + return ErrMarkFailed + } + return nil +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/cmd/nucleus/internal/mark/command_test.go b/cmd/nucleus/internal/mark/command_test.go new file mode 100644 index 0000000..8706193 --- /dev/null +++ b/cmd/nucleus/internal/mark/command_test.go @@ -0,0 +1,205 @@ +package mark + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/nucleuskit/contract/manifest" +) + +func TestMarkContractUpdatesManifestOnly(t *testing.T) { + dir := t.TempDir() + writeMarkFile(t, dir, manifestFileName, baseManifest()) + writeMarkFile(t, dir, "go.mod", "module example.com/mark\n\ngo 1.26.3\n") + + output := markContract(Config{Dir: &dir}, "http", &options{kind: "openapi", path: "api/openapi.yaml"}) + if !output.OK { + t.Fatalf("ok=false diagnostics=%#v", output.Diagnostics) + } + if !output.Changed { + t.Fatal("changed=false, want true") + } + m := loadMarkedManifest(t, dir) + if len(m.Contracts) != 1 || m.Contracts[0].ID != "http" || m.Contracts[0].Kind != "openapi" || m.Contracts[0].Path != "api/openapi.yaml" { + t.Fatalf("contracts = %#v", m.Contracts) + } + if _, err := os.Stat(filepath.Join(dir, "go.sum")); err == nil { + t.Fatal("mark must not create go.sum") + } else if !os.IsNotExist(err) { + t.Fatalf("stat go.sum: %v", err) + } +} + +func TestMarkCapabilityResolvesAndDeclaresSymbols(t *testing.T) { + dir := t.TempDir() + writeMarkFile(t, dir, manifestFileName, baseManifest()) + writeMarkFile(t, dir, "go.mod", "module example.com/mark\n\ngo 1.26.3\n") + writeMarkFile(t, dir, "order/store.go", "package order\ntype OrderStore interface { Save() error }\n") + beforeGoMod := readMarkFile(t, dir, "go.mod") + + output := markCapability(Config{Dir: &dir}, "order_store", &options{ + kind: "project_specific_store_kind", + intent: "persist orders", + symbols: []string{"OrderStore", "FutureStore"}, + }) + if !output.OK { + t.Fatalf("ok=false diagnostics=%#v", output.Diagnostics) + } + if !output.Changed { + t.Fatal("changed=false, want true") + } + assertMarkDiagnostic(t, output, "mark.symbol_declared") + + m := loadMarkedManifest(t, dir) + if len(m.Capabilities) != 1 { + t.Fatalf("capabilities = %#v", m.Capabilities) + } + capability := m.Capabilities[0] + if capability.ID != "order_store" || capability.Kind != "project_specific_store_kind" || capability.Intent != "persist orders" { + t.Fatalf("capability = %#v", capability) + } + if len(capability.Symbols) != 2 { + t.Fatalf("symbols = %#v", capability.Symbols) + } + assertSymbolRef(t, capability.Symbols, manifest.SymbolRef{ID: "go://example.com/mark/order#OrderStore", Status: statusResolved}) + assertSymbolRef(t, capability.Symbols, manifest.SymbolRef{Name: "FutureStore", Status: statusDeclared}) + if got := readMarkFile(t, dir, "go.mod"); got != beforeGoMod { + t.Fatalf("go.mod was modified:\n%s", got) + } + if _, err := os.Stat(filepath.Join(dir, "go.sum")); err == nil { + t.Fatal("mark capability must not create go.sum") + } else if !os.IsNotExist(err) { + t.Fatalf("stat go.sum: %v", err) + } + manifestText := readMarkFile(t, dir, manifestFileName) + for _, forbidden := range []string{"provider:", "providers:", "library:", "driver:"} { + if strings.Contains(manifestText, forbidden) { + t.Fatalf("manifest leaked provider decision field %q:\n%s", forbidden, manifestText) + } + } +} + +func TestMarkCapabilityFailsOnAmbiguousShortSymbol(t *testing.T) { + dir := t.TempDir() + writeMarkFile(t, dir, manifestFileName, baseManifest()) + writeMarkFile(t, dir, "go.mod", "module example.com/ambiguous\n\ngo 1.26.3\n") + writeMarkFile(t, dir, "a/a.go", "package a\ntype Store interface{}\n") + writeMarkFile(t, dir, "b/b.go", "package b\ntype Store interface{}\n") + + output := markCapability(Config{Dir: &dir}, "store", &options{kind: "relational_store", symbols: []string{"Store"}}) + if output.OK { + t.Fatal("ok=true, want ambiguous failure") + } + if len(output.Candidates) != 2 { + t.Fatalf("candidates = %#v", output.Candidates) + } + assertMarkDiagnostic(t, output, "mark.symbol_ambiguous") + + m := loadMarkedManifest(t, dir) + if len(m.Capabilities) != 0 { + t.Fatalf("capability should not be written on ambiguity: %#v", m.Capabilities) + } +} + +func TestMarkVerifyAppendsIdempotently(t *testing.T) { + dir := t.TempDir() + writeMarkFile(t, dir, manifestFileName, baseManifest()) + + first := markVerify(Config{Dir: &dir}, "go test ./...") + if !first.OK || !first.Changed { + t.Fatalf("first output = %#v", first) + } + second := markVerify(Config{Dir: &dir}, "go test ./...") + if !second.OK || second.Changed { + t.Fatalf("second output = %#v", second) + } + m := loadMarkedManifest(t, dir) + if got := strings.Join(m.Verify.Commands, ","); got != "go test ./..." { + t.Fatalf("verify commands = %q", got) + } +} + +func TestMarkCommandRendersJSONBeforeFailure(t *testing.T) { + dir := t.TempDir() + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"verify", "go test ./...", "--json"}) + + err := cmd.Execute() + if !errors.Is(err, ErrMarkFailed) { + t.Fatalf("execute error = %v, want ErrMarkFailed", err) + } + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if output["ok"] != false || output["result_kind"] != resultKindMark { + t.Fatalf("unexpected output: %#v", output) + } +} + +func baseManifest() string { + return `schema_version: "2.0" +service: + name: mark-demo + version: "0.1.0" +ai: + intent: test mark +` +} + +func writeMarkFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func readMarkFile(t *testing.T, dir string, name string) string { + t.Helper() + data, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name))) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + return string(data) +} + +func loadMarkedManifest(t *testing.T, dir string) manifest.Manifest { + t.Helper() + m, err := manifest.Load(dir) + if err != nil { + t.Fatalf("load manifest: %v", err) + } + return m +} + +func assertMarkDiagnostic(t *testing.T, output result, code string) { + t.Helper() + for _, item := range output.Diagnostics { + if item.Code == code { + return + } + } + t.Fatalf("diagnostic %q not found in %#v", code, output.Diagnostics) +} + +func assertSymbolRef(t *testing.T, symbols []manifest.SymbolRef, want manifest.SymbolRef) { + t.Helper() + for _, symbol := range symbols { + if symbol == want { + return + } + } + t.Fatalf("symbol ref %#v not found in %#v", want, symbols) +} diff --git a/cmd/nucleus/internal/mark/constants.go b/cmd/nucleus/internal/mark/constants.go new file mode 100644 index 0000000..81c753b --- /dev/null +++ b/cmd/nucleus/internal/mark/constants.go @@ -0,0 +1,39 @@ +package mark + +const ( + commandUseMark = "mark" + commandShortMark = "declare protocol anchors in nucleus.yaml" + commandUseContract = "contract " + commandShortContract = "declare a contract file" + commandUseCapability = "capability " + commandShortCapability = "declare a capability anchor" + commandUseVerify = "verify " + commandShortVerify = "declare a project-owned verification command" + defaultDir = "." + resultKindMark = "nucleus.mark_result" + schemaVersionMark = "mark-result.v1" + schemaRefMark = "contract/schema/mark-result.v1.schema.json" + manifestFileName = "nucleus.yaml" + jsonIndentPrefix = "" + jsonIndentValue = " " + statusResolved = "resolved" + statusDeclared = "declared" +) + +const ( + flagKind = "kind" + flagPath = "path" + flagSymbol = "symbol" + flagIntent = "intent" + flagJSON = "json" + flagPretty = "pretty" +) + +const ( + flagHelpKind = "semantic kind to record in nucleus.yaml" + flagHelpPath = "relative contract path to record in nucleus.yaml" + flagHelpSymbol = "capability anchor symbol; may be repeated" + flagHelpIntent = "optional capability intent" + flagHelpJSON = "emit machine-readable mark result" + flagHelpPretty = "pretty-print JSON output" +) diff --git a/cmd/nucleus/internal/mark/output.go b/cmd/nucleus/internal/mark/output.go new file mode 100644 index 0000000..1b57a62 --- /dev/null +++ b/cmd/nucleus/internal/mark/output.go @@ -0,0 +1,75 @@ +package mark + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" +) + +type result struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Action string `json:"action"` + ManifestPath string `json:"manifest_path"` + Changed bool `json:"changed"` + Entry any `json:"entry,omitempty"` + Symbols []symbolMark `json:"symbols,omitempty"` + Candidates []inspect.SymbolNode `json:"candidates,omitempty"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +type symbolMark struct { + Query string `json:"query"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Status string `json:"status"` +} + +func renderJSON(writer io.Writer, output result, pretty bool) error { + output = normalizeResult(output) + encoder := json.NewEncoder(writer) + if pretty { + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + } + return encoder.Encode(output) +} + +func renderHuman(writer io.Writer, output result) { + output = normalizeResult(output) + if output.OK { + _, _ = fmt.Fprintln(writer, "OK mark") + } else { + _, _ = fmt.Fprintln(writer, "FAILED mark") + } + _, _ = fmt.Fprintf(writer, "action: %s\n", output.Action) + _, _ = fmt.Fprintf(writer, "changed: %t\n", output.Changed) + for _, item := range output.Diagnostics { + _, _ = fmt.Fprintf(writer, " - %s %s %s: %s\n", item.Severity, item.Path, item.Code, item.Message) + } +} + +func normalizeResult(output result) result { + if output.ManifestPath == "" { + output.ManifestPath = manifestFileName + } + if output.Symbols == nil { + output.Symbols = []symbolMark{} + } + if output.Candidates == nil { + output.Candidates = []inspect.SymbolNode{} + } + if output.Diagnostics == nil { + output.Diagnostics = diagnostic.Diagnostics{} + } + output.Diagnostics.Sort() + output.ResultKind = resultKindMark + output.SchemaVersion = schemaVersionMark + output.SchemaRef = schemaRefMark + output.OK = !output.Diagnostics.Failed() + return output +} diff --git a/cmd/nucleus/internal/mark/run.go b/cmd/nucleus/internal/mark/run.go new file mode 100644 index 0000000..5f76009 --- /dev/null +++ b/cmd/nucleus/internal/mark/run.go @@ -0,0 +1,347 @@ +package mark + +import ( + "errors" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/contract/manifest" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/graphquery" + "go.yaml.in/yaml/v3" +) + +func markContract(config Config, id string, opts *options) result { + dir := stringValue(config.Dir, defaultDir) + m, diagnostics := loadManifestForMark(dir) + normalized := contractInput{ + id: strings.TrimSpace(id), + kind: strings.TrimSpace(opts.kind), + path: normalizeSlashPath(opts.path), + } + diagnostics = append(diagnostics, validateContractInput(normalized)...) + if diagnostics.Failed() { + return normalizeResult(result{Action: "contract", Diagnostics: diagnostics}) + } + + entry := manifest.Contract{ID: normalized.id, Kind: normalized.kind, Path: normalized.path} + changed := upsertContract(&m, entry) + if changed { + diagnostics = append(diagnostics, writeManifest(dir, m)...) + } + return normalizeResult(result{ + Action: "contract", + Changed: changed && !diagnostics.Failed(), + Entry: entry, + Diagnostics: diagnostics, + }) +} + +func markCapability(config Config, id string, opts *options) result { + dir := stringValue(config.Dir, defaultDir) + m, diagnostics := loadManifestForMark(dir) + normalized := capabilityInput{ + id: strings.TrimSpace(id), + kind: strings.TrimSpace(opts.kind), + intent: strings.TrimSpace(opts.intent), + symbols: trimStrings(opts.symbols), + } + diagnostics = append(diagnostics, validateCapabilityInput(normalized)...) + if diagnostics.Failed() { + return normalizeResult(result{Action: "capability", Diagnostics: diagnostics}) + } + + refs, marks, candidates, resolveDiagnostics := resolveSymbolRefs(dir, normalized.symbols) + diagnostics = append(diagnostics, resolveDiagnostics...) + if diagnostics.Failed() { + return normalizeResult(result{ + Action: "capability", + Symbols: marks, + Candidates: candidates, + Diagnostics: diagnostics, + }) + } + + entry := manifest.Capability{ + ID: normalized.id, + Kind: normalized.kind, + Intent: normalized.intent, + Symbols: refs, + } + changed := upsertCapability(&m, entry) + if changed { + diagnostics = append(diagnostics, writeManifest(dir, m)...) + } + return normalizeResult(result{ + Action: "capability", + Changed: changed && !diagnostics.Failed(), + Entry: capabilityEntry(m.Capabilities, normalized.id), + Symbols: marks, + Diagnostics: diagnostics, + }) +} + +func markVerify(config Config, command string) result { + dir := stringValue(config.Dir, defaultDir) + m, diagnostics := loadManifestForMark(dir) + command = strings.TrimSpace(command) + if command == "" { + diagnostics = append(diagnostics, errorDiagnostic("mark.verify_command_required", manifestFileName, "verify command is required")) + } + if diagnostics.Failed() { + return normalizeResult(result{Action: "verify", Diagnostics: diagnostics}) + } + changed := appendVerifyCommand(&m, command) + if changed { + diagnostics = append(diagnostics, writeManifest(dir, m)...) + } + return normalizeResult(result{ + Action: "verify", + Changed: changed && !diagnostics.Failed(), + Entry: command, + Diagnostics: diagnostics, + }) +} + +type contractInput struct { + id string + kind string + path string +} + +type capabilityInput struct { + id string + kind string + intent string + symbols []string +} + +func loadManifestForMark(dir string) (manifest.Manifest, diagnostic.Diagnostics) { + m, err := manifest.Load(dir) + if err == nil { + return m, manifest.ValidateDiagnostics(m) + } + if errors.Is(err, os.ErrNotExist) { + return manifest.Manifest{}, diagnostic.Diagnostics{errorDiagnostic("mark.manifest_missing", manifestFileName, "nucleus.yaml is required; run nucleus adopt first")} + } + return manifest.Manifest{}, diagnostic.Diagnostics{errorDiagnostic("mark.manifest_load_failed", manifestFileName, err.Error())} +} + +func validateContractInput(input contractInput) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + if input.id == "" { + diagnostics = append(diagnostics, errorDiagnostic("mark.contract_id_required", manifestFileName, "contract id is required")) + } + if input.kind == "" { + diagnostics = append(diagnostics, errorDiagnostic("mark.contract_kind_required", manifestFileName, "contract kind is required")) + } + if input.path == "" { + diagnostics = append(diagnostics, errorDiagnostic("mark.contract_path_required", manifestFileName, "contract path is required")) + } else if !safeRelativePath(input.path) { + diagnostics = append(diagnostics, errorDiagnostic("mark.contract_path_invalid", input.path, "contract path must be relative and stay inside the service directory")) + } + return diagnostics +} + +func validateCapabilityInput(input capabilityInput) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + if input.id == "" { + diagnostics = append(diagnostics, errorDiagnostic("mark.capability_id_required", manifestFileName, "capability id is required")) + } + if input.kind == "" { + diagnostics = append(diagnostics, errorDiagnostic("mark.capability_kind_required", manifestFileName, "capability kind is required")) + } + return diagnostics +} + +func resolveSymbolRefs(dir string, queries []string) ([]manifest.SymbolRef, []symbolMark, []inspect.SymbolNode, diagnostic.Diagnostics) { + if len(queries) == 0 { + return nil, nil, nil, nil + } + description, err := inspect.Describe(dir) + if err != nil { + return nil, nil, nil, diagnostic.Diagnostics{errorDiagnostic("mark.describe_failed", manifestFileName, err.Error())} + } + var refs []manifest.SymbolRef + var marks []symbolMark + var candidates []inspect.SymbolNode + var diagnostics diagnostic.Diagnostics + for _, query := range queries { + resolved := graphquery.ResolveSymbol(description.SymbolGraph, query) + if resolved.OK { + refs = append(refs, manifest.SymbolRef{ID: resolved.Node.ID, Status: statusResolved}) + marks = append(marks, symbolMark{Query: query, ID: resolved.Node.ID, Name: resolved.Node.Name, Status: statusResolved}) + continue + } + if len(resolved.Candidates) > 0 { + candidates = append(candidates, resolved.Candidates...) + diagnostics = append(diagnostics, errorDiagnostic("mark.symbol_ambiguous", manifestFileName, "symbol matched multiple candidates; rerun with a stable symbol id")) + continue + } + ref := declaredSymbolRef(query) + refs = append(refs, ref) + marks = append(marks, symbolMark{Query: query, ID: ref.ID, Name: ref.Name, Status: statusDeclared}) + diagnostics = append(diagnostics, warningDiagnostic("mark.symbol_declared", manifestFileName, "symbol was not found and was recorded as declared intent")) + } + sortCandidates(candidates) + return refs, marks, candidates, diagnostics +} + +func declaredSymbolRef(query string) manifest.SymbolRef { + query = strings.TrimSpace(query) + if strings.HasPrefix(query, "go://") { + return manifest.SymbolRef{ID: query, Status: statusDeclared} + } + return manifest.SymbolRef{Name: query, Status: statusDeclared} +} + +func upsertContract(m *manifest.Manifest, entry manifest.Contract) bool { + for index, existing := range m.Contracts { + if existing.ID != entry.ID { + continue + } + if existing == entry { + return false + } + m.Contracts[index] = entry + return true + } + m.Contracts = append(m.Contracts, entry) + sort.Slice(m.Contracts, func(i, j int) bool { return m.Contracts[i].ID < m.Contracts[j].ID }) + return true +} + +func upsertCapability(m *manifest.Manifest, entry manifest.Capability) bool { + for index, existing := range m.Capabilities { + if existing.ID != entry.ID { + continue + } + updated := existing + updated.Kind = entry.Kind + if entry.Intent != "" { + updated.Intent = entry.Intent + } + updated.Symbols = mergeSymbolRefs(updated.Symbols, entry.Symbols) + if capabilitiesEqual(existing, updated) { + return false + } + m.Capabilities[index] = updated + return true + } + m.Capabilities = append(m.Capabilities, entry) + sort.Slice(m.Capabilities, func(i, j int) bool { return m.Capabilities[i].ID < m.Capabilities[j].ID }) + return true +} + +func appendVerifyCommand(m *manifest.Manifest, command string) bool { + for _, existing := range m.Verify.Commands { + if existing == command { + return false + } + } + m.Verify.Commands = append(m.Verify.Commands, command) + return true +} + +func mergeSymbolRefs(existing []manifest.SymbolRef, additions []manifest.SymbolRef) []manifest.SymbolRef { + result := append([]manifest.SymbolRef{}, existing...) + seen := map[string]bool{} + for _, item := range result { + seen[symbolKey(item)] = true + } + for _, item := range additions { + key := symbolKey(item) + if seen[key] { + continue + } + seen[key] = true + result = append(result, item) + } + sort.Slice(result, func(i, j int) bool { return symbolKey(result[i]) < symbolKey(result[j]) }) + return result +} + +func symbolKey(symbol manifest.SymbolRef) string { + if symbol.ID != "" { + return "id:" + symbol.ID + } + return "name:" + symbol.Name +} + +func capabilityEntry(capabilities []manifest.Capability, id string) manifest.Capability { + for _, capability := range capabilities { + if capability.ID == id { + return capability + } + } + return manifest.Capability{} +} + +func capabilitiesEqual(left manifest.Capability, right manifest.Capability) bool { + if left.ID != right.ID || left.Kind != right.Kind || left.Intent != right.Intent || len(left.Symbols) != len(right.Symbols) { + return false + } + for index := range left.Symbols { + if left.Symbols[index] != right.Symbols[index] { + return false + } + } + return true +} + +func writeManifest(dir string, m manifest.Manifest) diagnostic.Diagnostics { + data, err := yaml.Marshal(m) + if err != nil { + return diagnostic.Diagnostics{errorDiagnostic("mark.manifest_encode_failed", manifestFileName, err.Error())} + } + path := filepath.Join(dir, manifestFileName) + if err := os.WriteFile(path, data, 0o644); err != nil { + return diagnostic.Diagnostics{errorDiagnostic("mark.manifest_write_failed", manifestFileName, err.Error())} + } + return nil +} + +func normalizeSlashPath(value string) string { + return strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "./") +} + +func safeRelativePath(value string) bool { + if value == "" || strings.HasPrefix(value, "/") { + return false + } + clean := pathClean(value) + return clean != "." && clean != ".." && !strings.HasPrefix(clean, "../") && !strings.Contains(clean, "/../") +} + +func pathClean(value string) string { + return filepath.ToSlash(filepath.Clean(strings.ReplaceAll(value, "\\", "/"))) +} + +func trimStrings(values []string) []string { + result := make([]string, 0, len(values)) + seen := map[string]bool{} + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} + +func sortCandidates(candidates []inspect.SymbolNode) { + sort.Slice(candidates, func(i, j int) bool { return candidates[i].ID < candidates[j].ID }) +} + +func errorDiagnostic(code string, path string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: code, Path: path, Message: message} +} + +func warningDiagnostic(code string, path string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{Severity: diagnostic.SeverityWarning, Code: code, Path: path, Message: message} +} diff --git a/cmd/nucleus/internal/mcp/command.go b/cmd/nucleus/internal/mcp/command.go new file mode 100644 index 0000000..07672ac --- /dev/null +++ b/cmd/nucleus/internal/mcp/command.go @@ -0,0 +1,46 @@ +package mcp + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" +) + +// Config carries root-level flag values used by the mcp command. +type Config struct { + Dir *string +} + +type options struct { + stdio bool +} + +// NewCommand creates the mcp subcommand. +func NewCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseMCP, + Short: commandShortMCP, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + server := NewServer(stringValue(config.Dir, defaultDir)) + if opts.stdio { + return server.Serve(cmd.InOrStdin(), cmd.OutOrStdout()) + } + return fmt.Errorf("--%s is required", flagStdio) + }, + } + cmd.Flags().BoolVar(&opts.stdio, flagStdio, false, flagHelpStdio) + cmd.SetIn(os.Stdin) + return cmd +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/cmd/nucleus/internal/mcp/constants.go b/cmd/nucleus/internal/mcp/constants.go new file mode 100644 index 0000000..b726a03 --- /dev/null +++ b/cmd/nucleus/internal/mcp/constants.go @@ -0,0 +1,55 @@ +package mcp + +const ( + commandUseMCP = "mcp" + commandShortMCP = "serve local Nucleus MCP tools over stdio" + defaultDir = "." + nucleusMCPProtocol = "2024-11-05" + serverName = "nucleus" + serverVersion = "0.0.0" + jsonRPCVersion = "2.0" +) + +const ( + flagStdio = "stdio" +) + +const ( + flagHelpStdio = "serve MCP over stdio" +) + +const ( + toolGetServiceDescription = "get_service_description" + toolGetEditSurfaces = "get_edit_surfaces" + toolGetContracts = "get_contracts" + toolGetCapabilities = "get_capabilities" + toolTraceRoute = "trace_route" + toolTraceSymbol = "trace_symbol" + toolTraceCapability = "trace_capability" + toolImpactSymbol = "impact_symbol" + toolImpactFile = "impact_file" + toolImpactContract = "impact_contract" + toolFindSymbol = "find_symbol" + toolListCallers = "list_callers" + toolListCallees = "list_callees" + toolValidateDecision = "validate_decision" + toolListDecisions = "list_decisions" + toolGetReport = "get_report" + toolBuildPlan = "build_plan" + toolListRecipes = "list_recipes" + toolGetRecipe = "get_recipe" +) + +const ( + defaultRecipeDir = ".nucleus/recipes" +) + +const ( + resultKindMCPEditSurfaces = "nucleus.mcp.edit_surfaces_result" + resultKindMCPContracts = "nucleus.mcp.contracts_result" + resultKindMCPCapabilities = "nucleus.mcp.capabilities_result" + resultKindMCPFindSymbol = "nucleus.mcp.find_symbol_result" + resultKindMCPCalls = "nucleus.mcp.calls_result" + schemaVersionMCPResult = "mcp-result.v1" + schemaRefMCPResult = "contract/schema/mcp-result.v1.schema.json" +) diff --git a/cmd/nucleus/internal/mcp/server.go b/cmd/nucleus/internal/mcp/server.go new file mode 100644 index 0000000..683c1e3 --- /dev/null +++ b/cmd/nucleus/internal/mcp/server.go @@ -0,0 +1,201 @@ +package mcp + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "strconv" + "strings" +) + +// Server exposes local Nucleus facts as MCP tools. +type Server struct { + dir string +} + +// NewServer creates an MCP server rooted at a service directory. +func NewServer(dir string) *Server { + if strings.TrimSpace(dir) == "" { + dir = defaultDir + } + return &Server{dir: dir} +} + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc,omitempty"` + ID any `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID any `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *rpcError `json:"error,omitempty"` +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` +} + +type callToolParams struct { + Name string `json:"name"` + Arguments map[string]any `json:"arguments,omitempty"` +} + +type toolCallResult struct { + Content []toolContent `json:"content"` + StructuredContent any `json:"structuredContent,omitempty"` + IsError bool `json:"isError,omitempty"` +} + +type toolContent struct { + Type string `json:"type"` + Text string `json:"text"` +} + +// Serve reads MCP JSON-RPC messages using Content-Length framing. +func (server *Server) Serve(input io.Reader, output io.Writer) error { + reader := bufio.NewReader(input) + for { + payload, err := readFrame(reader) + if err != nil { + if errors.Is(err, io.EOF) { + return nil + } + return err + } + response, ok := server.Handle(payload) + if !ok { + continue + } + if err := writeFrame(output, response); err != nil { + return err + } + } +} + +// Handle processes one JSON-RPC request payload. It returns ok=false for notifications. +func (server *Server) Handle(payload []byte) ([]byte, bool) { + var request rpcRequest + if err := json.Unmarshal(payload, &request); err != nil { + return mustJSON(rpcResponse{JSONRPC: jsonRPCVersion, Error: &rpcError{Code: -32700, Message: "parse error"}}), true + } + if request.ID == nil { + _, _ = server.dispatch(request) + return nil, false + } + result, rpcErr := server.dispatch(request) + response := rpcResponse{JSONRPC: jsonRPCVersion, ID: request.ID, Result: result, Error: rpcErr} + if rpcErr != nil { + response.Result = nil + } + return mustJSON(response), true +} + +func (server *Server) dispatch(request rpcRequest) (any, *rpcError) { + switch request.Method { + case "initialize": + return map[string]any{ + "protocolVersion": nucleusMCPProtocol, + "serverInfo": map[string]any{ + "name": serverName, + "version": serverVersion, + }, + "capabilities": map[string]any{ + "tools": map[string]any{}, + }, + }, nil + case "ping": + return map[string]any{}, nil + case "tools/list": + return map[string]any{"tools": server.Tools()}, nil + case "tools/call": + var params callToolParams + if len(request.Params) > 0 { + if err := json.Unmarshal(request.Params, ¶ms); err != nil { + return nil, &rpcError{Code: -32602, Message: "invalid tools/call params"} + } + } + payload, err := server.CallTool(params.Name, params.Arguments) + if err != nil { + return toolResult(map[string]any{"error": err.Error()}, true), nil + } + return toolResult(payload, false), nil + case "notifications/initialized": + return map[string]any{}, nil + default: + return nil, &rpcError{Code: -32601, Message: "method not found"} + } +} + +func toolResult(payload any, isError bool) toolCallResult { + data, err := json.Marshal(payload) + if err != nil { + data = []byte(`{"error":"tool payload could not be encoded"}`) + isError = true + } + return toolCallResult{ + Content: []toolContent{{Type: "text", Text: string(data)}}, + StructuredContent: payload, + IsError: isError, + } +} + +func readFrame(reader *bufio.Reader) ([]byte, error) { + contentLength := 0 + for { + line, err := reader.ReadString('\n') + if err != nil { + return nil, err + } + line = strings.TrimRight(line, "\r\n") + if line == "" { + break + } + name, value, ok := strings.Cut(line, ":") + if !ok { + continue + } + if strings.EqualFold(strings.TrimSpace(name), "Content-Length") { + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return nil, fmt.Errorf("invalid content length: %w", err) + } + contentLength = parsed + } + } + if contentLength <= 0 { + return nil, fmt.Errorf("missing content length") + } + payload := make([]byte, contentLength) + if _, err := io.ReadFull(reader, payload); err != nil { + return nil, err + } + return payload, nil +} + +func writeFrame(output io.Writer, payload []byte) error { + _, err := fmt.Fprintf(output, "Content-Length: %d\r\n\r\n%s", len(payload), payload) + return err +} + +func mustJSON(value any) []byte { + data, err := json.Marshal(value) + if err != nil { + return []byte(`{"jsonrpc":"2.0","error":{"code":-32603,"message":"internal error"}}`) + } + return data +} + +func frame(payload []byte) []byte { + var buffer bytes.Buffer + _, _ = fmt.Fprintf(&buffer, "Content-Length: %d\r\n\r\n", len(payload)) + buffer.Write(payload) + return buffer.Bytes() +} diff --git a/cmd/nucleus/internal/mcp/server_test.go b/cmd/nucleus/internal/mcp/server_test.go new file mode 100644 index 0000000..f1152a3 --- /dev/null +++ b/cmd/nucleus/internal/mcp/server_test.go @@ -0,0 +1,271 @@ +package mcp + +import ( + "bufio" + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestToolsIncludeAgentNativeSet(t *testing.T) { + server := NewServer(t.TempDir()) + names := map[string]bool{} + for _, tool := range server.Tools() { + names[tool.Name] = true + } + for _, want := range []string{ + toolGetServiceDescription, + toolGetEditSurfaces, + toolGetContracts, + toolGetCapabilities, + toolTraceSymbol, + toolTraceRoute, + toolTraceCapability, + toolImpactSymbol, + toolImpactFile, + toolImpactContract, + toolFindSymbol, + toolListCallers, + toolListCallees, + toolValidateDecision, + toolListDecisions, + toolGetReport, + toolBuildPlan, + toolListRecipes, + toolGetRecipe, + } { + if !names[want] { + t.Fatalf("tool %q missing from %#v", want, names) + } + } +} + +func TestCallToolBuildPlanAndTraceSymbol(t *testing.T) { + dir := writeMCPFixture(t) + server := NewServer(dir) + + planRaw, err := server.CallTool(toolBuildPlan, map[string]any{"task": "change ListOrders", "executable": true}) + if err != nil { + t.Fatalf("build_plan: %v", err) + } + planOutput := asMap(t, planRaw) + if planOutput["result_kind"] != "nucleus.executable_plan_result" { + t.Fatalf("plan result_kind = %#v", planOutput["result_kind"]) + } + impactSummary := asMap(t, planOutput["impact_summary"]) + if len(asSlice(t, impactSummary["affected_symbols"])) == 0 { + t.Fatalf("impact_summary missing symbols: %#v", impactSummary) + } + + traceRaw, err := server.CallTool(toolTraceSymbol, map[string]any{"symbol": "ListOrders"}) + if err != nil { + t.Fatalf("trace_symbol: %v", err) + } + traceOutput := asMap(t, traceRaw) + if traceOutput["result_kind"] != "nucleus.trace_result" || traceOutput["ok"] != true { + t.Fatalf("unexpected trace output: %#v", traceOutput) + } + if len(asSlice(t, traceOutput["callers"])) == 0 { + t.Fatalf("trace callers missing: %#v", traceOutput) + } +} + +func TestCallToolOutputsCarryAgentEnvelope(t *testing.T) { + dir := writeMCPFixture(t) + server := NewServer(dir) + cases := []struct { + name string + args map[string]any + schemaVersion string + schemaRef string + }{ + {toolGetServiceDescription, nil, "describe-result.v1", "contract/schema/describe-result.v1.schema.json"}, + {toolGetEditSurfaces, nil, "mcp-result.v1", "contract/schema/mcp-result.v1.schema.json"}, + {toolGetContracts, nil, "mcp-result.v1", "contract/schema/mcp-result.v1.schema.json"}, + {toolGetCapabilities, nil, "mcp-result.v1", "contract/schema/mcp-result.v1.schema.json"}, + {toolFindSymbol, map[string]any{"query": "ListOrders"}, "mcp-result.v1", "contract/schema/mcp-result.v1.schema.json"}, + {toolListCallers, map[string]any{"symbol": "ListOrders"}, "mcp-result.v1", "contract/schema/mcp-result.v1.schema.json"}, + {toolListCallees, map[string]any{"symbol": "ListOrders"}, "mcp-result.v1", "contract/schema/mcp-result.v1.schema.json"}, + {toolListRecipes, nil, "recipe-result.v1", "contract/schema/recipe-result.v1.schema.json"}, + {toolGetRecipe, map[string]any{"id": "sql-port-boundary"}, "recipe-result.v1", "contract/schema/recipe-result.v1.schema.json"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + raw, err := server.CallTool(tc.name, tc.args) + if err != nil { + t.Fatalf("CallTool(%s): %v", tc.name, err) + } + output := asMap(t, raw) + assertEnvelope(t, output, tc.schemaVersion, tc.schemaRef) + }) + } +} + +func TestRecipeToolsAreReadOnlyStructuredKnowledge(t *testing.T) { + dir := t.TempDir() + writeMCPFile(t, dir, ".nucleus/recipes/gorm.yaml", `schema_version: "recipe.v1" +id: gorm-relational-store +kind: relational_store +provider: gorm +language: go +detect: + imports: + - gorm.io/gorm +suggest: + interfaces: + - keep ORM behind interface + verification: + - go test ./... +risks: + - transaction boundaries must be explicit +`) + writeMCPFile(t, dir, ".nucleus/recipes/unsafe.yaml", `schema_version: "recipe.v1" +id: unsafe +kind: relational_store +language: go +commands: + - rm -rf . +`) + + server := NewServer(dir) + listRaw, err := server.CallTool(toolListRecipes, map[string]any{"kind": "relational_store"}) + if err != nil { + t.Fatalf("list_recipes: %v", err) + } + listOutput := asMap(t, listRaw) + recipes := asSlice(t, listOutput["recipes"]) + if len(recipes) != 1 { + t.Fatalf("recipes = %#v, want only safe recipe", recipes) + } + diagnostics := asSlice(t, listOutput["diagnostics"]) + if len(diagnostics) == 0 { + t.Fatalf("diagnostics should include unsafe recipe parse failure: %#v", listOutput) + } + + recipeRaw, err := server.CallTool(toolGetRecipe, map[string]any{"id": "gorm-relational-store"}) + if err != nil { + t.Fatalf("get_recipe: %v", err) + } + recipeOutput := asMap(t, recipeRaw) + if recipeOutput["ok"] != true { + t.Fatalf("recipe output = %#v", recipeOutput) + } +} + +func TestServeHandlesInitializeAndToolCallFrames(t *testing.T) { + dir := writeMCPFixture(t) + server := NewServer(dir) + initialize := []byte(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}`) + listTools := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}`) + input := bytes.NewReader(append(frame(initialize), frame(listTools)...)) + var output bytes.Buffer + + if err := server.Serve(input, &output); err != nil { + t.Fatalf("serve: %v", err) + } + first, err := readFrame(bufio.NewReader(bytes.NewReader(output.Bytes()))) + if err != nil { + t.Fatalf("read first frame: %v", err) + } + var response map[string]any + if err := json.Unmarshal(first, &response); err != nil { + t.Fatalf("decode first response: %v", err) + } + if response["id"].(float64) != 1 { + t.Fatalf("first response = %#v", response) + } +} + +func writeMCPFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + writeMCPFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: orders + version: "0.1.0" +capabilities: + - id: order_store + kind: relational_store + symbols: + - id: go://example.com/orders/order#OrderStore + status: resolved +ai: + intent: mcp test + allowed_changes: + - order/** + - api/** +`) + writeMCPFile(t, dir, "go.mod", "module example.com/orders\n\ngo 1.26.3\n") + writeMCPFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 +paths: + /orders: + get: + operationId: listOrders + responses: + "200": + description: ok +`) + writeMCPFile(t, dir, "order/service.go", `package order + +type OrderStore interface { List() error } + +func ListOrders(store OrderStore) { loadOrders() } +func loadOrders() {} +func Handler(store OrderStore) { ListOrders(store) } +`) + return dir +} + +func writeMCPFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func asMap(t *testing.T, value any) map[string]any { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal value: %v", err) + } + var output map[string]any + if err := json.Unmarshal(data, &output); err != nil { + t.Fatalf("decode map: %v\n%s", err, string(data)) + } + return output +} + +func asSlice(t *testing.T, value any) []any { + t.Helper() + items, ok := value.([]any) + if !ok { + t.Fatalf("value has type %T, want []any", value) + } + return items +} + +func assertEnvelope(t *testing.T, output map[string]any, schemaVersion string, schemaRef string) { + t.Helper() + if output["result_kind"] == "" { + t.Fatalf("missing result_kind: %#v", output) + } + if output["schema_version"] != schemaVersion { + t.Fatalf("schema_version = %#v, want %s", output["schema_version"], schemaVersion) + } + if output["schema_ref"] != schemaRef { + t.Fatalf("schema_ref = %#v, want %s", output["schema_ref"], schemaRef) + } + if _, ok := output["ok"].(bool); !ok { + t.Fatalf("ok has type %T, want bool", output["ok"]) + } + if _, ok := output["diagnostics"].([]any); !ok { + t.Fatalf("diagnostics has type %T, want []any", output["diagnostics"]) + } +} diff --git a/cmd/nucleus/internal/mcp/tools.go b/cmd/nucleus/internal/mcp/tools.go new file mode 100644 index 0000000..3faba94 --- /dev/null +++ b/cmd/nucleus/internal/mcp/tools.go @@ -0,0 +1,370 @@ +package mcp + +import ( + "fmt" + "sort" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/contract/manifest" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/decision" + describecmd "github.com/nucleuskit/nucleus/cmd/nucleus/internal/describe" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/graphquery" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/impact" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/plan" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/recipe" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/report" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/trace" +) + +type toolDescriptor struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema map[string]any `json:"inputSchema"` +} + +// Tools returns MCP descriptors for all local Nucleus tools. +func (server *Server) Tools() []toolDescriptor { + tools := []toolDescriptor{ + tool(toolGetServiceDescription, "Return the full local Nucleus service description.", objectSchema()), + tool(toolGetEditSurfaces, "Return AI edit, readonly, and forbidden surfaces.", objectSchema()), + tool(toolGetContracts, "Return declared contracts plus HTTP, gRPC, and error facts.", objectSchema()), + tool(toolGetCapabilities, "Return manifest capabilities and capability graph facts.", objectSchema()), + tool(toolTraceRoute, "Trace an HTTP route through the flow graph.", stringSchema("route", "Route query such as GET /orders.")), + tool(toolTraceSymbol, "Trace callers and callees for a symbol.", stringSchema("symbol", "Stable symbol id or symbol name.")), + tool(toolTraceCapability, "Trace callers and callees for a manifest capability anchor.", stringSchema("capability", "Capability id.")), + tool(toolImpactSymbol, "Return symbol impact facts.", stringSchema("symbol", "Stable symbol id or symbol name.")), + tool(toolImpactFile, "Return file impact facts.", stringSchema("path", "Relative file path.")), + tool(toolImpactContract, "Return contract impact facts.", stringSchema("path", "Relative contract path.")), + tool(toolFindSymbol, "Find symbols by stable id, name, package, or file.", findSymbolSchema()), + tool(toolListCallers, "List direct callers for a symbol.", stringSchema("symbol", "Stable symbol id or symbol name.")), + tool(toolListCallees, "List direct callees for a symbol.", stringSchema("symbol", "Stable symbol id or symbol name.")), + tool(toolValidateDecision, "Validate structured decision evidence.", pathsSchema()), + tool(toolListDecisions, "List and validate decision evidence summaries.", pathsSchema()), + tool(toolGetReport, "Return local AI quality report facts.", aiTasksSchema()), + tool(toolBuildPlan, "Build a Nucleus plan with impact summary.", buildPlanSchema()), + tool(toolListRecipes, "List project and built-in read-only recipe knowledge.", recipeListSchema()), + tool(toolGetRecipe, "Get one project or built-in read-only recipe by id.", stringSchema("id", "Recipe id.")), + } + sort.Slice(tools, func(i, j int) bool { return tools[i].Name < tools[j].Name }) + return tools +} + +// CallTool executes one local MCP tool and returns structured JSON. +func (server *Server) CallTool(name string, args map[string]any) (any, error) { + if args == nil { + args = map[string]any{} + } + dir := server.dirArg(args) + switch name { + case toolGetServiceDescription: + return describecmd.BuildOutput(describecmd.OutputOptions{Dir: dir}) + case toolGetEditSurfaces: + description, err := inspect.Describe(dir) + if err != nil { + return nil, err + } + return mcpResult(resultKindMCPEditSurfaces, !description.Diagnostics.Failed(), description.Diagnostics, map[string]any{ + "edit_surfaces": description.EditSurfaces, + }), nil + case toolGetContracts: + return server.getContracts(dir) + case toolGetCapabilities: + return server.getCapabilities(dir) + case toolTraceRoute: + return trace.RouteForMCP(dir, stringArg(args, "route")), nil + case toolTraceSymbol: + return trace.SymbolForMCP(dir, stringArg(args, "symbol")), nil + case toolTraceCapability: + return trace.CapabilityForMCP(dir, stringArg(args, "capability")), nil + case toolImpactSymbol: + return impact.SymbolForMCP(dir, stringArg(args, "symbol")), nil + case toolImpactFile: + return impact.FileForMCP(dir, stringArg(args, "path")), nil + case toolImpactContract: + return impact.ContractForMCP(dir, stringArg(args, "path")), nil + case toolFindSymbol: + return server.findSymbol(dir, stringArg(args, "query"), intArg(args, "limit", 50)) + case toolListCallers: + return server.listCalls(dir, stringArg(args, "symbol"), true) + case toolListCallees: + return server.listCalls(dir, stringArg(args, "symbol"), false) + case toolValidateDecision: + return decision.ValidateForMCP(dir, stringSliceArg(args, "paths")), nil + case toolListDecisions: + return decision.ValidateForMCP(dir, stringSliceArg(args, "paths")), nil + case toolGetReport: + return report.BuildForMCP(dir, stringArg(args, "ai_tasks")), nil + case toolBuildPlan: + return plan.BuildOutput(plan.OutputOptions{Dir: dir, Task: stringArg(args, "task"), Executable: boolArg(args, "executable")}) + case toolListRecipes: + return recipe.List(dir, recipe.Filter{Kind: stringArg(args, "kind"), Provider: stringArg(args, "provider")}) + case toolGetRecipe: + return recipe.Get(dir, stringArg(args, "id")) + default: + return nil, fmt.Errorf("unknown tool %q", name) + } +} + +func (server *Server) getContracts(dir string) (any, error) { + description, err := inspect.Describe(dir) + if err != nil { + return nil, err + } + var contracts []manifest.Contract + if m, err := manifest.Load(dir); err == nil { + contracts = m.Contracts + } + return mcpResult(resultKindMCPContracts, !description.Diagnostics.Failed(), description.Diagnostics, map[string]any{ + "contracts": nonNil(contracts), + "endpoints": description.Endpoints, + "grpc_services": description.GRPCServices, + "error_codes": description.ErrorCodes, + }), nil +} + +func (server *Server) getCapabilities(dir string) (any, error) { + description, err := inspect.Describe(dir) + if err != nil { + return nil, err + } + var capabilities []manifest.Capability + if m, err := manifest.Load(dir); err == nil { + capabilities = m.Capabilities + } + return mcpResult(resultKindMCPCapabilities, !description.Diagnostics.Failed(), description.Diagnostics, map[string]any{ + "capabilities": nonNil(capabilities), + "capability_kinds": description.Capabilities, + "capability_graph": description.CapabilityGraph, + "decision_directory": defaultDecisionDir(), + }), nil +} + +func (server *Server) findSymbol(dir string, query string, limit int) (any, error) { + description, err := inspect.Describe(dir) + if err != nil { + return nil, err + } + query = strings.TrimSpace(query) + var symbols []inspect.SymbolNode + for _, node := range description.SymbolGraph.Nodes { + if node.Kind == "package" || node.Kind == "file" { + continue + } + if query == "" || symbolMatches(node, query) { + symbols = append(symbols, node) + } + } + sort.Slice(symbols, func(i, j int) bool { return symbols[i].ID < symbols[j].ID }) + if limit > 0 && len(symbols) > limit { + symbols = symbols[:limit] + } + return mcpResult(resultKindMCPFindSymbol, !description.Diagnostics.Failed(), description.Diagnostics, map[string]any{ + "query": query, + "symbols": symbols, + }), nil +} + +func (server *Server) listCalls(dir string, symbol string, callers bool) (any, error) { + description, err := inspect.Describe(dir) + if err != nil { + return nil, err + } + resolved := graphquery.ResolveSymbol(description.SymbolGraph, symbol) + if !resolved.OK { + diagnostics := mcpDiagnostics(description.Diagnostics, resolved.Diagnostic) + return mcpResult(resultKindMCPCalls, false, diagnostics, map[string]any{ + "query": symbol, + "candidates": resolved.Candidates, + "relationship": chooseRelationship(callers), + }), nil + } + edges := graphquery.OutgoingEdges(description.SymbolGraph, resolved.Node.ID, graphquery.EdgeKindCalls) + if callers { + edges = graphquery.IncomingEdges(description.SymbolGraph, resolved.Node.ID, graphquery.EdgeKindCalls) + } + diagnostics := mcpDiagnostics(description.Diagnostics) + return mcpResult(resultKindMCPCalls, !diagnostics.Failed(), diagnostics, map[string]any{ + "query": symbol, + "relationship": chooseRelationship(callers), + "target": resolved.Node, + "nodes": graphquery.NodesForEdges(description.SymbolGraph, edges), + "edges": edges, + }), nil +} + +func mcpResult(resultKind string, ok bool, diagnostics diagnostic.Diagnostics, fields map[string]any) map[string]any { + diagnostics = mcpDiagnostics(diagnostics) + output := map[string]any{ + "result_kind": resultKind, + "schema_version": schemaVersionMCPResult, + "schema_ref": schemaRefMCPResult, + "ok": ok && !diagnostics.Failed(), + "diagnostics": diagnostics, + } + for key, value := range fields { + output[key] = value + } + return output +} + +func mcpDiagnostics(parts ...diagnostic.Diagnostics) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + for _, part := range parts { + diagnostics = append(diagnostics, part...) + } + if diagnostics == nil { + return diagnostic.Diagnostics{} + } + diagnostics.Sort() + return diagnostics +} + +func symbolMatches(node inspect.SymbolNode, query string) bool { + query = strings.ToLower(query) + return strings.Contains(strings.ToLower(node.ID), query) || + strings.Contains(strings.ToLower(node.Name), query) || + strings.Contains(strings.ToLower(node.Package), query) || + strings.Contains(strings.ToLower(node.File), query) +} + +func chooseRelationship(callers bool) string { + if callers { + return "callers" + } + return "callees" +} + +func (server *Server) dirArg(args map[string]any) string { + if value := stringArg(args, "dir"); value != "" { + return value + } + return server.dir +} + +func stringArg(args map[string]any, key string) string { + value, _ := args[key].(string) + return strings.TrimSpace(value) +} + +func boolArg(args map[string]any, key string) bool { + value, _ := args[key].(bool) + return value +} + +func intArg(args map[string]any, key string, fallback int) int { + switch value := args[key].(type) { + case float64: + return int(value) + case int: + return value + default: + return fallback + } +} + +func stringSliceArg(args map[string]any, key string) []string { + raw, ok := args[key].([]any) + if !ok { + if single := stringArg(args, key); single != "" { + return []string{single} + } + return nil + } + var result []string + for _, item := range raw { + value, ok := item.(string) + if ok && strings.TrimSpace(value) != "" { + result = append(result, strings.TrimSpace(value)) + } + } + return result +} + +func nonNil[T any](values []T) []T { + if values == nil { + return []T{} + } + return values +} + +func defaultDecisionDir() string { + return ".nucleus/decisions" +} + +func tool(name string, description string, inputSchema map[string]any) toolDescriptor { + return toolDescriptor{Name: name, Description: description, InputSchema: inputSchema} +} + +func objectSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{"dir": map[string]any{"type": "string"}}, + } +} + +func stringSchema(name string, description string) map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + name: map[string]any{"type": "string", "description": description}, + "dir": map[string]any{"type": "string"}, + }, + "required": []string{name}, + } +} + +func findSymbolSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "query": map[string]any{"type": "string"}, + "limit": map[string]any{"type": "integer", "minimum": 1}, + "dir": map[string]any{"type": "string"}, + }, + } +} + +func pathsSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "paths": map[string]any{"type": "array", "items": map[string]any{"type": "string"}}, + "dir": map[string]any{"type": "string"}, + }, + } +} + +func aiTasksSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "ai_tasks": map[string]any{"type": "string"}, + "dir": map[string]any{"type": "string"}, + }, + } +} + +func buildPlanSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "task": map[string]any{"type": "string"}, + "executable": map[string]any{"type": "boolean"}, + "dir": map[string]any{"type": "string"}, + }, + "required": []string{"task"}, + } +} + +func recipeListSchema() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "kind": map[string]any{"type": "string"}, + "provider": map[string]any{"type": "string"}, + "dir": map[string]any{"type": "string"}, + }, + } +} diff --git a/cmd/nucleus/internal/migrate/command.go b/cmd/nucleus/internal/migrate/command.go deleted file mode 100644 index 7cd5cb5..0000000 --- a/cmd/nucleus/internal/migrate/command.go +++ /dev/null @@ -1,73 +0,0 @@ -package migrate - -import ( - "errors" - "fmt" - - "github.com/spf13/cobra" -) - -// Config carries root-level flag values used by the migrate command. -type Config struct { - Dir *string -} - -type options struct { - fromVersion string - toVersion string - check bool - reportPath string - json bool - pretty bool -} - -// ErrMigrateFailed is returned when migration planning or checks fail. -var ErrMigrateFailed = errors.New("migrate failed") - -// NewCommand creates to migrate subcommand. -func NewCommand(config Config) *cobra.Command { - opts := &options{} - cmd := &cobra.Command{ - Use: commandUseMigrate, - Short: commandShortMigrate, - SilenceUsage: true, - SilenceErrors: true, - Args: cobra.NoArgs, - RunE: func(cmd *cobra.Command, args []string) error { - result, err := run(config, opts) - if err != nil { - return err - } - if opts.reportPath != "" { - if err := writeReport(stringValue(config.Dir, defaultDir), opts.reportPath, result); err != nil { - return err - } - } - if opts.json { - if err := renderJSON(cmd.OutOrStdout(), result, opts.pretty); err != nil { - return err - } - } else { - renderHuman(cmd.OutOrStdout(), cmd.ErrOrStderr(), result) - } - if !result.OK { - return fmt.Errorf("%w: migration diagnostics contain errors", ErrMigrateFailed) - } - return nil - }, - } - cmd.Flags().StringVar(&opts.fromVersion, flagFromVersion, "", flagHelpFromVersion) - cmd.Flags().StringVar(&opts.toVersion, flagToVersion, "", flagHelpToVersion) - cmd.Flags().BoolVar(&opts.check, flagCheck, false, flagHelpCheck) - cmd.Flags().StringVar(&opts.reportPath, flagReport, "", flagHelpReport) - cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) - cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) - return cmd -} - -func stringValue(value *string, fallback string) string { - if value == nil { - return fallback - } - return *value -} diff --git a/cmd/nucleus/internal/migrate/command_test.go b/cmd/nucleus/internal/migrate/command_test.go deleted file mode 100644 index 2af994a..0000000 --- a/cmd/nucleus/internal/migrate/command_test.go +++ /dev/null @@ -1,301 +0,0 @@ -package migrate - -import ( - "bytes" - "encoding/json" - "errors" - "os" - "path/filepath" - "strings" - "testing" -) - -func TestMigrateCommandEmitsJSONPlan(t *testing.T) { - serviceDir := t.TempDir() - writeMigrateService(t, serviceDir) - - dir := serviceDir - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--json"}) - - if err := cmd.Execute(); err != nil { - t.Fatalf("execute migrate: %v", err) - } - - var output map[string]any - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("decode migrate output: %v\n%s", err, stdout.String()) - } - assertMigrateString(t, output, "result_kind", resultKindMigrate) - assertMigrateString(t, output, "schema_version", schemaVersionMigrate) - assertMigrateString(t, output, "schema_ref", schemaRefMigrate) - assertMigrateBool(t, output, "ok", true) - assertMigrateString(t, output, "mode", modePlan) - summary := assertMigrateMap(t, output, "summary") - assertMigrateString(t, summary, "service", "demo") - assertMigrateString(t, summary, "compatibility", compatibilitySupported) - assertMigrateNumber(t, summary, "steps", 6) - assertMigrateNumber(t, summary, "contract_surfaces", 3) - migration := assertMigrateMap(t, output, "migration") - assertMigrateString(t, migration, "write_policy", writePolicyReport) - commands := assertMigrateSlice(t, migration, "commands") - if len(commands) < 3 { - t.Fatalf("commands len = %d, want at least 3", len(commands)) - } -} - -func TestMigrateCheckFailsOnStaleGeneratedTargets(t *testing.T) { - serviceDir := t.TempDir() - writeMigrateService(t, serviceDir) - writeMigrateFile(t, serviceDir, "nucleus.yaml", `schema_version: "1.0" -service: - name: demo - version: "0.1.0" -ai: - intent: Exercise migrate checks. - generated: - - contract/gen -`) - - dir := serviceDir - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--check", "--json"}) - - err := cmd.Execute() - if err == nil { - t.Fatal("execute migrate succeeded, want failure") - } - if !errors.Is(err, ErrMigrateFailed) { - t.Fatalf("error = %v, want ErrMigrateFailed", err) - } - var output map[string]any - if decodeErr := json.Unmarshal(stdout.Bytes(), &output); decodeErr != nil { - t.Fatalf("decode migrate output: %v\n%s", decodeErr, stdout.String()) - } - assertMigrateBool(t, output, "ok", false) - diagnostics := assertMigrateSlice(t, output, "diagnostics") - assertMigrateContainsDiagnostic(t, diagnostics, diagnosticGeneratedStale) -} - -func TestMigrateCommandWritesReportForRelativePath(t *testing.T) { - serviceDir := t.TempDir() - writeMigrateService(t, serviceDir) - - dir := serviceDir - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--report", "artifacts/nucleus/migrate.json"}) - - if err := cmd.Execute(); err != nil { - t.Fatalf("execute migrate: %v", err) - } - reportPath := filepath.Join(serviceDir, "artifacts", "nucleus", "migrate.json") - data, err := os.ReadFile(reportPath) - if err != nil { - t.Fatalf("read report: %v", err) - } - var output map[string]any - if err := json.Unmarshal(data, &output); err != nil { - t.Fatalf("decode report: %v\n%s", err, string(data)) - } - assertMigrateString(t, output, "result_kind", resultKindMigrate) - if !strings.Contains(stdout.String(), "OK") { - t.Fatalf("human stdout = %q, want OK", stdout.String()) - } -} - -func TestMigrateCommandRejectsMissingVersions(t *testing.T) { - dir := t.TempDir() - cmd := NewCommand(Config{Dir: &dir}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--to-version", "v0.2.0"}) - - err := cmd.Execute() - if err == nil || !strings.Contains(err.Error(), "--from-version is required") { - t.Fatalf("error = %v, want missing from-version", err) - } -} - -func TestMigrateCommandRejectsRelativeReportTraversal(t *testing.T) { - serviceDir := t.TempDir() - writeMigrateService(t, serviceDir) - - dir := serviceDir - cmd := NewCommand(Config{Dir: &dir}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--report", "../migrate.json"}) - - err := cmd.Execute() - if err == nil || !strings.Contains(err.Error(), "--report must resolve inside the service directory") { - t.Fatalf("error = %v, want report traversal rejection", err) - } -} - -func TestMigrateCommandRejectsAbsoluteReportOutsideServiceDir(t *testing.T) { - serviceDir := t.TempDir() - writeMigrateService(t, serviceDir) - outsidePath := filepath.Join(t.TempDir(), "migrate.json") - - dir := serviceDir - cmd := NewCommand(Config{Dir: &dir}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--report", outsidePath}) - - err := cmd.Execute() - if err == nil || !strings.Contains(err.Error(), "--report must resolve inside the service directory") { - t.Fatalf("error = %v, want absolute report rejection", err) - } -} - -func TestMigrateCommandRedactsInspectErrors(t *testing.T) { - serviceDir := filepath.Join(t.TempDir(), "missing-service") - dir := serviceDir - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--from-version", "v0.1.0", "--to-version", "v0.2.0", "--json"}) - - err := cmd.Execute() - if err == nil { - t.Fatal("execute migrate succeeded, want inspect failure") - } - var output map[string]any - if decodeErr := json.Unmarshal(stdout.Bytes(), &output); decodeErr != nil { - t.Fatalf("decode migrate output: %v\n%s", decodeErr, stdout.String()) - } - diagnostics := assertMigrateSlice(t, output, "diagnostics") - assertMigrateContainsDiagnostic(t, diagnostics, diagnosticInspectFailed) - for _, raw := range diagnostics { - item, ok := raw.(map[string]any) - if !ok { - continue - } - for _, key := range []string{"path", "message"} { - text, _ := item[key].(string) - if strings.Contains(text, serviceDir) { - t.Fatalf("diagnostic %s leaked absolute path %q in %#v", key, serviceDir, item) - } - } - } -} - -func writeMigrateService(t *testing.T, dir string) { - t.Helper() - writeMigrateFile(t, dir, "nucleus.yaml", `schema_version: "1.0" -service: - name: demo - version: "0.1.0" -capabilities: - - log -ai: - intent: Exercise migrate against a contract-first service. - allowed_changes: - - api/** - - internal/** - - contract/gen - generated: [] -nucleus: - providers: - log: - provider: noop -`) - writeMigrateFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 -paths: - /hello: - get: - operationId: sayHello - responses: - "200": - description: ok -`) - writeMigrateFile(t, dir, "api/errors.yaml", `errors: - - code: 1001 - message: hello failed - http_status: 500 -`) -} - -func writeMigrateFile(t *testing.T, dir string, name string, data string) { - t.Helper() - path := filepath.Join(dir, name) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(data), 0o644); err != nil { - t.Fatal(err) - } -} - -func assertMigrateMap(t *testing.T, value map[string]any, key string) map[string]any { - t.Helper() - item, ok := value[key].(map[string]any) - if !ok { - t.Fatalf("%s has type %T, want map[string]any", key, value[key]) - } - return item -} - -func assertMigrateSlice(t *testing.T, value map[string]any, key string) []any { - t.Helper() - item, ok := value[key].([]any) - if !ok { - t.Fatalf("%s has type %T, want []any", key, value[key]) - } - return item -} - -func assertMigrateString(t *testing.T, value map[string]any, key string, want string) { - t.Helper() - got, ok := value[key].(string) - if !ok { - t.Fatalf("%s has type %T, want string", key, value[key]) - } - if got != want { - t.Fatalf("%s = %q, want %q", key, got, want) - } -} - -func assertMigrateBool(t *testing.T, value map[string]any, key string, want bool) { - t.Helper() - got, ok := value[key].(bool) - if !ok { - t.Fatalf("%s has type %T, want bool", key, value[key]) - } - if got != want { - t.Fatalf("%s = %v, want %v", key, got, want) - } -} - -func assertMigrateNumber(t *testing.T, value map[string]any, key string, want float64) { - t.Helper() - got, ok := value[key].(float64) - if !ok { - t.Fatalf("%s has type %T, want number", key, value[key]) - } - if got != want { - t.Fatalf("%s = %v, want %v", key, got, want) - } -} - -func assertMigrateContainsDiagnostic(t *testing.T, diagnostics []any, code string) { - t.Helper() - for _, value := range diagnostics { - item, ok := value.(map[string]any) - if ok && item["code"] == code { - return - } - } - t.Fatalf("no diagnostic %q in %#v", code, diagnostics) -} diff --git a/cmd/nucleus/internal/migrate/constants.go b/cmd/nucleus/internal/migrate/constants.go deleted file mode 100644 index 061872d..0000000 --- a/cmd/nucleus/internal/migrate/constants.go +++ /dev/null @@ -1,80 +0,0 @@ -package migrate - -const ( - commandUseMigrate = "migrate" - commandShortMigrate = "plan Nucleus version migrations and readiness checks" - defaultDir = "." -) - -const ( - flagFromVersion = "from-version" - flagToVersion = "to-version" - flagCheck = "check" - flagReport = "report" - flagJSON = "json" - flagPretty = "pretty" -) - -const ( - flagHelpFromVersion = "source Nucleus or manifest schema version" - flagHelpToVersion = "target Nucleus or manifest schema version" - flagHelpCheck = "fail when migration readiness checks do not pass" - flagHelpReport = "write the migration result JSON to this path" - flagHelpJSON = "emit machine-readable migration result" - flagHelpPretty = "pretty-print JSON output" -) - -const ( - resultKindMigrate = "nucleus.migrate_result" - schemaVersionMigrate = "migrate.v1" - schemaRefMigrate = "contract/schema/migrate.schema.json" - jsonIndentPrefix = "" - jsonIndentValue = " " -) - -const ( - modePlan = "plan" - modeCheck = "check" -) - -const ( - compatibilityNoChange = "no_change" - compatibilitySupported = "supported" - compatibilityGenericForward = "generic_forward" - compatibilityUnsupported = "unsupported" -) - -const ( - diagnosticInspectFailed = "migrate.inspect_failed" - diagnosticInvalidVersion = "migrate.version_invalid" - diagnosticDowngrade = "migrate.downgrade_unsupported" - diagnosticGenericTransition = "migrate.generic_transition" - diagnosticGeneratedStale = "migrate.generated_stale" - diagnosticVerificationMissing = "migrate.verification_missing" - diagnosticValidationFailed = "migrate.validation_failed" -) - -const ( - checkVersionOrder = "version_order" - checkRuleRegistry = "rule_registry" - checkManifestValidation = "manifest_validation" - checkContractInspection = "contract_inspection" - checkGeneratedFreshness = "generated_freshness" - checkVerificationCommands = "verification_commands" -) - -const ( - stepInventory = "inventory" - stepManifest = "manifest" - stepContracts = "contracts" - stepGenerated = "generated" - stepCapabilities = "capabilities" - stepVerification = "verification" - stepNoChange = "no_change" - commandWorkingDir = "." - writePolicyReport = "report_only" - scopeLocalService = "local_service" - severityInfo = "info" - severityWarning = "warning" - severityError = "error" -) diff --git a/cmd/nucleus/internal/migrate/doc.go b/cmd/nucleus/internal/migrate/doc.go deleted file mode 100644 index b294ae5..0000000 --- a/cmd/nucleus/internal/migrate/doc.go +++ /dev/null @@ -1,6 +0,0 @@ -// Package migrate implements the Nucleus version migration planning command. -// -// The package is intentionally CLI-internal: it composes contract inspection, -// validation diagnostics, and local migration rules into auditable output, but -// it does not expose a public migration engine or mutate service code. -package migrate diff --git a/cmd/nucleus/internal/migrate/output.go b/cmd/nucleus/internal/migrate/output.go deleted file mode 100644 index 11ad3cc..0000000 --- a/cmd/nucleus/internal/migrate/output.go +++ /dev/null @@ -1,173 +0,0 @@ -package migrate - -import ( - "encoding/json" - "fmt" - "io" - "strings" - - "github.com/nucleuskit/contract/diagnostic" - "github.com/nucleuskit/contract/inspect" - "github.com/nucleuskit/contract/manifest" -) - -type migrateResult struct { - ResultKind string `json:"result_kind"` - SchemaVersion string `json:"schema_version"` - SchemaRef string `json:"schema_ref"` - OK bool `json:"ok"` - Mode string `json:"mode"` - Summary migrateSummary `json:"summary"` - Diagnostics diagnostic.Diagnostics `json:"diagnostics"` - Migration *migrationPlan `json:"migration,omitempty"` -} - -type migrateSummary struct { - Service string `json:"service,omitempty"` - FromVersion string `json:"from_version"` - ToVersion string `json:"to_version"` - Compatibility string `json:"compatibility"` - Steps int `json:"steps"` - RequiredEdits int `json:"required_edits"` - AdvisoryEdits int `json:"advisory_edits"` - Checks int `json:"checks"` - Commands int `json:"commands"` - Errors int `json:"errors"` - Warnings int `json:"warnings"` - GeneratedFresh bool `json:"generated_fresh"` - ContractSurfaces int `json:"contract_surfaces"` - CapabilityCount int `json:"capability_count"` -} - -type migrationPlan struct { - Service manifest.Service `json:"service"` - Scope string `json:"scope"` - WritePolicy string `json:"write_policy"` - FromVersion versionInfo `json:"from_version"` - ToVersion versionInfo `json:"to_version"` - Compatibility string `json:"compatibility"` - ContractFirst bool `json:"contract_first"` - DescriptionSchema string `json:"description_schema"` - RequiredEdits []migrationEdit `json:"required_edits"` - AdvisoryEdits []migrationEdit `json:"advisory_edits"` - Checks []migrationCheck `json:"checks"` - Steps []migrationStep `json:"steps"` - Commands []migrationCommand `json:"commands"` - Risks []migrationRisk `json:"risks"` - GeneratedFreshness []inspect.GeneratedFreshness `json:"generated_freshness"` - DeclaredCapabilities []string `json:"declared_capabilities"` -} - -type versionInfo struct { - Raw string `json:"raw"` - Normalized string `json:"normalized"` - Known bool `json:"known"` -} - -type migrationEdit struct { - Path string `json:"path"` - Kind string `json:"kind"` - Required bool `json:"required"` - Reason string `json:"reason"` -} - -type migrationCheck struct { - ID string `json:"id"` - Pass bool `json:"pass"` - Severity string `json:"severity"` - Subject string `json:"subject,omitempty"` - Reason string `json:"reason"` -} - -type migrationStep struct { - ID string `json:"id"` - Sequence int `json:"sequence"` - Title string `json:"title"` - Purpose string `json:"purpose"` - Edits []string `json:"edits,omitempty"` - Commands []string `json:"commands,omitempty"` -} - -type migrationCommand struct { - Phase string `json:"phase"` - Command string `json:"command"` - WorkingDir string `json:"working_dir"` - Reason string `json:"reason"` -} - -type migrationRisk struct { - ID string `json:"id"` - Severity string `json:"severity"` - Reason string `json:"reason"` - Mitigation string `json:"mitigation"` -} - -func renderHuman(stdout io.Writer, stderr io.Writer, result migrateResult) { - for _, item := range result.Diagnostics { - _, _ = fmt.Fprintf(stderr, "%s %s %s: %s\n", item.Severity, item.Path, item.Code, item.Message) - } - if result.OK { - _, _ = fmt.Fprintln(stdout, "OK") - } else { - _, _ = fmt.Fprintln(stderr, "FAILED") - } - _, _ = fmt.Fprintf(stdout, "mode: %s\n", result.Mode) - _, _ = fmt.Fprintf(stdout, "migration: %s -> %s\n", result.Summary.FromVersion, result.Summary.ToVersion) - if result.Summary.Service != "" { - _, _ = fmt.Fprintf(stdout, "service: %s\n", result.Summary.Service) - } - _, _ = fmt.Fprintf(stdout, "compatibility: %s\n", result.Summary.Compatibility) - _, _ = fmt.Fprintf(stdout, "steps: %d\n", result.Summary.Steps) - _, _ = fmt.Fprintf(stdout, "edits: %d required, %d advisory\n", result.Summary.RequiredEdits, result.Summary.AdvisoryEdits) - _, _ = fmt.Fprintf(stdout, "checks: %d\n", result.Summary.Checks) - if result.Migration != nil && len(result.Migration.Commands) > 0 { - commands := make([]string, 0, len(result.Migration.Commands)) - for _, command := range result.Migration.Commands { - commands = append(commands, command.Command) - } - _, _ = fmt.Fprintf(stdout, "commands: %s\n", strings.Join(commands, " -> ")) - } - _, _ = fmt.Fprintf(stdout, "diagnostics: %d errors, %d warnings\n", result.Summary.Errors, result.Summary.Warnings) -} - -func renderJSON(writer io.Writer, result migrateResult, pretty bool) error { - result = finalizeResult(result) - encoder := json.NewEncoder(writer) - encoder.SetEscapeHTML(false) - if pretty { - encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) - } - return encoder.Encode(result) -} - -func finalizeResult(result migrateResult) migrateResult { - result.ResultKind = resultKindMigrate - result.SchemaVersion = schemaVersionMigrate - result.SchemaRef = schemaRefMigrate - if result.Diagnostics == nil { - result.Diagnostics = diagnostic.Diagnostics{} - } - result.Diagnostics.Sort() - result.OK = !result.Diagnostics.Failed() - result.Summary.Errors = result.Diagnostics.Count(diagnostic.SeverityError) - result.Summary.Warnings = result.Diagnostics.Count(diagnostic.SeverityWarning) - return result -} - -func errorDiagnostic(path string, code string, message string) diagnostic.Diagnostic { - return diagnostic.Diagnostic{ - Severity: diagnostic.SeverityError, - Code: code, - Path: path, - Message: message, - } -} - -func warningDiagnostic(path string, code string, message string) diagnostic.Diagnostic { - return diagnostic.Diagnostic{ - Severity: diagnostic.SeverityWarning, - Code: code, - Path: path, - Message: message, - } -} diff --git a/cmd/nucleus/internal/migrate/paths.go b/cmd/nucleus/internal/migrate/paths.go deleted file mode 100644 index 978ee06..0000000 --- a/cmd/nucleus/internal/migrate/paths.go +++ /dev/null @@ -1,94 +0,0 @@ -package migrate - -import ( - "encoding/json" - "errors" - "fmt" - "os" - "path/filepath" - "strings" -) - -func writeReport(serviceDir string, reportPath string, result migrateResult) error { - path, err := resolveReportPath(serviceDir, reportPath) - if err != nil { - return err - } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return err - } - var buffer strings.Builder - encoder := json.NewEncoder(&buffer) - encoder.SetEscapeHTML(false) - encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) - if err := encoder.Encode(finalizeResult(result)); err != nil { - return err - } - return os.WriteFile(path, []byte(buffer.String()), 0o644) -} - -func resolveReportPath(serviceDir string, reportPath string) (string, error) { - reportPath = strings.TrimSpace(reportPath) - serviceAbs, err := filepath.Abs(serviceDir) - if err != nil { - return "", err - } - var target string - if filepath.IsAbs(reportPath) { - target = filepath.Clean(reportPath) - } else { - cleaned := filepath.Clean(reportPath) - if cleaned == "." { - return "", fmt.Errorf("--%s must resolve inside the service directory", flagReport) - } - target = filepath.Join(serviceAbs, cleaned) - } - targetAbs, err := filepath.Abs(target) - if err != nil { - return "", err - } - rel, err := filepath.Rel(serviceAbs, targetAbs) - if err != nil || !isContainedRelativePath(rel) { - return "", fmt.Errorf("--%s must resolve inside the service directory", flagReport) - } - return targetAbs, nil -} - -func safeMigratePath(path string) string { - if strings.TrimSpace(path) == "" { - return "" - } - clean := filepath.Clean(path) - if !filepath.IsAbs(clean) { - return filepath.ToSlash(clean) - } - if cwd, err := os.Getwd(); err == nil { - if rel, err := filepath.Rel(cwd, clean); err == nil && isContainedRelativePath(rel) { - return filepath.ToSlash(rel) - } - } - base := filepath.Base(clean) - parent := filepath.Base(filepath.Dir(clean)) - if parent != "." && parent != string(filepath.Separator) && parent != "" { - return filepath.ToSlash(filepath.Join("", parent, base)) - } - return filepath.ToSlash(filepath.Join("", base)) -} - -func isContainedRelativePath(path string) bool { - if path == "." { - return true - } - if path == ".." { - return false - } - return !strings.HasPrefix(path, ".."+string(filepath.Separator)) -} - -func safeErrorMessage(err error) string { - var pathErr *os.PathError - if errors.As(err, &pathErr) { - return pathErr.Op + " failed: " + pathErr.Err.Error() - } - return err.Error() -} diff --git a/cmd/nucleus/internal/migrate/run.go b/cmd/nucleus/internal/migrate/run.go deleted file mode 100644 index c12f570..0000000 --- a/cmd/nucleus/internal/migrate/run.go +++ /dev/null @@ -1,536 +0,0 @@ -package migrate - -import ( - "fmt" - "path/filepath" - "sort" - "strconv" - "strings" - - "github.com/nucleuskit/contract/diagnostic" - "github.com/nucleuskit/contract/inspect" - "github.com/nucleuskit/contract/validation" -) - -type parsedVersion struct { - raw string - normalized string - major int - minor int - patch int - known bool -} - -type migrationRule struct { - fromMajor int - fromMinor int - toMajor int - toMinor int -} - -var knownRules = []migrationRule{ - {fromMajor: 0, fromMinor: 1, toMajor: 0, toMinor: 2}, - {fromMajor: 0, fromMinor: 2, toMajor: 0, toMinor: 3}, - {fromMajor: 1, fromMinor: 0, toMajor: 1, toMinor: 1}, -} - -func run(config Config, opts *options) (migrateResult, error) { - if err := validateOptions(opts); err != nil { - return migrateResult{}, err - } - dir := stringValue(config.Dir, defaultDir) - mode := modePlan - if opts.check { - mode = modeCheck - } - from, fromErr := parseVersion(opts.fromVersion) - to, toErr := parseVersion(opts.toVersion) - - description, inspectErr := inspect.Describe(dir) - if inspectErr != nil { - result := migrateResult{ - Mode: mode, - Summary: migrateSummary{ - FromVersion: strings.TrimSpace(opts.fromVersion), - ToVersion: strings.TrimSpace(opts.toVersion), - Compatibility: compatibilityUnsupported, - }, - Diagnostics: diagnostic.Diagnostics{ - errorDiagnostic(safeMigratePath(dir), diagnosticInspectFailed, safeErrorMessage(inspectErr)), - }, - } - return finalizeResult(result), nil - } - - validationPassed, diagnostics := validationDiagnostics(dir, mode) - if fromErr != nil { - diagnostics = append(diagnostics, errorDiagnostic(flagFromVersion, diagnosticInvalidVersion, fromErr.Error())) - } - if toErr != nil { - diagnostics = append(diagnostics, errorDiagnostic(flagToVersion, diagnosticInvalidVersion, toErr.Error())) - } - if fromErr == nil && toErr == nil { - diagnostics = append(diagnostics, versionDiagnostics(from, to)...) - } - - compatibility := compatibilityForVersions(from, to, fromErr, toErr) - plan := buildMigrationPlan(description, from, to, compatibility, mode, validationPassed) - diagnostics = append(diagnostics, checkDiagnostics(plan.Checks, mode)...) - result := migrateResult{ - Mode: mode, - Summary: buildSummary(plan, diagnostics), - Diagnostics: diagnostics, - Migration: &plan, - } - return finalizeResult(result), nil -} - -func validateOptions(opts *options) error { - if strings.TrimSpace(opts.fromVersion) == "" { - return fmt.Errorf("--%s is required", flagFromVersion) - } - if strings.TrimSpace(opts.toVersion) == "" { - return fmt.Errorf("--%s is required", flagToVersion) - } - return nil -} - -func parseVersion(value string) (parsedVersion, error) { - raw := strings.TrimSpace(value) - trimmed := strings.TrimPrefix(raw, "v") - if trimmed == "" { - return parsedVersion{raw: raw, normalized: raw}, fmt.Errorf("version %q is empty", raw) - } - parts := strings.Split(trimmed, ".") - if len(parts) < 2 || len(parts) > 3 { - return parsedVersion{raw: raw, normalized: raw}, fmt.Errorf("version %q must look like MAJOR.MINOR or MAJOR.MINOR.PATCH", raw) - } - numbers := []int{0, 0, 0} - for index, part := range parts { - if part == "" { - return parsedVersion{raw: raw, normalized: raw}, fmt.Errorf("version %q contains an empty component", raw) - } - number, err := strconv.Atoi(part) - if err != nil || number < 0 { - return parsedVersion{raw: raw, normalized: raw}, fmt.Errorf("version %q must contain only non-negative numeric components", raw) - } - numbers[index] = number - } - normalized := fmt.Sprintf("%d.%d.%d", numbers[0], numbers[1], numbers[2]) - return parsedVersion{ - raw: raw, - normalized: normalized, - major: numbers[0], - minor: numbers[1], - patch: numbers[2], - known: isKnownVersion(numbers[0], numbers[1]), - }, nil -} - -func isKnownVersion(major int, minor int) bool { - for _, rule := range knownRules { - if rule.fromMajor == major && rule.fromMinor == minor { - return true - } - if rule.toMajor == major && rule.toMinor == minor { - return true - } - } - return false -} - -func compareVersions(from parsedVersion, to parsedVersion) int { - for _, pair := range [][2]int{{from.major, to.major}, {from.minor, to.minor}, {from.patch, to.patch}} { - if pair[0] < pair[1] { - return -1 - } - if pair[0] > pair[1] { - return 1 - } - } - return 0 -} - -func compatibilityForVersions(from parsedVersion, to parsedVersion, fromErr error, toErr error) string { - if fromErr != nil || toErr != nil { - return compatibilityUnsupported - } - switch compareVersions(from, to) { - case 0: - return compatibilityNoChange - case 1: - return compatibilityUnsupported - } - if hasExactRule(from, to) { - return compatibilitySupported - } - return compatibilityGenericForward -} - -func hasExactRule(from parsedVersion, to parsedVersion) bool { - for _, rule := range knownRules { - if rule.fromMajor == from.major && rule.fromMinor == from.minor && rule.toMajor == to.major && rule.toMinor == to.minor { - return true - } - } - return false -} - -func versionDiagnostics(from parsedVersion, to parsedVersion) diagnostic.Diagnostics { - switch compareVersions(from, to) { - case 1: - return diagnostic.Diagnostics{ - errorDiagnostic(flagToVersion, diagnosticDowngrade, "downgrade migrations are not supported"), - } - case 0: - return nil - } - if hasExactRule(from, to) { - return nil - } - return diagnostic.Diagnostics{ - warningDiagnostic(flagToVersion, diagnosticGenericTransition, "no exact migration rule is registered; using the generic forward migration checklist"), - } -} - -func validationDiagnostics(dir string, mode string) (bool, diagnostic.Diagnostics) { - diagnostics := validation.ValidateService(dir) - passed := !diagnostics.Failed() - var mapped diagnostic.Diagnostics - for _, item := range diagnostics { - severity := item.Severity - if mode == modePlan && severity == diagnostic.SeverityError { - severity = diagnostic.SeverityWarning - } - mapped = append(mapped, diagnostic.Diagnostic{ - Severity: severity, - Code: diagnosticValidationFailed, - Path: item.Path, - Message: item.Code + ": " + item.Message, - }) - } - return passed, mapped -} - -func buildMigrationPlan(description inspect.Description, from parsedVersion, to parsedVersion, compatibility string, mode string, validationPassed bool) migrationPlan { - requiredEdits, advisoryEdits := migrationEdits(description, compatibility) - commands := migrationCommands(description) - checks := migrationChecks(description, compatibility, mode, validationPassed) - steps := migrationSteps(requiredEdits, advisoryEdits, commands, compatibility) - return migrationPlan{ - Service: description.Service, - Scope: scopeLocalService, - WritePolicy: writePolicyReport, - FromVersion: toVersionInfo(from), - ToVersion: toVersionInfo(to), - Compatibility: compatibility, - ContractFirst: true, - DescriptionSchema: description.SchemaVersion, - RequiredEdits: nonNilEdits(requiredEdits), - AdvisoryEdits: nonNilEdits(advisoryEdits), - Checks: nonNilChecks(checks), - Steps: nonNilSteps(steps), - Commands: nonNilCommands(commands), - Risks: nonNilRisks(migrationRisks(description, compatibility)), - GeneratedFreshness: append([]inspect.GeneratedFreshness{}, description.GeneratedFreshness...), - DeclaredCapabilities: append([]string{}, description.Capabilities...), - } -} - -func toVersionInfo(version parsedVersion) versionInfo { - return versionInfo{ - Raw: version.raw, - Normalized: version.normalized, - Known: version.known, - } -} - -func migrationEdits(description inspect.Description, compatibility string) ([]migrationEdit, []migrationEdit) { - if compatibility == compatibilityNoChange { - return nil, []migrationEdit{ - {Path: "nucleus.yaml", Kind: "manifest", Required: false, Reason: "confirm service metadata already matches the target version"}, - } - } - required := []migrationEdit{ - {Path: "nucleus.yaml", Kind: "manifest", Required: true, Reason: "record target Nucleus compatibility and service metadata changes"}, - {Path: "AGENTS.md", Kind: "agent_contract", Required: true, Reason: "keep AI edit boundaries and verification instructions aligned with the target version"}, - } - if len(description.Endpoints) > 0 { - required = append(required, migrationEdit{Path: "api/openapi.yaml", Kind: "http_contract", Required: true, Reason: "review HTTP contract compatibility before regenerating service code"}) - } - for _, service := range description.GRPCServices { - required = append(required, migrationEdit{Path: service.Source, Kind: "grpc_contract", Required: true, Reason: "review gRPC contract compatibility before regenerating service code"}) - } - if len(description.ErrorCodes) > 0 { - required = append(required, migrationEdit{Path: "api/errors.yaml", Kind: "error_contract", Required: true, Reason: "preserve stable external error mapping during migration"}) - } - for _, item := range description.GeneratedFreshness { - required = append(required, migrationEdit{Path: item.Target, Kind: "generated", Required: true, Reason: "regenerate after contract or manifest changes"}) - } - advisory := []migrationEdit{ - {Path: "docs/**", Kind: "documentation", Required: false, Reason: "document behavior or compatibility changes visible to service owners"}, - } - if len(description.Capabilities) > 0 { - advisory = append(advisory, - migrationEdit{Path: "go.mod", Kind: "dependency", Required: false, Reason: "confirm module requirements match capability adapters used by the target version"}, - migrationEdit{Path: "configs/**", Kind: "configuration", Required: false, Reason: "review provider configuration keys and defaults"}, - ) - } - return uniqueEdits(required), uniqueEdits(advisory) -} - -func migrationChecks(description inspect.Description, compatibility string, mode string, validationPassed bool) []migrationCheck { - generatedFresh := generatedFreshnessPass(description.GeneratedFreshness) - commandsDeclared := len(description.Verification.Commands) > 0 - rulePass := compatibility == compatibilitySupported || compatibility == compatibilityNoChange - return []migrationCheck{ - {ID: checkContractInspection, Pass: true, Severity: severityInfo, Subject: "contract/inspect", Reason: "service manifest and contract facts loaded"}, - {ID: checkVersionOrder, Pass: compatibility != compatibilityUnsupported, Severity: checkSeverity(compatibility != compatibilityUnsupported, severityError), Subject: "version", Reason: chooseCheckReason(compatibility != compatibilityUnsupported, "target version is not older than source version", "target version is older than source version or invalid")}, - {ID: checkRuleRegistry, Pass: rulePass, Severity: checkSeverity(rulePass, severityWarning), Subject: "migration_rules", Reason: chooseCheckReason(rulePass, "exact migration rule is registered", "using generic forward migration checklist")}, - {ID: checkManifestValidation, Pass: validationPassed, Severity: checkSeverity(validationPassed, chooseModeSeverity(mode)), Subject: "nucleus.yaml", Reason: chooseCheckReason(validationPassed, "manifest and contract validation passed", "manifest or contract validation diagnostics are emitted separately")}, - {ID: checkGeneratedFreshness, Pass: generatedFresh, Severity: checkSeverity(generatedFresh, chooseModeSeverity(mode)), Subject: "ai.generated", Reason: chooseCheckReason(generatedFresh, "generated targets are fresh or not declared", "generated targets are stale or missing freshness markers")}, - {ID: checkVerificationCommands, Pass: commandsDeclared, Severity: checkSeverity(commandsDeclared, chooseModeSeverity(mode)), Subject: "verification", Reason: chooseCheckReason(commandsDeclared, "verification commands are declared", "verification commands are missing")}, - } -} - -func migrationSteps(requiredEdits []migrationEdit, advisoryEdits []migrationEdit, commands []migrationCommand, compatibility string) []migrationStep { - if compatibility == compatibilityNoChange { - return []migrationStep{ - {ID: stepNoChange, Sequence: 1, Title: "Confirm no-op migration", Purpose: "Verify source and target versions already match.", Commands: commandStrings(commands)}, - } - } - contractEdits := editPathsByKind(requiredEdits, "http_contract", "grpc_contract", "error_contract") - generatedEdits := editPathsByKind(requiredEdits, "generated") - capabilityEdits := editPathsByKind(advisoryEdits, "dependency", "configuration") - return []migrationStep{ - {ID: stepInventory, Sequence: 1, Title: "Inventory current service facts", Purpose: "Load manifest, contracts, capabilities, generated freshness, and verification metadata before editing.", Commands: []string{"nucleus describe --dir . --json"}}, - {ID: stepManifest, Sequence: 2, Title: "Update manifest and agent contract", Purpose: "Move version metadata and AI-safe edit boundaries first so subsequent edits remain auditable.", Edits: editPathsByKind(requiredEdits, "manifest", "agent_contract")}, - {ID: stepContracts, Sequence: 3, Title: "Review contract surfaces", Purpose: "Apply HTTP, gRPC, and error catalog compatibility changes before generated code refresh.", Edits: contractEdits}, - {ID: stepGenerated, Sequence: 4, Title: "Refresh generated artifacts", Purpose: "Regenerate derived code and metadata after contract-first changes.", Edits: generatedEdits, Commands: []string{"nucleus gen --dir ."}}, - {ID: stepCapabilities, Sequence: 5, Title: "Review capability wiring", Purpose: "Confirm optional provider and configuration wiring without leaking provider SDKs into kernel layers.", Edits: capabilityEdits}, - {ID: stepVerification, Sequence: 6, Title: "Run AI-safe verification loop", Purpose: "Validate, lint, and verify the migrated service with stable evidence.", Commands: commandStrings(commands)}, - } -} - -func migrationCommands(description inspect.Description) []migrationCommand { - commands := []migrationCommand{ - {Phase: "validate", Command: "nucleus validate --dir . --json", WorkingDir: commandWorkingDir, Reason: "validate manifest and contract sources"}, - {Phase: "lint", Command: "nucleus lint --dir . --strict --json", WorkingDir: commandWorkingDir, Reason: "enforce strict contract and capability rules"}, - {Phase: "verify", Command: "nucleus verify --dir . --json", WorkingDir: commandWorkingDir, Reason: "run the full verification gate and emit evidence"}, - } - for _, command := range description.Verification.Commands { - if !containsCommand(commands, command) { - commands = append(commands, migrationCommand{Phase: "declared", Command: command, WorkingDir: commandWorkingDir, Reason: "declared by service inspection metadata"}) - } - } - return commands -} - -func migrationRisks(description inspect.Description, compatibility string) []migrationRisk { - risks := []migrationRisk{ - {ID: "manual_code_changes", Severity: severityInfo, Reason: "migrate is report-only and does not rewrite service code", Mitigation: "apply edits through describe -> plan -> gen -> lint -> verify"}, - {ID: "version_rule_coverage", Severity: chooseRiskSeverity(compatibility == compatibilityGenericForward), Reason: chooseCheckReason(compatibility == compatibilityGenericForward, "no exact version rule is registered", "exact or no-op migration path is available"), Mitigation: "review generic checklist before treating the result as release evidence"}, - } - if !generatedFreshnessPass(description.GeneratedFreshness) { - risks = append(risks, migrationRisk{ID: "stale_generated_artifacts", Severity: severityWarning, Reason: "one or more generated targets are stale or missing freshness metadata", Mitigation: "run nucleus gen and nucleus verify before migration sign-off"}) - } - if len(description.Capabilities) > 0 { - risks = append(risks, migrationRisk{ID: "capability_provider_wiring", Severity: severityInfo, Reason: "capabilities may involve optional provider adapters outside core contracts", Mitigation: "keep provider-specific options in bridge or service wiring"}) - } - return risks -} - -func buildSummary(plan migrationPlan, diagnostics diagnostic.Diagnostics) migrateSummary { - contractSurfaces := 0 - for _, edit := range plan.RequiredEdits { - if strings.Contains(edit.Kind, "contract") { - contractSurfaces++ - } - } - return migrateSummary{ - Service: plan.Service.Name, - FromVersion: plan.FromVersion.Normalized, - ToVersion: plan.ToVersion.Normalized, - Compatibility: plan.Compatibility, - Steps: len(plan.Steps), - RequiredEdits: len(plan.RequiredEdits), - AdvisoryEdits: len(plan.AdvisoryEdits), - Checks: len(plan.Checks), - Commands: len(plan.Commands), - Errors: diagnostics.Count(diagnostic.SeverityError), - Warnings: diagnostics.Count(diagnostic.SeverityWarning), - GeneratedFresh: generatedFreshnessPass(plan.GeneratedFreshness), - ContractSurfaces: contractSurfaces, - CapabilityCount: len(plan.DeclaredCapabilities), - } -} - -func checkDiagnostics(checks []migrationCheck, mode string) diagnostic.Diagnostics { - var diagnostics diagnostic.Diagnostics - for _, check := range checks { - if check.Pass { - continue - } - switch check.ID { - case checkGeneratedFreshness: - if mode == modeCheck { - diagnostics = append(diagnostics, errorDiagnostic(check.Subject, diagnosticGeneratedStale, check.Reason)) - } else { - diagnostics = append(diagnostics, warningDiagnostic(check.Subject, diagnosticGeneratedStale, check.Reason)) - } - case checkVerificationCommands: - if mode == modeCheck { - diagnostics = append(diagnostics, errorDiagnostic(check.Subject, diagnosticVerificationMissing, check.Reason)) - } else { - diagnostics = append(diagnostics, warningDiagnostic(check.Subject, diagnosticVerificationMissing, check.Reason)) - } - } - } - return diagnostics -} - -func generatedFreshnessPass(items []inspect.GeneratedFreshness) bool { - for _, item := range items { - if !item.Fresh { - return false - } - } - return true -} - -func chooseModeSeverity(mode string) string { - if mode == modeCheck { - return severityError - } - return severityWarning -} - -func checkSeverity(pass bool, failureSeverity string) string { - if pass { - return severityInfo - } - return failureSeverity -} - -func chooseRiskSeverity(condition bool) string { - if condition { - return severityWarning - } - return severityInfo -} - -func chooseCheckReason(ok bool, pass string, fail string) string { - if ok { - return pass - } - return fail -} - -func uniqueEdits(values []migrationEdit) []migrationEdit { - seen := map[string]struct{}{} - var unique []migrationEdit - for _, value := range values { - path := strings.TrimSpace(value.Path) - if path == "" { - continue - } - path = filepath.ToSlash(path) - key := value.Kind + "\x00" + path - if _, ok := seen[key]; ok { - continue - } - seen[key] = struct{}{} - value.Path = path - unique = append(unique, value) - } - sort.SliceStable(unique, func(i, j int) bool { - if unique[i].Kind == unique[j].Kind { - return unique[i].Path < unique[j].Path - } - return unique[i].Kind < unique[j].Kind - }) - return unique -} - -func editPathsByKind(edits []migrationEdit, kinds ...string) []string { - allowed := map[string]struct{}{} - for _, kind := range kinds { - allowed[kind] = struct{}{} - } - var paths []string - for _, edit := range edits { - if _, ok := allowed[edit.Kind]; ok { - paths = append(paths, edit.Path) - } - } - return uniqueStrings(paths) -} - -func commandStrings(commands []migrationCommand) []string { - values := make([]string, 0, len(commands)) - for _, command := range commands { - values = append(values, command.Command) - } - return uniqueStrings(values) -} - -func uniqueStrings(values []string) []string { - seen := map[string]struct{}{} - var unique []string - for _, value := range values { - value = strings.TrimSpace(value) - if value == "" { - continue - } - if _, ok := seen[value]; ok { - continue - } - seen[value] = struct{}{} - unique = append(unique, value) - } - return unique -} - -func containsCommand(commands []migrationCommand, command string) bool { - for _, existing := range commands { - if existing.Command == command { - return true - } - } - return false -} - -func nonNilEdits(values []migrationEdit) []migrationEdit { - if values == nil { - return []migrationEdit{} - } - return values -} - -func nonNilChecks(values []migrationCheck) []migrationCheck { - if values == nil { - return []migrationCheck{} - } - return values -} - -func nonNilSteps(values []migrationStep) []migrationStep { - if values == nil { - return []migrationStep{} - } - return values -} - -func nonNilCommands(values []migrationCommand) []migrationCommand { - if values == nil { - return []migrationCommand{} - } - return values -} - -func nonNilRisks(values []migrationRisk) []migrationRisk { - if values == nil { - return []migrationRisk{} - } - return values -} diff --git a/cmd/nucleus/internal/plan/constants.go b/cmd/nucleus/internal/plan/constants.go index 16895d0..625cc80 100644 --- a/cmd/nucleus/internal/plan/constants.go +++ b/cmd/nucleus/internal/plan/constants.go @@ -25,10 +25,11 @@ const ( resultKindExecutablePlan = "nucleus.executable_plan_result" planKind = "nucleus.plan" executablePlanKind = "nucleus.executable_plan" - schemaVersionPlan = "plan.v1" - schemaVersionExecutable = "plan.v3" - schemaRefPlanExecutable = "contract/schema/plan-executable.schema.json" - schemaRefEvidence = "contract/schema/evidence.schema.json" + schemaVersionPlan = "plan-result.v1" + schemaVersionExecutable = "plan-executable.v1" + schemaRefPlan = "contract/schema/plan-result.v1.schema.json" + schemaRefPlanExecutable = "contract/schema/plan-executable.v1.schema.json" + schemaRefEvidence = "contract/schema/evidence.v1.schema.json" evidenceKindApply = "nucleus.apply_evidence" evidenceKindExecutor = "nucleus.executor_evidence" evidenceKindHTTPScenario = "nucleus.http_scenario_evidence" @@ -46,15 +47,15 @@ const ( ) const ( - commandValidate = "nucleus validate --dir ." - commandLintStrict = "nucleus lint --dir . --strict" - commandVerifyJSON = "nucleus verify --dir . --json" + commandValidate = "nucleus validate --dir ." + commandLintStrict = "nucleus lint --dir . --strict" + commandVerifyJSON = "nucleus verify --dir . --json" + commandDescribeFlow = "nucleus describe --dir . --json --flow" ) const ( commandPhaseGenerate = "generate" commandPhaseLint = "lint" - commandPhaseScaffold = "scaffold" commandPhaseTest = "test" commandPhaseValidate = "validate" commandPhaseVerify = "verify" @@ -64,6 +65,5 @@ const ( commandProducesExit = "command_exit" ) -const ( - modulePathPrefix = "github.com/nucleuskit/" -) +const decisionSchemaRef = "contract/schema/decision.v1.schema.json" +const recipeSchemaRef = "contract/schema/recipe.v1.schema.json" diff --git a/cmd/nucleus/internal/plan/decisions.go b/cmd/nucleus/internal/plan/decisions.go new file mode 100644 index 0000000..15a9570 --- /dev/null +++ b/cmd/nucleus/internal/plan/decisions.go @@ -0,0 +1,124 @@ +package plan + +import ( + "strings" + + "github.com/nucleuskit/contract/manifest" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/decision" +) + +type lockedDecisionBlock struct { + DecisionID string `json:"decision_id"` + Path string `json:"path"` + Capability string `json:"capability"` + Provider string `json:"provider,omitempty"` + Library string `json:"library,omitempty"` + Driver string `json:"driver,omitempty"` + Hash string `json:"hash"` + Matched []string `json:"matched"` + Reason string `json:"reason"` + RequiredAction string `json:"required_action"` +} + +func lockedDecisionBlocks(task string, requestedCapabilities []string, capabilities []manifest.Capability, state decision.PlanState) []lockedDecisionBlock { + if len(state.Locked) == 0 || supersedeIntent(task) || !providerChangeIntent(task) { + return []lockedDecisionBlock{} + } + var blocks []lockedDecisionBlock + for _, locked := range state.Locked { + if state.Supersedes[locked.ID] { + continue + } + matches := lockedDecisionMatches(task, requestedCapabilities, capabilities, locked) + if len(matches) == 0 { + continue + } + blocks = append(blocks, lockedDecisionBlock{ + DecisionID: locked.ID, + Path: locked.Path, + Capability: locked.Capability, + Provider: locked.Provider, + Library: locked.Library, + Driver: locked.Driver, + Hash: locked.Hash, + Matched: matches, + Reason: "task appears to change provider/library/driver protected by a locked decision", + RequiredAction: "create and validate a supersede decision with supersedes and supersedes_hash before planning implementation changes", + }) + } + if blocks == nil { + return []lockedDecisionBlock{} + } + return blocks +} + +func lockedDecisionMatches(task string, requested []string, capabilities []manifest.Capability, locked decision.LockedChoice) []string { + var matches []string + text := normalizedDecisionTask(task) + if containsDecisionToken(text, locked.Capability) { + matches = append(matches, "capability:"+locked.Capability) + } + for _, value := range []string{locked.Provider, locked.Library, locked.Driver} { + if containsDecisionToken(text, value) { + matches = append(matches, "locked_choice:"+value) + } + } + for _, capability := range capabilities { + if capability.ID != locked.Capability { + continue + } + for _, value := range []string{capability.ID, capability.Kind} { + if containsDecisionToken(text, value) || hasString(requested, value) { + matches = append(matches, "capability:"+value) + } + } + } + return uniqueStrings(matches) +} + +func providerChangeIntent(task string) bool { + return containsAny(task, + "replace", "switch", "migrate", "change provider", "change library", "change driver", + "use xorm", "use gorm", "use database/sql", "use mysql", "use postgres", + "替换", "切换", "改用", "更换", "迁移", "换成", "使用 xorm", "使用 gorm", + ) +} + +func supersedeIntent(task string) bool { + return containsAny(task, "supersede", "supersedes", "supersede decision", "替代决策", "取代决策", "新 supersede") +} + +func normalizedDecisionTask(task string) string { + task = strings.ToLower(strings.ReplaceAll(task, "\\", "/")) + replacer := strings.NewReplacer("-", " ", "_", " ", ".", " ", ",", " ", ":", " ", ";", " ", "\n", " ", "\t", " ") + return " " + strings.Join(strings.Fields(replacer.Replace(task)), " ") + " " +} + +func containsDecisionToken(text string, value string) bool { + value = strings.TrimSpace(strings.ToLower(value)) + if value == "" { + return false + } + normalized := normalizedDecisionTask(value) + normalized = strings.TrimSpace(normalized) + if normalized == "" { + return false + } + return strings.Contains(text, " "+normalized+" ") +} + +func blockedDecisionCount(output map[string]any) int { + switch items := output["blocked_decisions"].(type) { + case []lockedDecisionBlock: + return len(items) + case []map[string]any: + return len(items) + case []any: + return len(items) + default: + if summary, ok := output["summary"].(planSummary); ok { + return summary.BlockedDecisions + } + return 0 + } +} diff --git a/cmd/nucleus/internal/plan/executable.go b/cmd/nucleus/internal/plan/executable.go index 544fe95..becd2fd 100644 --- a/cmd/nucleus/internal/plan/executable.go +++ b/cmd/nucleus/internal/plan/executable.go @@ -12,13 +12,16 @@ func BuildExecutable(plan map[string]any) map[string]any { contractFirst, _ := plan["contract_first"].(bool) suggestedEdits := anyStringSlice(plan["suggested_edits"]) blockedEdits := anyStringSlice(plan["blocked_edits"]) + blockedDecisions, _ := plan["blocked_decisions"].([]lockedDecisionBlock) commands := anyStringSlice(plan["commands"]) risks := anyStringSlice(plan["risks"]) + impact, _ := plan["impact_summary"].(impactSummary) + diagnostics := mapDiagnostics(plan["diagnostics"]) return map[string]any{ "result_kind": resultKindExecutablePlan, - "ok": len(blockedEdits) == 0, - "summary": buildSummary(taskType, contractFirst, suggestedEdits, blockedEdits, commands, risks), + "ok": len(blockedEdits) == 0 && blockedDecisionCount(plan) == 0 && !diagnostics.Failed(), + "summary": buildSummary(taskType, contractFirst, suggestedEdits, blockedEdits, blockedDecisions, commands, risks, impact), "schema_version": schemaVersionExecutable, "kind": executablePlanKind, "schema_ref": schemaRefPlanExecutable, @@ -35,14 +38,17 @@ func BuildExecutable(plan map[string]any) map[string]any { "risk_level": riskLevel(risks), "acceptance": []string{"planned edits, commands, assertions, and rollback are machine-readable"}, }, - "edits": executableEdits(suggestedEdits), - "blocked_edits": executableBlockedEdits(blockedEdits), - "commands": executableCommands(commands), - "assertions": executableAssertions(commands, blockedEdits), - "rollback": executableRollback(suggestedEdits), - "risks": risks, - "context": plan["context"], - "evidence_policy": evidencePolicy(), + "edits": executableEdits(suggestedEdits), + "blocked_edits": executableBlockedEdits(blockedEdits), + "blocked_decisions": plan["blocked_decisions"], + "diagnostics": diagnostics, + "commands": executableCommands(commands), + "assertions": executableAssertions(commands, blockedEdits), + "rollback": executableRollback(suggestedEdits), + "risks": risks, + "impact_summary": plan["impact_summary"], + "context": plan["context"], + "evidence_policy": evidencePolicy(), } } @@ -165,8 +171,6 @@ func evidencePolicy() map[string]any { func commandPhase(command string) string { switch { - case strings.Contains(command, " capability add "): - return commandPhaseScaffold case strings.Contains(command, " gen "): return commandPhaseGenerate case strings.Contains(command, " lint "): diff --git a/cmd/nucleus/internal/plan/impact.go b/cmd/nucleus/internal/plan/impact.go new file mode 100644 index 0000000..7615776 --- /dev/null +++ b/cmd/nucleus/internal/plan/impact.go @@ -0,0 +1,399 @@ +package plan + +import ( + "os" + "sort" + "strings" + + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/contract/manifest" +) + +type impactSummary struct { + Mode string `json:"mode"` + AffectedSymbols []inspect.SymbolNode `json:"affected_symbols"` + AffectedFiles []string `json:"affected_files"` + AffectedRoutes []routeImpact `json:"affected_routes"` + AffectedContracts []string `json:"affected_contracts"` + AffectedTests []inspect.SymbolNode `json:"affected_tests"` + AffectedCapabilities []string `json:"affected_capabilities"` + GraphEdges []inspect.SymbolEdge `json:"graph_edges"` + SuggestedVerification []string `json:"suggested_verification"` + Warnings []string `json:"warnings"` +} + +type routeImpact struct { + Method string `json:"method"` + Path string `json:"path"` + OperationID string `json:"operation_id,omitempty"` +} + +func buildImpactSummary(dir string, task string, taskType string, description inspect.Description, requestedCapabilities []string, suggestedEdits []string, commands []string) impactSummary { + summary := impactSummary{ + Mode: "best_effort", + AffectedSymbols: []inspect.SymbolNode{}, + AffectedFiles: []string{}, + AffectedRoutes: []routeImpact{}, + AffectedContracts: affectedContracts(taskType, description, suggestedEdits), + AffectedTests: []inspect.SymbolNode{}, + AffectedCapabilities: []string{}, + GraphEdges: []inspect.SymbolEdge{}, + SuggestedVerification: append([]string{}, commands...), + Warnings: []string{}, + } + + manifestCapabilities := loadPlanCapabilities(dir) + summary.AffectedCapabilities = affectedCapabilities(task, description, manifestCapabilities, requestedCapabilities) + + seedIDs := matchedSymbolIDs(task, description.SymbolGraph) + seedIDs = append(seedIDs, capabilitySymbolIDs(description.SymbolGraph, manifestCapabilities, summary.AffectedCapabilities)...) + edges := impactEdges(description.SymbolGraph, seedIDs) + nodes := nodesForImpact(description.SymbolGraph, seedIDs, edges) + summary.AffectedCapabilities = uniqueStrings(append(summary.AffectedCapabilities, capabilitiesForNodes(manifestCapabilities, nodes)...)) + summary.AffectedSymbols = nonTestSymbols(nodes) + summary.AffectedTests = testSymbols(nodes) + summary.AffectedFiles = filesForSymbols(nodes) + summary.GraphEdges = uniquePlanEdges(edges) + summary.AffectedRoutes = affectedRoutes(task, taskType, description) + if len(summary.AffectedRoutes) > 0 { + summary.AffectedContracts = uniqueSortedStrings(append(summary.AffectedContracts, "api/openapi.yaml")) + } + + if len(summary.AffectedSymbols) == 0 && len(summary.AffectedRoutes) == 0 && len(summary.AffectedCapabilities) == 0 { + summary.Warnings = append(summary.Warnings, "no direct graph match found for task text; inspect trace/impact before editing business logic") + } + return summary +} + +func uniqueSortedStrings(values []string) []string { + result := uniqueStrings(values) + sort.Strings(result) + return result +} + +func affectedContracts(taskType string, description inspect.Description, suggestedEdits []string) []string { + seen := map[string]bool{} + var result []string + add := func(value string) { + if value == "" || seen[value] { + return + } + seen[value] = true + result = append(result, value) + } + for _, edit := range suggestedEdits { + if strings.HasPrefix(edit, "api/") && !strings.ContainsAny(edit, "*?[]") { + add(edit) + } + } + switch taskType { + case taskTypeHTTPEndpoint: + add("api/openapi.yaml") + case taskTypeErrorCatalog: + add("api/errors.yaml") + case taskTypeGRPCService: + for _, service := range description.GRPCServices { + add(service.Source) + } + if len(description.GRPCServices) == 0 { + add("api/proto/*.proto") + } + } + sort.Strings(result) + return result +} + +func loadPlanCapabilities(dir string) []manifest.Capability { + m, err := manifest.Load(dir) + if err != nil { + if os.IsNotExist(err) { + return nil + } + return nil + } + return m.Capabilities +} + +func affectedCapabilities(task string, description inspect.Description, capabilities []manifest.Capability, requested []string) []string { + text := normalizeTaskText(task) + seen := map[string]bool{} + var result []string + add := func(value string) { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + return + } + seen[value] = true + result = append(result, value) + } + for _, capability := range requested { + add(capability) + } + for _, capability := range description.Capabilities { + if textContainsToken(text, capability) { + add(capability) + } + } + for _, capability := range capabilities { + if textContainsToken(text, capability.ID) || textContainsToken(text, capability.Kind) { + add(capability.ID) + } + } + sort.Strings(result) + return result +} + +func matchedSymbolIDs(task string, graph inspect.SymbolGraph) []string { + text := normalizeTaskText(task) + var ids []string + for _, node := range graph.Nodes { + if node.Kind == "package" || node.Kind == "file" { + continue + } + if textContainsToken(text, node.Name) || strings.Contains(strings.ToLower(task), strings.ToLower(node.ID)) { + ids = append(ids, node.ID) + } + } + sort.Strings(ids) + return uniqueStrings(ids) +} + +func capabilitySymbolIDs(graph inspect.SymbolGraph, capabilities []manifest.Capability, affected []string) []string { + if len(affected) == 0 { + return nil + } + affectedSet := map[string]bool{} + for _, value := range affected { + affectedSet[value] = true + } + var queries []string + for _, capability := range capabilities { + if !affectedSet[capability.ID] && !affectedSet[capability.Kind] { + continue + } + for _, symbol := range capability.Symbols { + if symbol.ID != "" { + queries = append(queries, symbol.ID) + } else if symbol.Name != "" { + queries = append(queries, symbol.Name) + } + } + } + var ids []string + for _, query := range queries { + ids = append(ids, resolvePlanSymbolID(graph, query)...) + } + sort.Strings(ids) + return uniqueStrings(ids) +} + +func resolvePlanSymbolID(graph inspect.SymbolGraph, query string) []string { + query = strings.TrimSpace(query) + if query == "" { + return nil + } + if strings.HasPrefix(query, "go://") { + for _, node := range graph.Nodes { + if node.ID == query { + return []string{node.ID} + } + } + return nil + } + var ids []string + for _, node := range graph.Nodes { + if node.Name == query || strings.HasSuffix(node.ID, "#"+query) { + ids = append(ids, node.ID) + } + } + sort.Strings(ids) + return ids +} + +func impactEdges(graph inspect.SymbolGraph, seedIDs []string) []inspect.SymbolEdge { + if len(seedIDs) == 0 { + return nil + } + seed := map[string]bool{} + for _, id := range seedIDs { + seed[id] = true + } + var edges []inspect.SymbolEdge + for _, edge := range graph.Edges { + if edge.Kind != "calls" && edge.Kind != "tests" && edge.Kind != "implements" && edge.Kind != "accepts" && edge.Kind != "returns" { + continue + } + if seed[edge.From] || seed[edge.To] { + edges = append(edges, edge) + } + } + return uniquePlanEdges(edges) +} + +func capabilitiesForNodes(capabilities []manifest.Capability, nodes []inspect.SymbolNode) []string { + nodeIDs := map[string]bool{} + nodeNames := map[string]bool{} + for _, node := range nodes { + nodeIDs[node.ID] = true + nodeNames[node.Name] = true + } + var result []string + for _, capability := range capabilities { + for _, symbol := range capability.Symbols { + if symbol.ID != "" && nodeIDs[symbol.ID] || symbol.Name != "" && nodeNames[symbol.Name] { + result = append(result, capability.ID) + break + } + } + } + sort.Strings(result) + return uniqueStrings(result) +} + +func nodesForImpact(graph inspect.SymbolGraph, seedIDs []string, edges []inspect.SymbolEdge) []inspect.SymbolNode { + nodeByID := map[string]inspect.SymbolNode{} + for _, node := range graph.Nodes { + nodeByID[node.ID] = node + } + seen := map[string]bool{} + var result []inspect.SymbolNode + add := func(id string) { + if seen[id] { + return + } + node, ok := nodeByID[id] + if !ok { + return + } + seen[id] = true + result = append(result, node) + } + for _, id := range seedIDs { + add(id) + } + for _, edge := range edges { + add(edge.From) + add(edge.To) + } + sort.Slice(result, func(i, j int) bool { return result[i].ID < result[j].ID }) + return result +} + +func nonTestSymbols(nodes []inspect.SymbolNode) []inspect.SymbolNode { + var result []inspect.SymbolNode + for _, node := range nodes { + if isTestSymbol(node) { + continue + } + result = append(result, node) + } + return result +} + +func testSymbols(nodes []inspect.SymbolNode) []inspect.SymbolNode { + var result []inspect.SymbolNode + for _, node := range nodes { + if isTestSymbol(node) { + result = append(result, node) + } + } + return result +} + +func isTestSymbol(node inspect.SymbolNode) bool { + return node.Kind == "function" && (strings.HasPrefix(node.Name, "Test") || strings.HasSuffix(node.File, "_test.go")) +} + +func filesForSymbols(nodes []inspect.SymbolNode) []string { + seen := map[string]bool{} + var result []string + for _, node := range nodes { + if node.File == "" || seen[node.File] { + continue + } + seen[node.File] = true + result = append(result, node.File) + } + sort.Strings(result) + return result +} + +func affectedRoutes(task string, taskType string, description inspect.Description) []routeImpact { + text := normalizeTaskText(task) + includeAll := taskType == taskTypeHTTPEndpoint && len(description.Endpoints) == 1 + var routes []routeImpact + for _, endpoint := range description.Endpoints { + routeText := endpoint.Method + " " + endpoint.Path + " " + endpoint.OperationID + if includeAll || routeMatchesTask(text, routeText, endpoint.Path, endpoint.OperationID) { + routes = append(routes, routeImpact{Method: endpoint.Method, Path: endpoint.Path, OperationID: endpoint.OperationID}) + } + } + sort.Slice(routes, func(i, j int) bool { + if routes[i].Method == routes[j].Method { + return routes[i].Path < routes[j].Path + } + return routes[i].Method < routes[j].Method + }) + return routes +} + +func routeMatchesTask(text string, routeText string, path string, operationID string) bool { + if textContainsToken(text, operationID) || strings.Contains(text, strings.ToLower(routeText)) { + return true + } + for _, token := range splitPlanTokens(strings.Trim(path, "/")) { + if token == "" || len(token) < 3 { + continue + } + if textContainsToken(text, token) || textContainsToken(text, strings.TrimSuffix(token, "s")) { + return true + } + } + return false +} + +func uniquePlanEdges(edges []inspect.SymbolEdge) []inspect.SymbolEdge { + seen := map[string]bool{} + var result []inspect.SymbolEdge + for _, edge := range edges { + key := edge.From + "\x00" + edge.Kind + "\x00" + edge.To + if seen[key] { + continue + } + seen[key] = true + result = append(result, edge) + } + sort.Slice(result, func(i, j int) bool { + if result[i].From == result[j].From { + if result[i].Kind == result[j].Kind { + return result[i].To < result[j].To + } + return result[i].Kind < result[j].Kind + } + return result[i].From < result[j].From + }) + return result +} + +func normalizeTaskText(task string) string { + return " " + strings.Join(splitPlanTokens(task), " ") + " " +} + +func textContainsToken(text string, token string) bool { + token = strings.TrimSpace(token) + if token == "" { + return false + } + tokens := splitPlanTokens(token) + if len(tokens) == 0 { + return false + } + return strings.Contains(text, " "+strings.Join(tokens, " ")+" ") || strings.Contains(text, " "+tokens[len(tokens)-1]+" ") +} + +func splitPlanTokens(value string) []string { + value = strings.ToLower(value) + replacer := strings.NewReplacer("/", " ", "_", " ", "-", " ", ".", " ", "#", " ", ":", " ", "{", " ", "}", " ", "(", " ", ")", " ", "\"", " ", "'", " ") + value = replacer.Replace(value) + return strings.Fields(value) +} diff --git a/cmd/nucleus/internal/plan/output.go b/cmd/nucleus/internal/plan/output.go index b32a286..681434c 100644 --- a/cmd/nucleus/internal/plan/output.go +++ b/cmd/nucleus/internal/plan/output.go @@ -7,7 +7,10 @@ import ( "reflect" "strings" + "github.com/nucleuskit/contract/diagnostic" "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/decision" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/recipe" ) // OutputOptions controls the plan JSON payload. @@ -18,12 +21,17 @@ type OutputOptions struct { } type planSummary struct { - TaskType string `json:"task_type"` - ContractFirst bool `json:"contract_first"` - SuggestedEdits int `json:"suggested_edits"` - BlockedEdits int `json:"blocked_edits"` - Commands int `json:"commands"` - Risks int `json:"risks"` + TaskType string `json:"task_type"` + ContractFirst bool `json:"contract_first"` + SuggestedEdits int `json:"suggested_edits"` + BlockedEdits int `json:"blocked_edits"` + Commands int `json:"commands"` + Risks int `json:"risks"` + BlockedDecisions int `json:"blocked_decisions"` + AffectedSymbols int `json:"affected_symbols"` + AffectedRoutes int `json:"affected_routes"` + AffectedTests int `json:"affected_tests"` + AffectedCapabilities int `json:"affected_capabilities"` } // BuildOutput builds either the default plan payload or executable plan payload. @@ -52,31 +60,47 @@ func Build(dir string, task string) (map[string]any, error) { suggestedEdits, blockedEdits := suggestedEdits(taskType, description, requestedCapabilities) commands := commands(taskType, task, requestedCapabilities) risks := risks(taskType, description, requestedCapabilities) + recipeCandidates := recipe.Candidates(dir, recipe.CandidateQuery{Task: task, Kinds: requestedCapabilities, Limit: 20}) + if recipeCandidates.Diagnostics.Failed() { + risks = append(risks, "存在无效 recipe,已从 plan candidates 中忽略") + } + decisionState := decision.PlanStateForDir(dir) + manifestCapabilities := loadPlanCapabilities(dir) + blockedDecisions := lockedDecisionBlocks(task, requestedCapabilities, manifestCapabilities, decisionState) + if len(blockedDecisions) > 0 { + risks = append(risks, "locked decision 阻止 provider/library/driver 静默替换") + } if len(blockedEdits) > 0 { risks = append(risks, "部分建议改动不在 describe edit surfaces allowed 范围内") } risks = uniqueStrings(risks) contractFirst := contractFirst(taskType) + impact := buildImpactSummary(dir, task, taskType, description, requestedCapabilities, suggestedEdits, commands) + diagnostics := planDiagnostics(description.Diagnostics, decisionState.Diagnostics, recipeCandidates.Diagnostics) + ok := len(blockedEdits) == 0 && len(blockedDecisions) == 0 && !diagnostics.Failed() return map[string]any{ "result_kind": resultKindPlan, - "ok": len(blockedEdits) == 0, - "summary": buildSummary(taskType, contractFirst, suggestedEdits, blockedEdits, commands, risks), + "ok": ok, + "summary": buildSummary(taskType, contractFirst, suggestedEdits, blockedEdits, blockedDecisions, commands, risks, impact), "schema_version": schemaVersionPlan, "kind": planKind, - "schema_ref": schemaRefPlanExecutable, + "schema_ref": schemaRefPlan, "evidence_schema": schemaRefEvidence, + "diagnostics": diagnostics, "task": task, "task_type": taskType, "contract_first": contractFirst, "suggested_edits": suggestedEdits, "generated_outputs": generatedOutputs(taskType), "blocked_edits": blockedEdits, + "blocked_decisions": blockedDecisions, "forbidden_edits": description.EditSurfaces.Forbidden, "readonly_edits": description.EditSurfaces.Readonly, "allowed_edits": description.EditSurfaces.Allowed, "commands": commands, "risks": risks, + "impact_summary": impact, "context": map[string]any{ "service": description.Service, "existing_endpoints": description.Endpoints, @@ -86,18 +110,40 @@ func Build(dir string, task string) (map[string]any, error) { "edit_surfaces": description.EditSurfaces, "declared_capabilities": description.Capabilities, "requested_capabilities": capabilityContext(task, description, requestedCapabilities), + "locked_decisions": decisionState.Locked, + "decision_diagnostics": decisionState.Diagnostics, + "recipe_candidates": recipeCandidates.Candidates, + "recipe_diagnostics": recipeCandidates.Diagnostics, + "recipe_policy": recipePolicy(), }, }, nil } -func buildSummary(taskType string, contractFirst bool, suggestedEdits []string, blockedEdits []string, commands []string, risks []string) planSummary { +func planDiagnostics(parts ...diagnostic.Diagnostics) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + for _, part := range parts { + diagnostics = append(diagnostics, part...) + } + if diagnostics == nil { + return diagnostic.Diagnostics{} + } + diagnostics.Sort() + return diagnostics +} + +func buildSummary(taskType string, contractFirst bool, suggestedEdits []string, blockedEdits []string, blockedDecisions []lockedDecisionBlock, commands []string, risks []string, impact impactSummary) planSummary { return planSummary{ - TaskType: taskType, - ContractFirst: contractFirst, - SuggestedEdits: len(suggestedEdits), - BlockedEdits: len(blockedEdits), - Commands: len(commands), - Risks: len(risks), + TaskType: taskType, + ContractFirst: contractFirst, + SuggestedEdits: len(suggestedEdits), + BlockedEdits: len(blockedEdits), + Commands: len(commands), + Risks: len(risks), + BlockedDecisions: len(blockedDecisions), + AffectedSymbols: len(impact.AffectedSymbols), + AffectedRoutes: len(impact.AffectedRoutes), + AffectedTests: len(impact.AffectedTests), + AffectedCapabilities: len(impact.AffectedCapabilities), } } @@ -119,13 +165,27 @@ func configKeys(description any) any { return field.Interface() } +func recipePolicy() map[string]any { + return map[string]any{ + "schema_ref": recipeSchemaRef, + "selection": "candidate_only", + "decision_required": true, + "may_write_files": false, + "may_execute": false, + "may_accept": false, + } +} + func renderHuman(stdout io.Writer, stderr io.Writer, output map[string]any) { summary, _ := output["summary"].(planSummary) blockedEdits := anyStringSlice(output["blocked_edits"]) - if len(blockedEdits) > 0 { + if len(blockedEdits) > 0 || blockedDecisionCount(output) > 0 { for _, path := range blockedEdits { _, _ = fmt.Fprintf(stderr, "blocked %s: not covered by describe edit_surfaces.allowed\n", path) } + if blockedDecisionCount(output) > 0 { + _, _ = fmt.Fprintf(stderr, "blocked decisions: %d locked decision conflict(s)\n", blockedDecisionCount(output)) + } return } @@ -133,6 +193,7 @@ func renderHuman(stdout io.Writer, stderr io.Writer, output map[string]any) { _, _ = fmt.Fprintf(stdout, "planned: %s\n", stringField(output, "task_type")) _, _ = fmt.Fprintf(stdout, "contract first: %t\n", summary.ContractFirst) _, _ = fmt.Fprintf(stdout, "edits: %d allowed, %d blocked\n", summary.SuggestedEdits, summary.BlockedEdits) + _, _ = fmt.Fprintf(stdout, "impact: %d symbols, %d routes, %d tests, %d capabilities\n", summary.AffectedSymbols, summary.AffectedRoutes, summary.AffectedTests, summary.AffectedCapabilities) if generatedOutputs := anyStringSlice(output["generated_outputs"]); len(generatedOutputs) > 0 { _, _ = fmt.Fprintf(stdout, "generated: %s\n", strings.Join(generatedOutputs, ", ")) } diff --git a/cmd/nucleus/internal/plan/output_test.go b/cmd/nucleus/internal/plan/output_test.go index a8c163b..e5f9884 100644 --- a/cmd/nucleus/internal/plan/output_test.go +++ b/cmd/nucleus/internal/plan/output_test.go @@ -8,6 +8,11 @@ import ( "path/filepath" "strings" "testing" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/decision" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/recipe" ) func TestBuildOutputAddsPlanMetadata(t *testing.T) { @@ -23,9 +28,18 @@ func TestBuildOutputAddsPlanMetadata(t *testing.T) { if output["result_kind"] != resultKindPlan { t.Fatalf("result_kind = %v, want %s", output["result_kind"], resultKindPlan) } + if output["schema_version"] != schemaVersionPlan { + t.Fatalf("schema_version = %v, want %s", output["schema_version"], schemaVersionPlan) + } + if output["schema_ref"] != schemaRefPlan { + t.Fatalf("schema_ref = %v, want %s", output["schema_ref"], schemaRefPlan) + } if output["ok"] != true { t.Fatalf("ok = %v, want true", output["ok"]) } + if diagnostics, ok := output["diagnostics"].(diagnostic.Diagnostics); !ok || len(diagnostics) != 0 { + t.Fatalf("diagnostics = %#v, want empty diagnostic.Diagnostics", output["diagnostics"]) + } summary, ok := output["summary"].(planSummary) if !ok { t.Fatalf("summary has type %T, want planSummary", output["summary"]) @@ -55,9 +69,18 @@ func TestBuildOutputExecutableMarksBlockedEditsRequired(t *testing.T) { if output["result_kind"] != resultKindExecutablePlan { t.Fatalf("result_kind = %v, want %s", output["result_kind"], resultKindExecutablePlan) } + if output["schema_version"] != schemaVersionExecutable { + t.Fatalf("schema_version = %v, want %s", output["schema_version"], schemaVersionExecutable) + } + if output["schema_ref"] != schemaRefPlanExecutable { + t.Fatalf("schema_ref = %v, want %s", output["schema_ref"], schemaRefPlanExecutable) + } if output["ok"] != false { t.Fatalf("ok = %v, want false", output["ok"]) } + if diagnostics, ok := output["diagnostics"].(diagnostic.Diagnostics); !ok || len(diagnostics) != 0 { + t.Fatalf("diagnostics = %#v, want empty diagnostic.Diagnostics", output["diagnostics"]) + } blocked, ok := output["blocked_edits"].([]map[string]any) if !ok { t.Fatalf("blocked_edits has type %T, want []map[string]any", output["blocked_edits"]) @@ -100,6 +123,320 @@ func TestExecutablePlanAcceptsHTTPScenarioEvidence(t *testing.T) { } } +func TestCapabilityPlanRequiresDecisionEvidenceInsteadOfProviderScaffold(t *testing.T) { + dir := newPlanFixture(t, []string{"nucleus.yaml", ".nucleus/**", "docs/**"}) + + output, err := BuildOutput(OutputOptions{ + Dir: dir, + Task: "add mysql capability", + }) + if err != nil { + t.Fatalf("BuildOutput() error = %v", err) + } + for _, command := range anyStringSlice(output["commands"]) { + if strings.Contains(command, "capability add") || strings.Contains(command, "--provider") { + t.Fatalf("capability plan leaked provider scaffold command %q", command) + } + } + for _, path := range anyStringSlice(output["generated_outputs"]) { + if path == "go.mod" || path == "go.sum" || strings.HasPrefix(path, "internal/") { + t.Fatalf("capability plan leaked implementation output %q", path) + } + } + context, ok := output["context"].(map[string]any) + if !ok { + t.Fatalf("context has type %T, want map[string]any", output["context"]) + } + rawCapabilities, ok := context["requested_capabilities"].([]map[string]any) + if !ok || len(rawCapabilities) != 1 { + t.Fatalf("requested_capabilities = %#v, want one structured capability", context["requested_capabilities"]) + } + capability := rawCapabilities[0] + if _, exists := capability["provider_hint"]; exists { + t.Fatalf("capability context leaked provider_hint: %#v", capability) + } + if _, exists := capability["module"]; exists { + t.Fatalf("capability context leaked fixed module mapping: %#v", capability) + } + if capability["decision_schema_ref"] != decisionSchemaRef || capability["provider_selection"] != "decision_only" { + t.Fatalf("capability context missing decision evidence contract: %#v", capability) + } +} + +func TestCapabilityPlanUsesVocabularyWithoutProviderCatalogFalsePositive(t *testing.T) { + dir := newPlanFixture(t, []string{"nucleus.yaml", ".nucleus/**", "docs/**"}) + + output, err := BuildOutput(OutputOptions{ + Dir: dir, + Task: "move capability kind suggestions from Go catalog to vocab data", + }) + if err != nil { + t.Fatalf("BuildOutput() error = %v", err) + } + context, ok := output["context"].(map[string]any) + if !ok { + t.Fatalf("context has type %T, want map[string]any", output["context"]) + } + rawCapabilities, ok := context["requested_capabilities"].([]map[string]any) + if !ok { + t.Fatalf("requested_capabilities has type %T", context["requested_capabilities"]) + } + if len(rawCapabilities) != 0 { + t.Fatalf("catalog should not false-match log capability: %#v", rawCapabilities) + } +} + +func TestCapabilityPlanMatchesVocabularyAliases(t *testing.T) { + dir := newPlanFixture(t, []string{"nucleus.yaml", ".nucleus/**", "docs/**"}) + + output, err := BuildOutput(OutputOptions{ + Dir: dir, + Task: "接入消息队列并增加指标", + }) + if err != nil { + t.Fatalf("BuildOutput() error = %v", err) + } + context, ok := output["context"].(map[string]any) + if !ok { + t.Fatalf("context has type %T, want map[string]any", output["context"]) + } + rawCapabilities, ok := context["requested_capabilities"].([]map[string]any) + if !ok { + t.Fatalf("requested_capabilities has type %T", context["requested_capabilities"]) + } + names := map[string]bool{} + for _, capability := range rawCapabilities { + name, _ := capability["name"].(string) + names[name] = true + } + if !names["mq"] || !names["metric"] { + t.Fatalf("requested capability names = %#v, want mq and metric", names) + } +} + +func TestPlanIncludesRecipeCandidatesWithoutSelectingProvider(t *testing.T) { + dir := newPlanFixture(t, []string{"nucleus.yaml", ".nucleus/**", "docs/**"}) + writePlanFile(t, dir, ".nucleus/recipes/gorm.yaml", `schema_version: "recipe.v1" +id: gorm-sql +kind: sql +provider: gorm +language: go +suggest: + interfaces: + - keep ORM behind a project-owned interface + verification: + - go test ./storage +risks: + - transaction boundary must be explicit +`) + + output, err := BuildOutput(OutputOptions{ + Dir: dir, + Task: "add mysql capability", + }) + if err != nil { + t.Fatalf("BuildOutput() error = %v", err) + } + context := output["context"].(map[string]any) + candidates, ok := context["recipe_candidates"].([]recipe.Candidate) + if !ok || len(candidates) != 2 { + t.Fatalf("recipe_candidates = %#v, want project and built-in candidates", context["recipe_candidates"]) + } + projectCandidate := findRecipeCandidate(t, candidates, "gorm-sql") + if projectCandidate.Selection != "candidate_only" || !projectCandidate.DecisionRequired { + t.Fatalf("recipe candidate should not be selected automatically: %#v", projectCandidate) + } + if projectCandidate.Source != "project" { + t.Fatalf("project candidate source = %q, want project", projectCandidate.Source) + } + builtinCandidate := findRecipeCandidate(t, candidates, "sql-port-boundary") + if builtinCandidate.Provider != "" || builtinCandidate.Source != "builtin" { + t.Fatalf("builtin candidate should be provider-neutral: %#v", builtinCandidate) + } + policy, ok := context["recipe_policy"].(map[string]any) + if !ok { + t.Fatalf("recipe_policy has type %T", context["recipe_policy"]) + } + if policy["may_write_files"] != false || policy["may_execute"] != false || policy["may_accept"] != false { + t.Fatalf("recipe policy is not read-only: %#v", policy) + } + for _, command := range anyStringSlice(output["commands"]) { + if strings.Contains(command, "go test ./storage") || strings.Contains(command, "gorm") { + t.Fatalf("recipe suggestion leaked into executable commands: %q", command) + } + } + for _, generated := range anyStringSlice(output["generated_outputs"]) { + if generated == "go.mod" || generated == "go.sum" { + t.Fatalf("recipe candidate leaked dependency output: %q", generated) + } + } +} + +func TestPlanIgnoresUnsafeRecipeAndSurfacesDiagnostics(t *testing.T) { + dir := newPlanFixture(t, []string{"nucleus.yaml", ".nucleus/**", "docs/**"}) + writePlanFile(t, dir, ".nucleus/recipes/unsafe.yaml", `schema_version: "recipe.v1" +id: unsafe +kind: sql +language: go +commands: + - go get gorm.io/gorm +`) + + output, err := BuildOutput(OutputOptions{ + Dir: dir, + Task: "add mysql capability", + }) + if err != nil { + t.Fatalf("BuildOutput() error = %v", err) + } + context := output["context"].(map[string]any) + candidates, ok := context["recipe_candidates"].([]recipe.Candidate) + if !ok { + t.Fatalf("recipe_candidates has type %T", context["recipe_candidates"]) + } + if containsRecipeCandidate(candidates, "unsafe") { + t.Fatalf("unsafe recipe became a candidate: %#v", candidates) + } + if !containsRecipeCandidate(candidates, "sql-port-boundary") { + t.Fatalf("built-in safe recipe missing: %#v", candidates) + } + diagnostics, ok := context["recipe_diagnostics"].(diagnostic.Diagnostics) + if ok && !diagnostics.Failed() { + t.Fatalf("recipe diagnostics did not fail: %#v", diagnostics) + } + if !ok { + t.Fatalf("recipe_diagnostics has type %T", context["recipe_diagnostics"]) + } + if !containsString(anyStringSlice(output["risks"]), "存在无效 recipe,已从 plan candidates 中忽略") { + t.Fatalf("risks = %#v, want invalid recipe risk", output["risks"]) + } +} + +func TestPlanBlocksLockedDecisionProviderChangeUntilSupersedeExists(t *testing.T) { + dir := newDecisionPlanFixture(t) + acceptPlanDecision(t, dir, ".nucleus/decisions/order-store.yaml") + + output, err := BuildOutput(OutputOptions{ + Dir: dir, + Task: "switch order_store provider to xorm", + }) + if err != nil { + t.Fatalf("BuildOutput() error = %v", err) + } + if output["ok"] != false { + t.Fatalf("ok = %v, want false for locked decision conflict", output["ok"]) + } + blocks, ok := output["blocked_decisions"].([]lockedDecisionBlock) + if !ok || len(blocks) != 1 { + t.Fatalf("blocked_decisions = %#v, want one block", output["blocked_decisions"]) + } + if blocks[0].DecisionID != "order-store-provider" || blocks[0].RequiredAction == "" { + t.Fatalf("unexpected locked decision block: %#v", blocks[0]) + } + summary := output["summary"].(planSummary) + if summary.BlockedDecisions != 1 { + t.Fatalf("summary = %#v, want one blocked decision", summary) + } + + writePlanFile(t, dir, ".nucleus/decisions/order-store-xorm.yaml", `schema_version: "decision.v1" +id: order-store-provider-v2 +capability: order_store +supersedes: order-store-provider +decision: + provider: xorm + library: xorm.io/xorm + status: proposed + locked: false +reason: + - replace locked provider through explicit supersede evidence +impact: + files: + - internal/order/store.go +verification: + commands: + - go test ./internal/order +`) + supersedePlanDecision(t, dir, ".nucleus/decisions/order-store-xorm.yaml") + + output, err = BuildOutput(OutputOptions{ + Dir: dir, + Task: "switch order_store provider to xorm", + }) + if err != nil { + t.Fatalf("BuildOutput() after supersede error = %v", err) + } + if output["ok"] != true { + t.Fatalf("ok = %v, want true after supersede; blocked=%#v risks=%#v", output["ok"], output["blocked_decisions"], output["risks"]) + } + if blocks := output["blocked_decisions"].([]lockedDecisionBlock); len(blocks) != 0 { + t.Fatalf("blocked decisions after supersede = %#v", blocks) + } +} + +func TestPlanEmbedsImpactSummaryFromGraphFacts(t *testing.T) { + dir := newPlanImpactFixture(t) + + output, err := BuildOutput(OutputOptions{ + Dir: dir, + Task: "add order status filter to ListOrders", + }) + if err != nil { + t.Fatalf("BuildOutput() error = %v", err) + } + impact, ok := output["impact_summary"].(impactSummary) + if !ok { + t.Fatalf("impact_summary has type %T", output["impact_summary"]) + } + if impact.Mode != "best_effort" { + t.Fatalf("impact mode = %q", impact.Mode) + } + if !containsPlanSymbol(impact.AffectedSymbols, "ListOrders") { + t.Fatalf("affected symbols = %#v, want ListOrders", impact.AffectedSymbols) + } + if !containsPlanSymbol(impact.AffectedTests, "TestListOrders") { + t.Fatalf("affected tests = %#v, want TestListOrders", impact.AffectedTests) + } + if !containsString(impact.AffectedContracts, "api/openapi.yaml") { + t.Fatalf("affected contracts = %#v, want openapi", impact.AffectedContracts) + } + if !containsString(impact.AffectedCapabilities, "order_store") { + t.Fatalf("affected capabilities = %#v, want order_store", impact.AffectedCapabilities) + } + if !containsPlanRoute(impact.AffectedRoutes, "GET", "/orders") { + t.Fatalf("affected routes = %#v, want GET /orders", impact.AffectedRoutes) + } + if len(impact.GraphEdges) == 0 { + t.Fatalf("graph_edges = empty, want impact evidence") + } + if !containsString(impact.SuggestedVerification, commandVerifyJSON) { + t.Fatalf("suggested verification = %#v, want verify command", impact.SuggestedVerification) + } + summary := output["summary"].(planSummary) + if summary.AffectedSymbols == 0 || summary.AffectedRoutes == 0 || summary.AffectedTests == 0 || summary.AffectedCapabilities == 0 { + t.Fatalf("summary missing impact counts: %#v", summary) + } +} + +func TestExecutablePlanIncludesImpactSummary(t *testing.T) { + dir := newPlanImpactFixture(t) + + output, err := BuildOutput(OutputOptions{ + Dir: dir, + Task: "change ListOrders", + Executable: true, + }) + if err != nil { + t.Fatalf("BuildOutput() error = %v", err) + } + impact, ok := output["impact_summary"].(impactSummary) + if !ok { + t.Fatalf("impact_summary has type %T", output["impact_summary"]) + } + if !containsPlanSymbol(impact.AffectedSymbols, "ListOrders") { + t.Fatalf("affected symbols = %#v, want ListOrders", impact.AffectedSymbols) + } +} + func TestCommandJSONBlockedReturnsSentinel(t *testing.T) { dir := newPlanFixture(t, []string{"docs/**"}) cmd := NewCommand(Config{Dir: &dir}) @@ -184,10 +521,93 @@ func TestCommandPrettyJSONOutput(t *testing.T) { } } +func newPlanImpactFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + writePlanFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: orders + version: "0.1.0" +contracts: + - id: http + kind: openapi + path: api/openapi.yaml +capabilities: + - id: order_store + kind: relational_store + symbols: + - id: go://example.com/orders/order#OrderStore + status: resolved +ai: + intent: test + allowed_changes: + - api/** + - order/** + - nucleus.yaml +verify: + commands: + - go test ./order +`) + writePlanFile(t, dir, "go.mod", "module example.com/orders\n\ngo 1.26.3\n") + writePlanFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 +paths: + /orders: + get: + operationId: listOrders + responses: + "200": + description: ok +`) + writePlanFile(t, dir, "order/service.go", `package order + +type OrderStore interface { List() error } + +func ListOrders(store OrderStore) { loadOrders() } +func loadOrders() {} +func Handler(store OrderStore) { ListOrders(store) } +`) + writePlanFile(t, dir, "order/service_test.go", `package order + +import "testing" + +func TestListOrders(t *testing.T) { ListOrders(nil) } +`) + return dir +} + +func writePlanFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func containsPlanSymbol(nodes []inspect.SymbolNode, name string) bool { + for _, node := range nodes { + if node.Name == name { + return true + } + } + return false +} + +func containsPlanRoute(routes []routeImpact, method string, path string) bool { + for _, route := range routes { + if route.Method == method && route.Path == path { + return true + } + } + return false +} + func newPlanFixture(t *testing.T, allowedChanges []string) string { t.Helper() dir := t.TempDir() - manifest := `schema_version: "1.0" + manifest := `schema_version: "2.0" service: name: fixture version: "0.1.0" @@ -199,7 +619,6 @@ ai: manifest += " - " + quoteYAMLString(path) + "\n" } manifest += `capabilities: [] -nucleus: {} ` if err := os.WriteFile(filepath.Join(dir, "nucleus.yaml"), []byte(manifest), 0o600); err != nil { t.Fatalf("write nucleus.yaml: %v", err) @@ -207,6 +626,68 @@ nucleus: {} return dir } +func newDecisionPlanFixture(t *testing.T) string { + t.Helper() + dir := t.TempDir() + writePlanFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: orders + version: "0.1.0" +capabilities: + - id: order_store + kind: sql +ai: + intent: test + allowed_changes: + - nucleus.yaml + - .nucleus/** + - docs/** + - internal/** +`) + writePlanFile(t, dir, "go.mod", "module example.com/orders\n\ngo 1.26.3\n") + writePlanFile(t, dir, "internal/order/store.go", "package order\n") + writePlanFile(t, dir, ".nucleus/decisions/order-store.yaml", `schema_version: "decision.v1" +id: order-store-provider +capability: order_store +decision: + provider: gorm + library: gorm.io/gorm + status: proposed + locked: false +reason: + - project already uses gorm in storage package +impact: + files: + - internal/order/store.go +verification: + commands: + - go test ./internal/order +`) + return dir +} + +func acceptPlanDecision(t *testing.T, dir string, path string) { + t.Helper() + cmd := decision.NewCommand(decision.Config{Dir: &dir}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"accept", path, "--by", "human", "--accepted-at", "2026-07-03T00:00:00Z", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("accept decision %s: %v", path, err) + } +} + +func supersedePlanDecision(t *testing.T, dir string, path string) { + t.Helper() + cmd := decision.NewCommand(decision.Config{Dir: &dir}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"supersede", path, "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("supersede decision %s: %v", path, err) + } +} + func quoteYAMLString(value string) string { data, _ := json.Marshal(value) return string(data) @@ -220,3 +701,23 @@ func containsString(values []string, want string) bool { } return false } + +func findRecipeCandidate(t *testing.T, values []recipe.Candidate, id string) recipe.Candidate { + t.Helper() + for _, value := range values { + if value.ID == id { + return value + } + } + t.Fatalf("recipe candidate %q not found in %#v", id, values) + return recipe.Candidate{} +} + +func containsRecipeCandidate(values []recipe.Candidate, id string) bool { + for _, value := range values { + if value.ID == id { + return true + } + } + return false +} diff --git a/cmd/nucleus/internal/plan/surfaces.go b/cmd/nucleus/internal/plan/surfaces.go index c14af4d..95279e7 100644 --- a/cmd/nucleus/internal/plan/surfaces.go +++ b/cmd/nucleus/internal/plan/surfaces.go @@ -19,7 +19,7 @@ func suggestedEdits(taskType string, description inspect.Description, requestedC } edits = append(edits, "api/errors.yaml", "internal/domain/**", "internal/adapter/grpc/**") case taskTypeCapability: - edits = append(edits, "nucleus.yaml", "go.mod", "go.sum", "internal/app/**", "internal/config/**", "internal/adapter/store/**", "configs/**", "deploy/**", "docs/**") + edits = append(edits, "nucleus.yaml", ".nucleus/decisions/**", "docs/**") case taskTypeErrorCatalog: edits = append(edits, "api/errors.yaml", "internal/domain/**") case taskTypeHTTPEndpoint: @@ -27,11 +27,8 @@ func suggestedEdits(taskType string, description inspect.Description, requestedC default: edits = append(edits, description.EditSurfaces.Allowed...) } - for _, capability := range requestedCapabilities { - edits = append(edits, "nucleus.yaml") - if capability == "sql" || capability == "mongo" || capability == "redis" || capability == "kv" || capability == "store" { - edits = append(edits, "internal/adapter/store/**", "internal/config/**", "configs/**", "deploy/**", "docs/**", "go.mod", "go.sum") - } + if len(requestedCapabilities) > 0 { + edits = append(edits, "nucleus.yaml", ".nucleus/decisions/**", "docs/**") } return filterSuggestedEdits(taskType, uniqueStrings(edits), description.EditSurfaces) } @@ -62,7 +59,7 @@ func implementationSurfaces(taskType string, allowed []string) []string { case taskTypeGRPCService: prefixes = []string{"cmd/", "internal/domain/", "internal/adapter/grpc/", "api/"} case taskTypeCapability: - prefixes = []string{"nucleus.yaml", "go.mod", "go.sum", "internal/app/", "internal/config/", "internal/adapter/store/", "configs/", "deploy/", "docs/", "test/", "Makefile"} + return allowed case taskTypeErrorCatalog: prefixes = []string{"api/", "internal/domain/", "cmd/"} default: diff --git a/cmd/nucleus/internal/plan/task.go b/cmd/nucleus/internal/plan/task.go index 00e132e..65275c7 100644 --- a/cmd/nucleus/internal/plan/task.go +++ b/cmd/nucleus/internal/plan/task.go @@ -1,10 +1,8 @@ package plan import ( - "strings" - "github.com/nucleuskit/contract/inspect" - "github.com/nucleuskit/nucleus/cmd/nucleus/internal/capcatalog" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/capvocab" ) func generatedOutputs(taskType string) []string { @@ -16,7 +14,7 @@ func generatedOutputs(taskType string) []string { case taskTypeErrorCatalog: return []string{"contract/gen/errors.go"} case taskTypeCapability: - return []string{"nucleus.yaml", "internal/app/**", "internal/config/**", "internal/adapter/store/**", "configs/**", "deploy/**", "docs/**"} + return []string{"nucleus.yaml", ".nucleus/decisions/*.json", "docs/**"} default: return []string{"contract/gen/**"} } @@ -31,17 +29,7 @@ func commands(taskType string, task string, requestedCapabilities []string) []st case taskTypeErrorCatalog: return []string{"nucleus gen --dir . --errors", commandValidate, commandLintStrict, commandVerifyJSON} case taskTypeCapability: - var items []string - for _, capability := range requestedCapabilities { - command := "nucleus capability add " + capability - if provider := providerHint(task, capability); provider != "" { - command += " --provider " + provider - } - items = append(items, command) - } - items = append(items, commandValidate) - items = append(items, commandLintStrict, commandVerifyJSON) - return items + return []string{commandDescribeFlow, commandValidate, commandLintStrict, commandVerifyJSON} default: return []string{commandValidate, commandLintStrict, commandVerifyJSON} } @@ -57,15 +45,13 @@ func risks(taskType string, description inspect.Description, requestedCapabiliti case taskTypeErrorCatalog: risks = append(risks, "错误码变更必须保持 api/errors.yaml 与响应映射稳定") case taskTypeCapability: - risks = append(risks, "能力接入必须保持 manifest、cap 接口和 import graph 一致") + risks = append(risks, "能力接入必须声明抽象接口、decision evidence、影响面和验证命令") + risks = append(risks, "provider/library/driver 只能写入 decision evidence,不能写入 manifest") } for _, capability := range requestedCapabilities { if !hasString(description.Capabilities, capability) { risks = append(risks, "manifest 未声明能力 "+capability) } - if inspect.CapabilityModule(capability) == "" { - risks = append(risks, "未知 capability "+capability+" 需要先确认是否属于 Nucleus 已支持能力;业务服务不得修改 Nucleus steering") - } } if len(description.GeneratedFreshness) > 0 { for _, item := range description.GeneratedFreshness { @@ -81,163 +67,26 @@ func capabilityContext(task string, description inspect.Description, capabilitie items := make([]map[string]any, 0, len(capabilities)) for _, capability := range capabilities { items = append(items, map[string]any{ - "name": capability, - "declared": hasString(description.Capabilities, capability), - "module": strings.TrimPrefix(inspect.CapabilityModule(capability), modulePathPrefix), - "provider_hint": providerHint(task, capability), + "name": capability, + "declared": hasString(description.Capabilities, capability), + "decision_required": true, + "decision_schema_ref": decisionSchemaRef, + "provider_selection": "decision_only", + "implementation": "user_or_ai_defined_interface", }) } return items } func requestedCapabilities(task string) []string { - candidates := capcatalog.PlanningNames() - var capabilities []string - lowerTask := strings.ToLower(task) - mongoRequested := containsAny(lowerTask, "mongodb", "mongo", "文档库") - for _, candidate := range candidates { - if strings.Contains(lowerTask, candidate) || strings.Contains(task, candidate+"能力") { - capabilities = append(capabilities, candidate) - } - } - if !mongoRequested && containsAny(lowerTask, "postgres", "postgre", "pg", "mysql", "入库", "持久化", "数据库", "database") { - capabilities = append(capabilities, "sql") - } - if mongoRequested { - capabilities = append(capabilities, "mongo") - } - if containsAny(lowerTask, "kafka", "sarama", "nats", "amqp", "rabbit", "消息", "队列") { - capabilities = append(capabilities, "mq") - } - if containsAny(lowerTask, "prometheus", "metrics", "metric", "指标") { - capabilities = append(capabilities, "metric") - } - if containsAny(lowerTask, "config", "配置", "acm") { - capabilities = append(capabilities, "config") - } - if containsAny(lowerTask, "nacos", "service discovery", "注册", "发现") { - capabilities = append(capabilities, "discovery") - } - if containsAny(lowerTask, "auth", "认证", "鉴权", "权限") { - capabilities = append(capabilities, "auth") - } - if containsAny(lowerTask, "redis") { - capabilities = append(capabilities, "redis") - } - if containsAny(lowerTask, "lock", "锁") { - capabilities = append(capabilities, "lock") - } - if containsAny(lowerTask, "sentinel", "限流", "熔断") { - capabilities = append(capabilities, "sentinel") - } - if containsAny(lowerTask, "sentry", "errortracker", "错误追踪") { - capabilities = append(capabilities, "errortracker") - } - if containsAny(lowerTask, "pyroscope", "profiler", "profile", "性能剖析") { - capabilities = append(capabilities, "profiler") - } - return uniqueStrings(capabilities) -} - -func providerHint(task string, capability string) string { - lowerTask := strings.ToLower(task) - switch capability { - case "sql": - switch { - case containsAny(lowerTask, "postgres", "postgre", "pg"): - return "postgres" - case containsAny(lowerTask, "mysql"): - return "mysql" - case containsAny(lowerTask, "gorm"): - return "gorm" - default: - return capcatalog.DefaultProvider(capability) - } - case "mongo": - return "mongo" - case "redis": - if containsAny(lowerTask, "goredis", "go-redis") { - return "goredis" - } - return "redis" - case "mq": - switch { - case containsAny(lowerTask, "sarama"): - return "sarama" - case containsAny(lowerTask, "nats"): - return "nats" - case containsAny(lowerTask, "amqp", "rabbit"): - return "amqp" - default: - return "kafka" - } - case "config": - switch { - case containsAny(lowerTask, "nacosofficial"): - return "nacosofficial" - case containsAny(lowerTask, "nacos"): - return "nacos" - case containsAny(lowerTask, "acm"): - return "acm" - case containsAny(lowerTask, "kv"): - return "configkv" - default: - return "file" - } - case "discovery": - if containsAny(lowerTask, "nacosofficial") { - return "nacosofficial" - } - return "nacos" - case "metric": - if containsAny(lowerTask, "otel") { - return "otel" - } - return "prometheus" - case "log": - return "zap" - case "trace": - return "otel" - case "httpclient": - return "standard" - case "transport": - return "netdialer" - case "auth": - return "security" - case "health": - return "noop" - case "kv": - return "kv" - case "store": - switch { - case containsAny(lowerTask, "cache"): - return "cache" - case containsAny(lowerTask, "bloom"): - return "bloom" - default: - return "memory" - } - case "lock": - if containsAny(lowerTask, "redis") { - return "redislock" - } - return "memorylock" - case "sentinel": - return "sentinel" - case "errortracker": - return "sentry" - case "profiler": - return "pyroscope" - default: - return capcatalog.DefaultProvider(capability) - } + return capvocab.MatchTask(task) } func taskType(task string) string { switch { case containsAny(task, "grpc", "proto", "rpc"): return taskTypeGRPCService - case containsAny(task, "cap", "能力", "bridge"): + case containsAny(task, "cap", "能力"): return taskTypeCapability case containsAny(task, "错误码", "error"): return taskTypeErrorCatalog diff --git a/cmd/nucleus/internal/plan/util.go b/cmd/nucleus/internal/plan/util.go index d28ad67..c5a5054 100644 --- a/cmd/nucleus/internal/plan/util.go +++ b/cmd/nucleus/internal/plan/util.go @@ -1,6 +1,10 @@ package plan -import "strings" +import ( + "strings" + + "github.com/nucleuskit/contract/diagnostic" +) func anyStringSlice(value any) []string { switch items := value.(type) { @@ -66,3 +70,14 @@ func uniqueStrings(values []string) []string { } return result } + +func mapDiagnostics(value any) diagnostic.Diagnostics { + switch diagnostics := value.(type) { + case diagnostic.Diagnostics: + return diagnostics + case []diagnostic.Diagnostic: + return diagnostic.Diagnostics(diagnostics) + default: + return diagnostic.Diagnostics{} + } +} diff --git a/cmd/nucleus/internal/recipe/builtin/sql-port-boundary.yaml b/cmd/nucleus/internal/recipe/builtin/sql-port-boundary.yaml new file mode 100644 index 0000000..393dfee --- /dev/null +++ b/cmd/nucleus/internal/recipe/builtin/sql-port-boundary.yaml @@ -0,0 +1,14 @@ +schema_version: "recipe.v1" +id: sql-port-boundary +kind: sql +language: go +suggest: + interfaces: + - keep relational persistence behind a project-owned interface + - make transaction ownership explicit in method names or context + - do not expose ORM, driver, or generated model types across API boundaries + verification: + - go test ./... +risks: + - provider, ORM, driver, and migration tooling must be captured as decision evidence + - schema changes need project-owned migration verification diff --git a/cmd/nucleus/internal/recipe/recipes.go b/cmd/nucleus/internal/recipe/recipes.go new file mode 100644 index 0000000..ebd6a6b --- /dev/null +++ b/cmd/nucleus/internal/recipe/recipes.go @@ -0,0 +1,498 @@ +package recipe + +import ( + "bytes" + "embed" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + "go.yaml.in/yaml/v3" +) + +const ( + defaultRecipeDir = ".nucleus/recipes" + builtinRecipeDir = "builtin" + builtinRecipePathPrefix = "builtin://recipes/" + recipeSourceBuiltin = "builtin" + recipeSourceProject = "project" + schemaVersionResult = "recipe-result.v1" + schemaRefResult = "contract/schema/recipe-result.v1.schema.json" +) + +//go:embed builtin/*.yaml +var builtinRecipes embed.FS + +// Filter selects recipes by stable metadata. +type Filter struct { + Kind string + Provider string +} + +// Document is a read-only recipe knowledge document. +type Document struct { + SchemaVersion string `yaml:"schema_version" json:"schema_version"` + ID string `yaml:"id" json:"id"` + Kind string `yaml:"kind" json:"kind"` + Provider string `yaml:"provider,omitempty" json:"provider,omitempty"` + Language string `yaml:"language" json:"language"` + Detect Detect `yaml:"detect,omitempty" json:"detect,omitempty"` + Suggest Suggest `yaml:"suggest,omitempty" json:"suggest,omitempty"` + Risks []string `yaml:"risks,omitempty" json:"risks,omitempty"` +} + +// Detect describes facts that can make a recipe relevant. +type Detect struct { + Imports []string `yaml:"imports,omitempty" json:"imports,omitempty"` + Files []string `yaml:"files,omitempty" json:"files,omitempty"` +} + +// Suggest carries non-executable hints for agents. +type Suggest struct { + Interfaces []string `yaml:"interfaces,omitempty" json:"interfaces,omitempty"` + Verification []string `yaml:"verification,omitempty" json:"verification,omitempty"` +} + +// Summary is the short list view returned by MCP and planning. +type Summary struct { + Path string `json:"path"` + Source string `json:"source"` + ID string `json:"id"` + Kind string `json:"kind"` + Provider string `json:"provider,omitempty"` + Language string `json:"language"` +} + +// ListResult is a structured recipe list payload. +type ListResult struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Recipes []Summary `json:"recipes"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +// GetResult is a structured single recipe payload. +type GetResult struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Path string `json:"path,omitempty"` + Source string `json:"source,omitempty"` + Recipe Document `json:"recipe,omitempty"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +// CandidateQuery controls plan-time recipe matching. +type CandidateQuery struct { + Task string + Kinds []string + Limit int +} + +// CandidateResult contains read-only recipe candidates for plan context. +type CandidateResult struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Candidates []Candidate `json:"candidates"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +// Candidate is a recipe hint. It is never an accepted decision. +type Candidate struct { + Path string `json:"path"` + Source string `json:"source"` + ID string `json:"id"` + Kind string `json:"kind"` + Provider string `json:"provider,omitempty"` + Language string `json:"language"` + Match []string `json:"match"` + SuggestedInterfaces []string `json:"suggested_interfaces,omitempty"` + SuggestedVerification []string `json:"suggested_verification,omitempty"` + Risks []string `json:"risks,omitempty"` + Selection string `json:"selection"` + DecisionRequired bool `json:"decision_required"` +} + +// List returns strict, read-only recipe summaries. +func List(dir string, filter Filter) (ListResult, error) { + recipesWithSource, diagnostics := loadRecipeEntries(dir) + var recipes []Summary + for _, item := range recipesWithSource { + if !recipeMatches(item.doc, filter) { + continue + } + recipes = append(recipes, Summary{Path: item.file.relPath, Source: item.file.source, ID: item.doc.ID, Kind: item.doc.Kind, Provider: item.doc.Provider, Language: item.doc.Language}) + } + sort.Slice(recipes, func(i, j int) bool { return recipes[i].ID < recipes[j].ID }) + diagnostics.Sort() + if recipes == nil { + recipes = []Summary{} + } + if diagnostics == nil { + diagnostics = diagnostic.Diagnostics{} + } + return ListResult{ + ResultKind: "nucleus.recipe_list_result", + SchemaVersion: schemaVersionResult, + SchemaRef: schemaRefResult, + OK: !diagnostics.Failed(), + Recipes: recipes, + Diagnostics: diagnostics, + }, nil +} + +// Get returns one strict, read-only recipe document. +func Get(dir string, id string) (GetResult, error) { + files, diagnostics := collectRecipeFiles(dir) + for _, file := range files { + doc, itemDiagnostics, ok := loadRecipeFile(file) + diagnostics = append(diagnostics, itemDiagnostics...) + if doc.ID == id { + diagnostics.Sort() + if diagnostics == nil { + diagnostics = diagnostic.Diagnostics{} + } + return GetResult{ + ResultKind: "nucleus.recipe_result", + SchemaVersion: schemaVersionResult, + SchemaRef: schemaRefResult, + OK: ok && !diagnostics.Failed(), + Path: file.relPath, + Source: file.source, + Recipe: doc, + Diagnostics: diagnostics, + }, nil + } + } + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.not_found", Path: defaultRecipeDir, Message: "recipe id was not found"}) + diagnostics.Sort() + return GetResult{ + ResultKind: "nucleus.recipe_result", + SchemaVersion: schemaVersionResult, + SchemaRef: schemaRefResult, + OK: false, + Diagnostics: diagnostics, + }, nil +} + +// Candidates returns read-only plan hints. Missing recipe directories are not a plan warning. +func Candidates(dir string, query CandidateQuery) CandidateResult { + recipesWithSource, diagnostics := loadRecipeEntries(dir) + diagnostics = suppressMissingDir(diagnostics) + var candidates []Candidate + for _, item := range recipesWithSource { + doc := item.doc + matches := candidateMatches(dir, doc, query) + if len(matches) == 0 { + continue + } + candidates = append(candidates, Candidate{ + Path: item.file.relPath, + Source: item.file.source, + ID: doc.ID, + Kind: doc.Kind, + Provider: doc.Provider, + Language: doc.Language, + Match: matches, + SuggestedInterfaces: nonNilStrings(doc.Suggest.Interfaces), + SuggestedVerification: nonNilStrings(doc.Suggest.Verification), + Risks: nonNilStrings(doc.Risks), + Selection: "candidate_only", + DecisionRequired: true, + }) + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].ID < candidates[j].ID }) + if query.Limit > 0 && len(candidates) > query.Limit { + candidates = candidates[:query.Limit] + } + diagnostics.Sort() + if candidates == nil { + candidates = []Candidate{} + } + if diagnostics == nil { + diagnostics = diagnostic.Diagnostics{} + } + return CandidateResult{ + ResultKind: "nucleus.recipe_candidate_result", + SchemaVersion: schemaVersionResult, + SchemaRef: schemaRefResult, + OK: !diagnostics.Failed(), + Candidates: candidates, + Diagnostics: diagnostics, + } +} + +type recipeFile struct { + relPath string + fullPath string + embedPath string + source string +} + +type loadedRecipe struct { + file recipeFile + doc Document +} + +func loadRecipeEntries(dir string) ([]loadedRecipe, diagnostic.Diagnostics) { + files, diagnostics := collectRecipeFiles(dir) + var loaded []loadedRecipe + projectIDs := map[string]bool{} + for _, file := range files { + doc, itemDiagnostics, ok := loadRecipeFile(file) + diagnostics = append(diagnostics, itemDiagnostics...) + if file.source == recipeSourceProject && strings.TrimSpace(doc.ID) != "" { + projectIDs[doc.ID] = true + } + if !ok { + continue + } + loaded = append(loaded, loadedRecipe{file: file, doc: doc}) + } + var selected []loadedRecipe + seenIDs := map[string]bool{} + for _, item := range loaded { + if item.file.source == recipeSourceBuiltin && projectIDs[item.doc.ID] { + continue + } + if seenIDs[item.doc.ID] { + continue + } + seenIDs[item.doc.ID] = true + selected = append(selected, item) + } + return selected, diagnostics +} + +func collectRecipeFiles(dir string) ([]recipeFile, diagnostic.Diagnostics) { + projectFiles, diagnostics := collectProjectRecipeFiles(dir) + builtinFiles, builtinDiagnostics := collectBuiltinRecipeFiles() + diagnostics = append(diagnostics, builtinDiagnostics...) + files := append(projectFiles, builtinFiles...) + sort.Slice(files, func(i, j int) bool { + if files[i].source != files[j].source { + return files[i].source == recipeSourceProject + } + return files[i].relPath < files[j].relPath + }) + return files, diagnostics +} + +func collectProjectRecipeFiles(dir string) ([]recipeFile, diagnostic.Diagnostics) { + root := filepath.Join(dir, filepath.FromSlash(defaultRecipeDir)) + info, err := os.Stat(root) + if err != nil { + if os.IsNotExist(err) { + return nil, diagnostic.Diagnostics{diagnostic.Diagnostic{Severity: diagnostic.SeverityWarning, Code: "recipe.dir_missing", Path: defaultRecipeDir, Message: "recipe directory does not exist"}} + } + return nil, diagnostic.Diagnostics{diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.dir_read_failed", Path: defaultRecipeDir, Message: err.Error()}} + } + if !info.IsDir() { + return nil, diagnostic.Diagnostics{diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.dir_invalid", Path: defaultRecipeDir, Message: "recipe path is not a directory"}} + } + var files []recipeFile + var diagnostics diagnostic.Diagnostics + err = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityWarning, Code: "recipe.path_read_failed", Path: defaultRecipeDir, Message: err.Error()}) + return nil + } + if entry.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + return nil + } + rel, relErr := filepath.Rel(dir, path) + if relErr != nil { + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityWarning, Code: "recipe.path_read_failed", Path: defaultRecipeDir, Message: relErr.Error()}) + return nil + } + files = append(files, recipeFile{relPath: filepath.ToSlash(rel), fullPath: path, source: recipeSourceProject}) + return nil + }) + if err != nil { + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.dir_read_failed", Path: defaultRecipeDir, Message: err.Error()}) + } + sort.Slice(files, func(i, j int) bool { return files[i].relPath < files[j].relPath }) + return files, diagnostics +} + +func collectBuiltinRecipeFiles() ([]recipeFile, diagnostic.Diagnostics) { + var files []recipeFile + err := fs.WalkDir(builtinRecipes, builtinRecipeDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + return nil + } + ext := strings.ToLower(filepath.Ext(entry.Name())) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + return nil + } + name := strings.TrimPrefix(path, builtinRecipeDir+"/") + files = append(files, recipeFile{ + relPath: builtinRecipePathPrefix + filepath.ToSlash(name), + embedPath: path, + source: recipeSourceBuiltin, + }) + return nil + }) + if err != nil { + return nil, diagnostic.Diagnostics{diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.builtin_read_failed", Path: builtinRecipeDir, Message: err.Error()}} + } + sort.Slice(files, func(i, j int) bool { return files[i].relPath < files[j].relPath }) + return files, nil +} + +func loadRecipeFile(file recipeFile) (Document, diagnostic.Diagnostics, bool) { + data, err := readRecipeFile(file) + if err != nil { + return Document{}, diagnostic.Diagnostics{diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.read_failed", Path: file.relPath, Message: err.Error()}}, false + } + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + var doc Document + if err := decoder.Decode(&doc); err != nil { + return Document{}, diagnostic.Diagnostics{diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.parse_failed", Path: file.relPath, Message: err.Error()}}, false + } + diagnostics := validateRecipe(file.relPath, doc) + return doc, diagnostics, !diagnostics.Failed() +} + +func readRecipeFile(file recipeFile) ([]byte, error) { + if file.source == recipeSourceBuiltin { + return builtinRecipes.ReadFile(file.embedPath) + } + return os.ReadFile(file.fullPath) +} + +func validateRecipe(path string, doc Document) diagnostic.Diagnostics { + var diagnostics diagnostic.Diagnostics + if doc.SchemaVersion != "recipe.v1" { + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.schema_version_invalid", Path: path, Message: "schema_version must be recipe.v1"}) + } + if strings.TrimSpace(doc.ID) == "" { + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.id_required", Path: path, Message: "id is required"}) + } + if strings.TrimSpace(doc.Kind) == "" { + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.kind_required", Path: path, Message: "kind is required"}) + } + if strings.TrimSpace(doc.Language) == "" { + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.language_required", Path: path, Message: "language is required"}) + } + for _, file := range doc.Detect.Files { + if invalidRecipePath(file) { + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "recipe.detect_file_invalid", Path: path, Message: "detect.files entries must be relative paths inside the service directory"}) + } + } + return diagnostics +} + +func invalidRecipePath(value string) bool { + value = strings.TrimSpace(filepath.ToSlash(value)) + return value == "" || strings.HasPrefix(value, "/") || value == ".." || strings.HasPrefix(value, "../") || strings.Contains(value, "/../") +} + +func recipeMatches(doc Document, filter Filter) bool { + if filter.Kind != "" && doc.Kind != filter.Kind { + return false + } + if filter.Provider != "" && doc.Provider != filter.Provider { + return false + } + return true +} + +func candidateMatches(dir string, doc Document, query CandidateQuery) []string { + var matches []string + for _, kind := range query.Kinds { + if doc.Kind == kind { + matches = append(matches, "kind:"+kind) + } + } + task := " " + strings.ToLower(query.Task) + " " + for _, token := range []string{doc.ID, doc.Kind, doc.Provider} { + token = strings.TrimSpace(strings.ToLower(token)) + if token != "" && strings.Contains(task, token) { + matches = append(matches, "task:"+token) + } + } + for _, file := range doc.Detect.Files { + if fileExists(dir, file) { + matches = append(matches, "file:"+filepath.ToSlash(file)) + } + } + for _, item := range doc.Detect.Imports { + if importExists(dir, item) { + matches = append(matches, "import:"+item) + } + } + return uniqueStrings(matches) +} + +func suppressMissingDir(diagnostics diagnostic.Diagnostics) diagnostic.Diagnostics { + var filtered diagnostic.Diagnostics + for _, item := range diagnostics { + if item.Code == "recipe.dir_missing" { + continue + } + filtered = append(filtered, item) + } + return filtered +} + +func fileExists(dir string, rel string) bool { + path := filepath.Join(dir, filepath.FromSlash(rel)) + info, err := os.Stat(path) + return err == nil && !info.IsDir() +} + +func importExists(dir string, importPath string) bool { + importPath = strings.TrimSpace(importPath) + if importPath == "" { + return false + } + found := false + _ = filepath.WalkDir(dir, func(path string, entry os.DirEntry, err error) error { + if err != nil || found || entry.IsDir() || filepath.Ext(entry.Name()) != ".go" { + return nil + } + data, readErr := os.ReadFile(path) + if readErr == nil && bytes.Contains(data, []byte(`"`+importPath+`"`)) { + found = true + } + return nil + }) + return found +} + +func nonNilStrings(values []string) []string { + if len(values) == 0 { + return []string{} + } + return values +} + +func uniqueStrings(values []string) []string { + seen := map[string]bool{} + var result []string + for _, value := range values { + if value == "" || seen[value] { + continue + } + seen[value] = true + result = append(result, value) + } + return result +} diff --git a/cmd/nucleus/internal/recipe/recipes_test.go b/cmd/nucleus/internal/recipe/recipes_test.go new file mode 100644 index 0000000..157cd5d --- /dev/null +++ b/cmd/nucleus/internal/recipe/recipes_test.go @@ -0,0 +1,176 @@ +package recipe + +import ( + "os" + "path/filepath" + "testing" +) + +func TestListRejectsExecutableRecipeFields(t *testing.T) { + dir := t.TempDir() + writeRecipeTestFile(t, dir, ".nucleus/recipes/gorm.yaml", `schema_version: "recipe.v1" +id: gorm-sql +kind: sql +provider: gorm +language: go +suggest: + interfaces: + - keep ORM behind a project-owned interface + verification: + - go test ./... +risks: + - transaction boundary must be explicit +`) + writeRecipeTestFile(t, dir, ".nucleus/recipes/unsafe.yaml", `schema_version: "recipe.v1" +id: unsafe +kind: sql +language: go +commands: + - rm -rf . +`) + + output, err := List(dir, Filter{Kind: "sql"}) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if output.OK { + t.Fatalf("OK = true, want false because unsafe recipe failed validation") + } + if !containsRecipe(output.Recipes, "gorm-sql", recipeSourceProject) { + t.Fatalf("recipes = %#v, want safe project gorm recipe", output.Recipes) + } + if !containsRecipe(output.Recipes, "sql-port-boundary", recipeSourceBuiltin) { + t.Fatalf("recipes = %#v, want built-in sql recipe", output.Recipes) + } + if !output.Diagnostics.Failed() { + t.Fatalf("diagnostics = %#v, want failed diagnostics", output.Diagnostics) + } +} + +func TestCandidatesAreReadOnlyHints(t *testing.T) { + dir := t.TempDir() + writeRecipeTestFile(t, dir, ".nucleus/recipes/gorm.yaml", `schema_version: "recipe.v1" +id: gorm-sql +kind: sql +provider: gorm +language: go +detect: + files: + - storage/db.go +suggest: + interfaces: + - expose storage through an interface + verification: + - go test ./storage +risks: + - avoid leaking ORM models into API contracts +`) + writeRecipeTestFile(t, dir, "storage/db.go", "package storage\n") + + output := Candidates(dir, CandidateQuery{Task: "add mysql capability", Kinds: []string{"sql"}}) + if !output.OK { + t.Fatalf("OK = false: %#v", output.Diagnostics) + } + if len(output.Candidates) != 2 { + t.Fatalf("candidates = %#v, want project and built-in candidates", output.Candidates) + } + candidate := findCandidate(t, output.Candidates, "gorm-sql") + if candidate.Selection != "candidate_only" || !candidate.DecisionRequired { + t.Fatalf("candidate should remain decision-only hint: %#v", candidate) + } + if candidate.ID != "gorm-sql" || candidate.Provider != "gorm" || candidate.Source != recipeSourceProject { + t.Fatalf("candidate = %#v, want gorm metadata as hint", candidate) + } + if len(candidate.SuggestedVerification) != 1 || candidate.SuggestedVerification[0] != "go test ./storage" { + t.Fatalf("suggested verification = %#v", candidate.SuggestedVerification) + } +} + +func TestCandidatesIgnoreMissingRecipeDir(t *testing.T) { + output := Candidates(t.TempDir(), CandidateQuery{Task: "add cache capability", Kinds: []string{"redis"}}) + if !output.OK { + t.Fatalf("OK = false: %#v", output.Diagnostics) + } + if len(output.Candidates) != 0 || len(output.Diagnostics) != 0 { + t.Fatalf("output = %#v, want empty candidate set without missing-dir warning", output) + } +} + +func TestCandidatesIncludeBuiltinReadOnlyRecipes(t *testing.T) { + output := Candidates(t.TempDir(), CandidateQuery{Task: "add mysql capability", Kinds: []string{"sql"}}) + if !output.OK { + t.Fatalf("OK = false: %#v", output.Diagnostics) + } + if len(output.Candidates) != 1 { + t.Fatalf("candidates = %#v, want one built-in candidate", output.Candidates) + } + candidate := output.Candidates[0] + if candidate.ID != "sql-port-boundary" || candidate.Source != recipeSourceBuiltin { + t.Fatalf("candidate = %#v, want built-in sql-port-boundary", candidate) + } + if candidate.Provider != "" { + t.Fatalf("candidate provider = %q, want provider-neutral built-in", candidate.Provider) + } + if candidate.Path != "builtin://recipes/sql-port-boundary.yaml" { + t.Fatalf("candidate path = %q, want built-in URI", candidate.Path) + } + if candidate.Selection != "candidate_only" || !candidate.DecisionRequired { + t.Fatalf("candidate should remain read-only decision hint: %#v", candidate) + } +} + +func TestProjectRecipeOverridesBuiltinByID(t *testing.T) { + dir := t.TempDir() + writeRecipeTestFile(t, dir, ".nucleus/recipes/sql.yaml", `schema_version: "recipe.v1" +id: sql-port-boundary +kind: sql +provider: team-sql +language: go +suggest: + verification: + - go test ./internal/storage +`) + + output := Candidates(dir, CandidateQuery{Task: "add mysql capability", Kinds: []string{"sql"}}) + if !output.OK { + t.Fatalf("OK = false: %#v", output.Diagnostics) + } + if len(output.Candidates) != 1 { + t.Fatalf("candidates = %#v, want local override only", output.Candidates) + } + candidate := output.Candidates[0] + if candidate.ID != "sql-port-boundary" || candidate.Source != recipeSourceProject || candidate.Provider != "team-sql" { + t.Fatalf("candidate = %#v, want project override", candidate) + } +} + +func writeRecipeTestFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func containsRecipe(values []Summary, id string, source string) bool { + for _, item := range values { + if item.ID == id && item.Source == source { + return true + } + } + return false +} + +func findCandidate(t *testing.T, values []Candidate, id string) Candidate { + t.Helper() + for _, item := range values { + if item.ID == id { + return item + } + } + t.Fatalf("candidate %q not found in %#v", id, values) + return Candidate{} +} diff --git a/cmd/nucleus/internal/repair/command_test.go b/cmd/nucleus/internal/repair/command_test.go index 7369510..ab8cff7 100644 --- a/cmd/nucleus/internal/repair/command_test.go +++ b/cmd/nucleus/internal/repair/command_test.go @@ -12,13 +12,14 @@ func TestCommandJSONManualActionReturnsSentinel(t *testing.T) { dir := t.TempDir() evidencePath := filepath.Join(dir, "evidence.json") writeFile(t, dir, "evidence.json", `{ - "kind": "nucleus.apply_evidence", - "pass": false, + "result_kind": "nucleus.apply_evidence", + "ok": false, "steps": [ { "id": "custom_failure", "kind": "custom_failure", - "pass": false + "status": "failed", + "ok": false } ] }`) @@ -39,14 +40,14 @@ func TestCommandJSONManualActionReturnsSentinel(t *testing.T) { } var output struct { - Kind string `json:"kind"` - Pass bool `json:"pass"` - Status string `json:"status"` + ResultKind string `json:"result_kind"` + OK bool `json:"ok"` + Status string `json:"status"` } if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) } - if output.Kind != "nucleus.repair_evidence" || output.Pass || output.Status != "needs_manual_action" { + if output.ResultKind != resultKindRepairEvidence || output.OK || output.Status != "needs_manual_action" { t.Fatalf("unexpected repair evidence: %#v", output) } } diff --git a/cmd/nucleus/internal/repair/constants.go b/cmd/nucleus/internal/repair/constants.go index b310685..9bd6a78 100644 --- a/cmd/nucleus/internal/repair/constants.go +++ b/cmd/nucleus/internal/repair/constants.go @@ -24,3 +24,15 @@ const ( jsonIndentPrefix = "" jsonIndentValue = " " ) + +const ( + resultKindRepairEvidence = "nucleus.repair_evidence" + schemaVersionEvidence = "evidence.v1" + schemaRefEvidence = "contract/schema/evidence.v1.schema.json" +) + +const ( + statusPassed = "passed" + statusFailed = "failed" + statusBlocked = "blocked" +) diff --git a/cmd/nucleus/internal/repair/output.go b/cmd/nucleus/internal/repair/output.go index 004c7e6..cd47fc8 100644 --- a/cmd/nucleus/internal/repair/output.go +++ b/cmd/nucleus/internal/repair/output.go @@ -30,8 +30,8 @@ func renderJSON(writer io.Writer, evidence map[string]any, pretty bool) error { } func evidencePass(evidence map[string]any) bool { - pass, _ := evidence["pass"].(bool) - return pass + ok, _ := evidence["ok"].(bool) + return ok } func valueLen(value any) int { diff --git a/cmd/nucleus/internal/repair/repair.go b/cmd/nucleus/internal/repair/repair.go index d55c200..9df881a 100644 --- a/cmd/nucleus/internal/repair/repair.go +++ b/cmd/nucleus/internal/repair/repair.go @@ -27,7 +27,7 @@ func BuildEvidence(dir string, evidencePath string, maxRounds int) (map[string]a if err != nil { return nil, err } - if evidence["kind"] == "nucleus.verify_result" && hasFailedStep(evidence, "generated_freshness") { + if evidenceResultKind(evidence) == "nucleus.verify_result" && hasFailedStep(evidence, "generated_freshness") { return regenerateAndVerify(dir, maxRounds, "regenerate_generated_freshness"), nil } if hasMissingGenerated(evidence) { @@ -38,47 +38,35 @@ func BuildEvidence(dir string, evidencePath string, maxRounds int) (map[string]a return applyPatchCandidate(dir, evidence, candidate, maxRounds), nil } if reason != "" { - return manualRepairEvidence(maxRounds, evidence["kind"], reason), nil + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), reason), nil } - return manualRepairEvidence(maxRounds, evidence["kind"], "automatic code repair is not implemented in the safe skeleton"), nil + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), "automatic code repair is not implemented in the safe skeleton"), nil } func manualRepairEvidence(maxRounds int, evidenceKind any, reason string) map[string]any { - return map[string]any{ - "schema_version": "repair.v1", - "kind": "nucleus.repair_evidence", - "pass": false, - "status": "needs_manual_action", - "max_rounds": maxRounds, - "rounds": []map[string]any{ - { - "id": "repair-1", - "status": "unsupported", - "reason": reason, - "evidence_kind": evidenceKind, - }, + rounds := []map[string]any{ + { + "id": "repair-1", + "status": "unsupported", + "reason": reason, + "evidence_kind": evidenceKind, }, } + return repairEvidence(false, "needs_manual_action", maxRounds, rounds, nil) } func regenerateAndVerify(dir string, maxRounds int, strategy string) map[string]any { result, genErr := contractgen.GenerateWithOptions(dir, contractgen.Options{HTTP: true, GRPC: true, Errors: true}) if genErr != nil { - return map[string]any{ - "schema_version": "repair.v1", - "kind": "nucleus.repair_evidence", - "pass": false, - "status": "failed", - "max_rounds": maxRounds, - "rounds": []map[string]any{ - { - "id": "repair-1", - "strategy": strategy, - "status": "failed", - "stderr": genErr.Error(), - }, + rounds := []map[string]any{ + { + "id": "repair-1", + "strategy": strategy, + "status": "failed", + "stderr": genErr.Error(), }, } + return repairEvidence(false, "failed", maxRounds, rounds, nil) } verifyResult := verify.BuildResultForDir(dir) verificationPass := verifyResult.OK @@ -86,56 +74,51 @@ func regenerateAndVerify(dir string, maxRounds int, strategy string) map[string] if verificationPass { status = "repaired" } - return map[string]any{ - "schema_version": "repair.v1", - "kind": "nucleus.repair_evidence", - "pass": verificationPass, - "status": status, - "max_rounds": maxRounds, - "verification_pass": verificationPass, - "rounds": []map[string]any{ - { - "id": "repair-1", - "strategy": strategy, - "status": status, - "generated_files": result.Files, - "source_hash": result.Hash, - }, + rounds := []map[string]any{ + { + "id": "repair-1", + "strategy": strategy, + "status": status, + "generated_files": result.Files, + "source_hash": result.Hash, }, - "verify_result": verifyResult, } + return repairEvidence(verificationPass, status, maxRounds, rounds, map[string]any{ + "verification_pass": verificationPass, + "verify_result": verifyResult, + }) } func applyPatchCandidate(dir string, evidence map[string]any, candidate patchCandidate, maxRounds int) map[string]any { description, err := inspect.Describe(dir) if err != nil { - return manualRepairEvidence(maxRounds, evidence["kind"], err.Error()) + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), err.Error()) } surface := classifyRepairSurface(candidate.File, description.EditSurfaces, evidenceAllowedFiles(evidence)) if surface != "allowed" { - return manualRepairEvidence(maxRounds, evidence["kind"], "patch file is not in allowed edit surfaces") + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), "patch file is not in allowed edit surfaces") } fullPath, err := resolveRepairPath(dir, candidate.File) if err != nil { - return manualRepairEvidence(maxRounds, evidence["kind"], err.Error()) + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), err.Error()) } if err := rejectSymlinkPath(dir, fullPath, candidate.File); err != nil { - return manualRepairEvidence(maxRounds, evidence["kind"], err.Error()) + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), err.Error()) } original, err := os.ReadFile(fullPath) if err != nil { - return manualRepairEvidence(maxRounds, evidence["kind"], err.Error()) + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), err.Error()) } originalHash := sha256String(string(original)) if candidate.ExpectedHash == "" || !strings.EqualFold(candidate.ExpectedHash, originalHash) { - return manualRepairEvidence(maxRounds, evidence["kind"], "patch expected_hash does not match current file") + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), "patch expected_hash does not match current file") } if count := strings.Count(string(original), candidate.Find); count != 1 { - return manualRepairEvidence(maxRounds, evidence["kind"], fmt.Sprintf("patch find must match exactly once, got %d", count)) + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), fmt.Sprintf("patch find must match exactly once, got %d", count)) } updated := strings.Replace(string(original), candidate.Find, candidate.Replace, 1) if err := os.WriteFile(fullPath, []byte(updated), 0o644); err != nil { - return manualRepairEvidence(maxRounds, evidence["kind"], err.Error()) + return manualRepairEvidence(maxRounds, evidenceResultKind(evidence), err.Error()) } verifyResult := verify.BuildResultForDir(dir) verificationPass := verifyResult.OK @@ -143,35 +126,30 @@ func applyPatchCandidate(dir string, evidence map[string]any, candidate patchCan if verificationPass { status = "repaired" } - return map[string]any{ - "schema_version": "repair.v1", - "kind": "nucleus.repair_evidence", - "pass": verificationPass, - "status": status, - "max_rounds": maxRounds, - "verification_pass": verificationPass, - "rounds": []map[string]any{ - { - "id": "repair-1", - "strategy": "bounded_business_patch", - "status": status, - "file": candidate.File, - "reason": candidate.Reason, - "rollback_point": map[string]any{ - "id": "rollback-1", - "path": candidate.File, - "strategy": "restore original content", - "available": true, - "original_hash": originalHash, - "original_content": string(original), - }, - "verification": map[string]any{ - "pass": verificationPass, - }, + rounds := []map[string]any{ + { + "id": "repair-1", + "strategy": "bounded_business_patch", + "status": status, + "file": candidate.File, + "reason": candidate.Reason, + "rollback_point": map[string]any{ + "id": "rollback-1", + "path": candidate.File, + "strategy": "restore original content", + "available": true, + "original_hash": originalHash, + "original_content": string(original), + }, + "verification": map[string]any{ + "ok": verificationPass, }, }, - "verify_result": verifyResult, } + return repairEvidence(verificationPass, status, maxRounds, rounds, map[string]any{ + "verification_pass": verificationPass, + "verify_result": verifyResult, + }) } func readJSONObject(path string) (map[string]any, error) { @@ -239,6 +217,75 @@ func rawStringField(value map[string]any, key string) string { return text } +func evidenceResultKind(evidence map[string]any) string { + return stringField(evidence, "result_kind") +} + +func repairEvidence(ok bool, status string, maxRounds int, rounds []map[string]any, extra map[string]any) map[string]any { + result := map[string]any{ + "result_kind": resultKindRepairEvidence, + "schema_version": schemaVersionEvidence, + "schema_ref": schemaRefEvidence, + "ok": ok, + "status": status, + "max_rounds": maxRounds, + "steps": repairSteps(rounds), + "diagnostics": []map[string]any{}, + "rounds": rounds, + } + for key, value := range extra { + result[key] = value + } + return result +} + +func repairSteps(rounds []map[string]any) []map[string]any { + steps := make([]map[string]any, 0, len(rounds)) + for index, round := range rounds { + id := stringField(round, "id") + if id == "" { + id = fmt.Sprintf("repair-%d", index+1) + } + status := repairStepStatus(stringField(round, "status")) + kind := stringField(round, "strategy") + if kind == "" { + kind = "repair_round" + } + step := map[string]any{ + "id": id, + "kind": kind, + "status": status, + "ok": status == statusPassed, + } + if reason := stringField(round, "reason"); reason != "" { + step["reason"] = reason + } + if stderr := stringField(round, "stderr"); stderr != "" { + step["error"] = stderr + } + if file := stringField(round, "file"); file != "" { + step["file"] = file + } + returnedStatus := stringField(round, "status") + if returnedStatus != "" && returnedStatus != status { + step["repair_status"] = returnedStatus + } + steps = append(steps, step) + } + return steps +} + +func repairStepStatus(status string) string { + switch status { + case "repaired": + return statusPassed + case "failed": + return statusFailed + default: + return statusBlocked + } +} + func hasFailedStep(evidence map[string]any, id string) bool { steps, ok := evidence["steps"].([]any) if !ok { @@ -250,8 +297,8 @@ func hasFailedStep(evidence map[string]any, id string) bool { continue } stepID, _ := step["id"].(string) - stepPass, _ := step["pass"].(bool) - if stepID == id && !stepPass { + stepOK, _ := step["ok"].(bool) + if stepID == id && !stepOK { return true } } @@ -283,8 +330,8 @@ func evidenceSteps(evidence map[string]any) []map[string]any { } func stepIndicatesMissingGenerated(step map[string]any) bool { - stepPass, _ := step["pass"].(bool) - if stepPass { + stepOK, _ := step["ok"].(bool) + if stepOK { return false } for _, key := range []string{"id", "kind"} { diff --git a/cmd/nucleus/internal/repair/repair_test.go b/cmd/nucleus/internal/repair/repair_test.go index de1595a..b614f6b 100644 --- a/cmd/nucleus/internal/repair/repair_test.go +++ b/cmd/nucleus/internal/repair/repair_test.go @@ -11,21 +11,17 @@ import ( func TestBuildEvidenceRepairsMissingGeneratedEvidence(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" ai: intent: test capabilities: - - http + - id: http + kind: http `) writeFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n\nrequire github.com/nucleuskit/http v0.0.0\n\nrequire (\n\tgithub.com/nucleuskit/cap v0.1.0-alpha.2 // indirect\n\tgithub.com/nucleuskit/core v0.1.0-alpha.2 // indirect\n)\n\n"+localRuntimeReplace(t)) - writeFile(t, dir, "go.sum", `github.com/nucleuskit/cap v0.1.0-alpha.2 h1:UnBp5ezoi+UrgT92DwpTdQynrEnUNnsf3rQKB0pNfPw= -github.com/nucleuskit/cap v0.1.0-alpha.2/go.mod h1:DLSQmS/6irYQfpWGJjce9+STvqG33yZMCxkwvdLf7XM= -github.com/nucleuskit/core v0.1.0-alpha.2 h1:CT4RJvCYtVNo0+Gqyf5zx4T90dBiXq4JVhQxEKBXyJ8= -github.com/nucleuskit/core v0.1.0-alpha.2/go.mod h1:fwIlIS28wLh/VQ/jhR4TgrZcrN1nOgrAxSYYYWRdEYk= -`) writeFile(t, dir, "demo.go", "package demo\n") writeFile(t, dir, "internal/app/routes.go", `package app @@ -55,13 +51,14 @@ paths: `) evidencePath := filepath.Join(dir, "missing-generated-evidence.json") writeFile(t, dir, "missing-generated-evidence.json", `{ - "kind": "nucleus.apply_evidence", - "pass": false, + "result_kind": "nucleus.apply_evidence", + "ok": false, "steps": [ { "id": "missing_generated", "kind": "missing_generated", - "pass": false, + "status": "failed", + "ok": false, "path": "contract/gen/endpoints.go" } ] @@ -88,7 +85,7 @@ paths: func TestBuildEvidenceDoesNotRepairVagueMissingGeneratedReason(t *testing.T) { dir := t.TempDir() - writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -102,13 +99,14 @@ capabilities: [] writeFile(t, dir, "api/errors.yaml", "errors: []\n") evidencePath := filepath.Join(dir, "vague-evidence.json") writeFile(t, dir, "vague-evidence.json", `{ - "kind": "nucleus.apply_evidence", - "pass": false, + "result_kind": "nucleus.apply_evidence", + "ok": false, "steps": [ { "id": "custom_failure", "kind": "custom_failure", - "pass": false, + "status": "failed", + "ok": false, "reason": "missing generated text in README" } ] @@ -130,12 +128,14 @@ func TestBuildEvidenceAppliesSafeBusinessPatch(t *testing.T) { writeFile(t, dir, "examples/hello-http/internal/adapter/http/handler.go", original) evidencePath := filepath.Join(dir, "evidence.json") writeFile(t, dir, "evidence.json", `{ - "kind": "nucleus.verify_result", - "pass": false, + "result_kind": "nucleus.verify_result", + "ok": false, "steps": [ { "id": "go_test", - "pass": false, + "kind": "verify_command", + "status": "failed", + "ok": false, "repair_suggestion": { "file": "examples/hello-http/internal/adapter/http/handler.go", "find": "return \"broken\"", @@ -181,8 +181,8 @@ func TestBuildEvidenceRejectsBusinessPatchOutsideAllowedSurface(t *testing.T) { writeFile(t, dir, "configs/prod.yaml", original) evidencePath := filepath.Join(dir, "evidence.json") writeFile(t, dir, "evidence.json", `{ - "kind": "nucleus.verify_result", - "pass": false, + "result_kind": "nucleus.verify_result", + "ok": false, "failure": { "fix_candidate": { "file": "configs/prod.yaml", @@ -214,8 +214,8 @@ func TestBuildEvidenceRejectsBusinessPatchWithMultipleFindMatches(t *testing.T) writeFile(t, dir, "internal/domain/service.go", original) evidencePath := filepath.Join(dir, "evidence.json") writeFile(t, dir, "evidence.json", `{ - "kind": "nucleus.verify_result", - "pass": false, + "result_kind": "nucleus.verify_result", + "ok": false, "failure": { "fix_candidate": { "file": "internal/domain/service.go", @@ -247,8 +247,8 @@ func TestBuildEvidenceRejectsBusinessPatchWithHashMismatch(t *testing.T) { writeFile(t, dir, "internal/domain/service.go", original) evidencePath := filepath.Join(dir, "evidence.json") writeFile(t, dir, "evidence.json", `{ - "kind": "nucleus.verify_result", - "pass": false, + "result_kind": "nucleus.verify_result", + "ok": false, "failure": { "fix_candidate": { "file": "internal/domain/service.go", @@ -290,8 +290,8 @@ func TestBuildEvidenceRejectsBusinessPatchSymlinkPath(t *testing.T) { } evidencePath := filepath.Join(dir, "evidence.json") writeFile(t, dir, "evidence.json", `{ - "kind": "nucleus.verify_result", - "pass": false, + "result_kind": "nucleus.verify_result", + "ok": false, "failure": { "fix_candidate": { "file": "internal/domain/service.go", @@ -332,7 +332,7 @@ func writeFile(t *testing.T, dir string, name string, data string) { func writePatchableService(t *testing.T, dir string, allowed []string) { t.Helper() - writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -382,5 +382,18 @@ func runtimeHTTPReplace(t *testing.T) string { func localRuntimeReplace(t *testing.T) string { t.Helper() - return "replace github.com/nucleuskit/http => " + runtimeHTTPReplace(t) + "\n" + return strings.Join([]string{ + "replace github.com/nucleuskit/http => " + runtimeHTTPReplace(t), + "replace github.com/nucleuskit/cap => " + localModulePath(t, "cap"), + "replace github.com/nucleuskit/core => " + localModulePath(t, "core"), + }, "\n\n") + "\n" +} + +func localModulePath(t *testing.T, rel string) string { + t.Helper() + path, err := filepath.Abs(filepath.Join("../../../../", rel)) + if err != nil { + t.Fatal(err) + } + return filepath.ToSlash(path) } diff --git a/cmd/nucleus/internal/report/ai_quality.go b/cmd/nucleus/internal/report/ai_quality.go index b892c1b..595a8b8 100644 --- a/cmd/nucleus/internal/report/ai_quality.go +++ b/cmd/nucleus/internal/report/ai_quality.go @@ -6,9 +6,11 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/decision" ) type aiQualityReport struct { @@ -35,6 +37,8 @@ type aiQualityReport struct { CapabilityErrorCount int `json:"capability_error_count"` CapabilitySummary map[string]capabilitySummary `json:"capability_summary"` StrategySummary strategyReport `json:"strategy_summary"` + DecisionQuality decision.QualitySummary `json:"decision_quality"` + RecipeCandidateUsage recipeCandidateUsageReport `json:"recipe_candidate_usage"` WeeklyReport string `json:"weekly_report"` } @@ -93,6 +97,15 @@ type capabilitySummary struct { StatusCount map[string]int `json:"status_count,omitempty"` } +type recipeCandidateUsageReport struct { + TaskCount int `json:"task_count"` + CandidateTaskCount int `json:"candidate_task_count"` + CandidateCount int `json:"candidate_count"` + DecisionRequiredCount int `json:"decision_required_count"` + UniqueCandidateIDs []string `json:"unique_candidate_ids"` + RecipeEvidenceCoverage float64 `json:"recipe_evidence_coverage"` +} + type aiQualityCountSet struct { scenario int realEvidence int @@ -108,8 +121,9 @@ type aiQualityCountSet struct { capabilityErrors int } -func buildAIQualityResult(tasksDir string, explicitTasksDir bool) reportResult { - report, diagnostics := loadAIQualityReport(tasksDir, explicitTasksDir) +func buildAIQualityResult(dir string, tasksDir string, explicitTasksDir bool) reportResult { + report, diagnostics := loadAIQualityReport(dir, tasksDir, explicitTasksDir) + diagnostics = append(diagnostics, report.DecisionQuality.Diagnostics...) summary := reportSummaryFromAIQuality(report) return finalizeReportResult(reportResult{ Mode: reportModeAIQuality, @@ -119,8 +133,8 @@ func buildAIQualityResult(tasksDir string, explicitTasksDir bool) reportResult { }) } -func loadAIQualityReport(tasksDir string, explicitTasksDir bool) (aiQualityReport, diagnostic.Diagnostics) { - report := emptyAIQualityReport(tasksDir, inputStatusLoaded) +func loadAIQualityReport(dir string, tasksDir string, explicitTasksDir bool) (aiQualityReport, diagnostic.Diagnostics) { + report := emptyAIQualityReport(dir, tasksDir, inputStatusLoaded) entries, err := os.ReadDir(tasksDir) if err != nil { if !explicitTasksDir && errors.Is(err, os.ErrNotExist) { @@ -148,15 +162,17 @@ func loadAIQualityReport(tasksDir string, explicitTasksDir bool) (aiQualityRepor } tasks = append(tasks, task) } - return aiQualityReportFromTasks(tasksDir, inputStatusLoaded, tasks), diagnostics + return aiQualityReportFromTasks(dir, tasksDir, inputStatusLoaded, tasks), diagnostics } -func emptyAIQualityReport(tasksDir string, status string) aiQualityReport { - return aiQualityReportFromTasks(tasksDir, status, nil) +func emptyAIQualityReport(dir string, tasksDir string, status string) aiQualityReport { + return aiQualityReportFromTasks(dir, tasksDir, status, nil) } -func aiQualityReportFromTasks(tasksDir string, status string, tasks []aiTaskResult) aiQualityReport { +func aiQualityReportFromTasks(dir string, tasksDir string, status string, tasks []aiTaskResult) aiQualityReport { counts := aiQualityCounts(tasks) + decisionQuality := decision.QualityForDir(dir) + recipeCandidateUsage := buildRecipeCandidateUsage(tasks) report := aiQualityReport{ InputStatus: status, TasksDir: safeReportPath(tasksDir), @@ -181,6 +197,8 @@ func aiQualityReportFromTasks(tasksDir string, status string, tasks []aiTaskResu CapabilityErrorCount: counts.capabilityErrors, CapabilitySummary: buildCapabilitySummary(tasks), StrategySummary: buildStrategySummary(tasks), + DecisionQuality: decisionQuality, + RecipeCandidateUsage: recipeCandidateUsage, } report.WeeklyReport = aiQualityMarkdown(report) return report @@ -206,6 +224,13 @@ func reportSummaryFromAIQuality(report aiQualityReport) reportSummary { RollbackCount: report.RollbackCount, CapabilityEventCount: report.CapabilityEventCount, CapabilityErrorCount: report.CapabilityErrorCount, + DecisionFileCount: report.DecisionQuality.Files, + DecisionValidCount: report.DecisionQuality.Valid, + DecisionErrorCount: report.DecisionQuality.Errors, + LockedDecisionCount: report.DecisionQuality.AcceptedLocked, + DecisionDriftCount: report.DecisionQuality.Drift, + RecipeCandidateTasks: report.RecipeCandidateUsage.CandidateTaskCount, + RecipeCandidateCount: report.RecipeCandidateUsage.CandidateCount, } } @@ -251,7 +276,7 @@ func normalizeAITaskResult(task *aiTaskResult) { func normalizeRepairEvidenceTask(task *aiTaskResult) { status := evidenceString(task.Evidence, "status", "") - verificationPass := evidenceBool(task.Evidence, "verification_pass", evidenceBool(task.Evidence, "pass", false)) + verificationPass := evidenceBool(task.Evidence, "verification_pass", evidenceBool(task.Evidence, "ok", false)) task.FailureLocated = replayFailureLocated(task.Evidence) task.FailureType = evidenceString(task.Evidence, "failure_type", status) task.ManualActionReason = evidenceString(task.Evidence, "manual_action_reason", "") @@ -278,7 +303,7 @@ func normalizeVerifyEvidenceTask(task *aiTaskResult) { } func normalizeGenericEvidenceTask(task *aiTaskResult) { - pass := evidenceBool(task.Evidence, "pass", evidenceBool(task.Evidence, "verification_pass", false)) + pass := evidenceBool(task.Evidence, "ok", evidenceBool(task.Evidence, "verification_pass", false)) status := evidenceString(task.Evidence, "status", "") task.PlanPass = true task.ApplyPass = pass @@ -293,9 +318,6 @@ func evidenceKind(evidence map[string]any) string { if evidence == nil { return "" } - if kind := evidenceString(evidence, "kind", ""); kind != "" { - return kind - } return evidenceString(evidence, "result_kind", "") } @@ -385,9 +407,6 @@ func replayRepairStrategy(evidence map[string]any) string { func verifyEvidenceHasFailedStep(evidence map[string]any) bool { for _, step := range evidenceSteps(evidence) { - if pass, exists := step["pass"].(bool); exists && !pass { - return true - } if ok, exists := step["ok"].(bool); exists && !ok { return true } @@ -401,9 +420,6 @@ func verifyEvidenceHasFailedStep(evidence map[string]any) bool { func verifyFailureType(evidence map[string]any) string { for _, step := range evidenceSteps(evidence) { - if pass, hasPass := step["pass"].(bool); hasPass && pass { - continue - } ok, hasOK := step["ok"].(bool) status, _ := step["status"].(string) if (hasOK && ok) || status == "passed" { @@ -565,6 +581,71 @@ func buildCapabilitySummary(tasks []aiTaskResult) map[string]capabilitySummary { return summaries } +func buildRecipeCandidateUsage(tasks []aiTaskResult) recipeCandidateUsageReport { + usage := recipeCandidateUsageReport{TaskCount: len(tasks)} + ids := map[string]bool{} + for _, task := range tasks { + before := usage.CandidateCount + collectRecipeCandidates(task.Evidence, &usage, ids) + if usage.CandidateCount > before { + usage.CandidateTaskCount++ + } + } + usage.UniqueCandidateIDs = sortedStringKeys(ids) + usage.RecipeEvidenceCoverage = rate(usage.CandidateTaskCount, usage.TaskCount) + return usage +} + +func collectRecipeCandidates(value any, usage *recipeCandidateUsageReport, ids map[string]bool) { + switch item := value.(type) { + case map[string]any: + for key, child := range item { + if key == "recipe_candidates" { + countRecipeCandidateArray(child, usage, ids) + continue + } + if key == "candidates" && evidenceKind(item) == "nucleus.recipe_candidate_result" { + countRecipeCandidateArray(child, usage, ids) + continue + } + collectRecipeCandidates(child, usage, ids) + } + case []any: + for _, child := range item { + collectRecipeCandidates(child, usage, ids) + } + } +} + +func countRecipeCandidateArray(value any, usage *recipeCandidateUsageReport, ids map[string]bool) { + items, ok := value.([]any) + if !ok { + return + } + for _, raw := range items { + candidate, ok := raw.(map[string]any) + if !ok { + continue + } + usage.CandidateCount++ + if id := evidenceString(candidate, "id", ""); id != "" { + ids[id] = true + } + if required, ok := candidate["decision_required"].(bool); ok && required { + usage.DecisionRequiredCount++ + } + } +} + +func sortedStringKeys(values map[string]bool) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} + func capabilityEventFailed(event capabilityEvent) bool { switch event.Status { case "error", "failed", "timeout", "rejected": @@ -623,7 +704,7 @@ func chooseNonEmpty(value string, fallback string) string { func aiQualityMarkdown(report aiQualityReport) string { return fmt.Sprintf( - "AI quality report: tasks=%d scenario=%d real_evidence=%d source_coverage=%.2f first_pass=%.2f failure_located=%.2f repair_success=%.2f manual_intervention=%.2f rollback=%.2f", + "AI quality report: tasks=%d scenario=%d real_evidence=%d source_coverage=%.2f first_pass=%.2f failure_located=%.2f repair_success=%.2f manual_intervention=%.2f rollback=%.2f decisions=%d locked=%d decision_drift=%d recipe_candidates=%d", report.TaskCount, report.ScenarioTaskCount, report.RealEvidenceTaskCount, @@ -633,6 +714,10 @@ func aiQualityMarkdown(report aiQualityReport) string { report.RepairSuccessRate, report.ManualInterventionRate, report.RollbackRate, + report.DecisionQuality.Files, + report.DecisionQuality.AcceptedLocked, + report.DecisionQuality.Drift, + report.RecipeCandidateUsage.CandidateCount, ) } diff --git a/cmd/nucleus/internal/report/command.go b/cmd/nucleus/internal/report/command.go index bd9443d..c3b685a 100644 --- a/cmd/nucleus/internal/report/command.go +++ b/cmd/nucleus/internal/report/command.go @@ -14,7 +14,6 @@ type Config struct { type options struct { aiTasksPath string - platform bool json bool pretty bool } @@ -50,7 +49,6 @@ func NewCommand(config Config) *cobra.Command { }, } cmd.Flags().StringVar(&opts.aiTasksPath, flagAITasks, "", flagHelpAITasks) - cmd.Flags().BoolVar(&opts.platform, flagPlatform, false, flagHelpPlatform) cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) return cmd diff --git a/cmd/nucleus/internal/report/command_test.go b/cmd/nucleus/internal/report/command_test.go index 5b4475c..3dbda23 100644 --- a/cmd/nucleus/internal/report/command_test.go +++ b/cmd/nucleus/internal/report/command_test.go @@ -27,7 +27,8 @@ func TestReportCommandEmitsAIQualityJSON(t *testing.T) { "kind": "nucleus.evidence_replay", "labels": ["single_service", "repairable"], "evidence": { - "kind": "nucleus.repair_evidence", + "result_kind": "nucleus.repair_evidence", + "ok": true, "status": "repaired", "verification_pass": true, "rounds": [ @@ -162,55 +163,8 @@ func TestReportCommandExplicitMissingAITasksDirFailsWithJSONDiagnostics(t *testi assertReportContainsDiagnostic(t, diagnostics, "report.ai_tasks_read_failed") } -func TestReportCommandEmitsPlatformReadinessJSON(t *testing.T) { +func TestReportCommandRejectsRemovedPlatformFlag(t *testing.T) { serviceDir := t.TempDir() - writeReportService(t, serviceDir) - writeReportFile(t, serviceDir, "cmd/demo/main.go", `package main - -import ( - _ "github.com/nucleuskit/bridge/zap" - _ "github.com/nucleuskit/cap/log" -) - -func main() {} -`) - - dir := serviceDir - cmd := NewCommand(Config{Dir: &dir}) - var stdout bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--platform", "--json"}) - - if err := cmd.Execute(); err != nil { - t.Fatalf("execute report: %v", err) - } - - var output map[string]any - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("decode report output: %v\n%s", err, stdout.String()) - } - assertReportString(t, output, "mode", reportModePlatformReadiness) - assertReportBool(t, output, "ok", true) - platform := assertReportMap(t, output, "platform_readiness") - assertReportString(t, platform, "service", "demo") - providerStrategies := assertReportSlice(t, platform, "provider_strategy") - if len(providerStrategies) != 1 { - t.Fatalf("provider_strategy len = %d, want 1: %#v", len(providerStrategies), providerStrategies) - } - strategy, ok := providerStrategies[0].(map[string]any) - if !ok { - t.Fatalf("provider_strategy[0] has type %T", providerStrategies[0]) - } - assertReportString(t, strategy, "capability", "log") - assertReportString(t, strategy, "provider", "zap") - assertReportString(t, strategy, "sdk_status", "optional_external_provider_detected") - gates := assertReportSlice(t, platform, "readiness_gates") - assertReportContainsGate(t, gates, "generated_freshness", false) -} - -func TestReportCommandRedactsAbsolutePlatformInspectErrors(t *testing.T) { - serviceDir := filepath.Join(t.TempDir(), "missing-service") dir := serviceDir cmd := NewCommand(Config{Dir: &dir}) var stdout bytes.Buffer @@ -220,58 +174,16 @@ func TestReportCommandRedactsAbsolutePlatformInspectErrors(t *testing.T) { err := cmd.Execute() if err == nil { - t.Fatal("execute report succeeded, want inspect failure") + t.Fatal("execute report succeeded, want unknown platform flag failure") } - var output map[string]any - if decodeErr := json.Unmarshal(stdout.Bytes(), &output); decodeErr != nil { - t.Fatalf("decode report output: %v\n%s", decodeErr, stdout.String()) + if !strings.Contains(err.Error(), "unknown flag: --platform") { + t.Fatalf("error = %v, want unknown platform flag", err) } - diagnostics := assertReportSlice(t, output, "diagnostics") - assertReportContainsDiagnostic(t, diagnostics, reportDiagnosticInspectFailed) - for _, raw := range diagnostics { - item, ok := raw.(map[string]any) - if !ok { - continue - } - for _, key := range []string{"path", "message"} { - text, _ := item[key].(string) - if strings.Contains(text, serviceDir) { - t.Fatalf("diagnostic %s leaked absolute path %q in %#v", key, serviceDir, item) - } - } - } -} - -func TestReportCommandRejectsConflictingInputModes(t *testing.T) { - serviceDir := t.TempDir() - dir := serviceDir - cmd := NewCommand(Config{Dir: &dir}) - cmd.SetOut(&bytes.Buffer{}) - cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--platform", "--ai-tasks", filepath.Join(serviceDir, "tasks")}) - - err := cmd.Execute() - if err == nil || !strings.Contains(err.Error(), "--platform cannot be combined with --ai-tasks") { - t.Fatalf("error = %v, want conflicting input mode error", err) + if stdout.Len() != 0 { + t.Fatalf("stdout = %q, want empty output for rejected platform flag", stdout.String()) } } -func writeReportService(t *testing.T, dir string) { - t.Helper() - writeReportFile(t, dir, "nucleus.yaml", `schema_version: "1.0" -service: - name: demo - version: "0.1.0" -capabilities: - - log -ai: - generated: - - contract/gen -`) - writeReportFile(t, dir, "api/openapi.yaml", "openapi: 3.0.3\npaths: {}\n") - writeReportFile(t, dir, "api/errors.yaml", "errors: []\n") -} - func writeReportFile(t *testing.T, dir string, name string, data string) { t.Helper() path := filepath.Join(dir, name) @@ -344,18 +256,3 @@ func assertReportContainsDiagnostic(t *testing.T, diagnostics []any, code string } t.Fatalf("no diagnostic %q in %#v", code, diagnostics) } - -func assertReportContainsGate(t *testing.T, gates []any, id string, pass bool) { - t.Helper() - for _, value := range gates { - item, ok := value.(map[string]any) - if !ok || item["id"] != id { - continue - } - if item["pass"] != pass { - t.Fatalf("gate %s pass = %#v, want %v", id, item["pass"], pass) - } - return - } - t.Fatalf("no gate %q in %#v", id, gates) -} diff --git a/cmd/nucleus/internal/report/constants.go b/cmd/nucleus/internal/report/constants.go index 276cf40..4fa8ee2 100644 --- a/cmd/nucleus/internal/report/constants.go +++ b/cmd/nucleus/internal/report/constants.go @@ -2,34 +2,31 @@ package report const ( commandUseReport = "report" - commandShortReport = "summarize AI change quality and release readiness" + commandShortReport = "summarize local AI change quality" defaultDir = "." defaultAITasksDir = "artifacts/nucleus/ai-tasks" ) const ( - flagAITasks = "ai-tasks" - flagPlatform = "platform" - flagJSON = "json" - flagPretty = "pretty" + flagAITasks = "ai-tasks" + flagJSON = "json" + flagPretty = "pretty" ) const ( - flagHelpAITasks = "directory containing AI task result JSON files" - flagHelpPlatform = "emit platform and release readiness metadata" - flagHelpJSON = "emit machine-readable report result" - flagHelpPretty = "pretty-print JSON output" + flagHelpAITasks = "directory containing AI task result JSON files" + flagHelpJSON = "emit machine-readable report result" + flagHelpPretty = "pretty-print JSON output" ) const ( - reportModeAIQuality = "ai_quality" - reportModePlatformReadiness = "platform_readiness" + reportModeAIQuality = "ai_quality" ) const ( resultKindReport = "nucleus.report_result" schemaVersionReport = "report.v1" - schemaRefReport = "contract/schema/report.schema.json" + schemaRefReport = "contract/schema/report.v1.schema.json" jsonIndentPrefix = "" jsonIndentValue = " " ) @@ -38,7 +35,6 @@ const ( reportDiagnosticAITasksMissing = "report.ai_tasks_missing" reportDiagnosticAITasksReadFailed = "report.ai_tasks_read_failed" reportDiagnosticAITaskParseFailed = "report.ai_task_parse_failed" - reportDiagnosticInspectFailed = "report.inspect_failed" ) const ( diff --git a/cmd/nucleus/internal/report/output.go b/cmd/nucleus/internal/report/output.go index 40d4506..5fa444f 100644 --- a/cmd/nucleus/internal/report/output.go +++ b/cmd/nucleus/internal/report/output.go @@ -9,15 +9,14 @@ import ( ) type reportResult struct { - ResultKind string `json:"result_kind"` - SchemaVersion string `json:"schema_version"` - SchemaRef string `json:"schema_ref"` - OK bool `json:"ok"` - Mode string `json:"mode"` - Summary reportSummary `json:"summary"` - Diagnostics diagnostic.Diagnostics `json:"diagnostics"` - AIQuality *aiQualityReport `json:"ai_quality,omitempty"` - PlatformReadiness *platformReadinessReport `json:"platform_readiness,omitempty"` + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Mode string `json:"mode"` + Summary reportSummary `json:"summary"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` + AIQuality *aiQualityReport `json:"ai_quality,omitempty"` } type reportSummary struct { @@ -41,16 +40,17 @@ type reportSummary struct { RollbackCount int `json:"rollback_count"` CapabilityEventCount int `json:"capability_event_count"` CapabilityErrorCount int `json:"capability_error_count"` + DecisionFileCount int `json:"decision_file_count"` + DecisionValidCount int `json:"decision_valid_count"` + DecisionErrorCount int `json:"decision_error_count"` + LockedDecisionCount int `json:"locked_decision_count"` + DecisionDriftCount int `json:"decision_drift_count"` + RecipeCandidateTasks int `json:"recipe_candidate_tasks"` + RecipeCandidateCount int `json:"recipe_candidate_count"` EndpointCount int `json:"endpoint_count"` GRPCServiceCount int `json:"grpc_service_count"` CapabilityCount int `json:"capability_count"` GeneratedFresh bool `json:"generated_fresh"` - ReadinessGateCount int `json:"readiness_gate_count"` - ReadinessGatePassed int `json:"readiness_gate_passed"` - ReadinessGateFailed int `json:"readiness_gate_failed"` - RiskGateCount int `json:"risk_gate_count"` - RiskGatePassed int `json:"risk_gate_passed"` - RiskGateFailed int `json:"risk_gate_failed"` } func renderHuman(stdout io.Writer, stderr io.Writer, result reportResult) { @@ -63,12 +63,7 @@ func renderHuman(stdout io.Writer, stderr io.Writer, result reportResult) { _, _ = fmt.Fprintln(stderr, "FAILED") } _, _ = fmt.Fprintf(stdout, "mode: %s\n", result.Mode) - switch result.Mode { - case reportModePlatformReadiness: - renderPlatformHuman(stdout, result) - default: - renderAIQualityHuman(stdout, result) - } + renderAIQualityHuman(stdout, result) _, _ = fmt.Fprintf(stdout, "diagnostics: %d errors, %d warnings\n", result.Summary.Errors, result.Summary.Warnings) } @@ -80,22 +75,11 @@ func renderAIQualityHuman(stdout io.Writer, result reportResult) { _, _ = fmt.Fprintf(stdout, "manual_intervention_rate: %.2f\n", result.Summary.ManualInterventionRate) if result.AIQuality != nil && result.AIQuality.TasksDir != "" { _, _ = fmt.Fprintf(stdout, "ai_tasks: %s\n", result.AIQuality.TasksDir) + _, _ = fmt.Fprintf(stdout, "decisions: files=%d valid=%d locked=%d drift=%d\n", result.AIQuality.DecisionQuality.Files, result.AIQuality.DecisionQuality.Valid, result.AIQuality.DecisionQuality.AcceptedLocked, result.AIQuality.DecisionQuality.Drift) + _, _ = fmt.Fprintf(stdout, "recipe_candidates: tasks=%d candidates=%d decision_required=%d\n", result.AIQuality.RecipeCandidateUsage.CandidateTaskCount, result.AIQuality.RecipeCandidateUsage.CandidateCount, result.AIQuality.RecipeCandidateUsage.DecisionRequiredCount) } } -func renderPlatformHuman(stdout io.Writer, result reportResult) { - if result.PlatformReadiness == nil { - return - } - platform := result.PlatformReadiness - _, _ = fmt.Fprintf(stdout, "service: %s\n", platform.Service) - _, _ = fmt.Fprintf(stdout, "version: %s\n", platform.Version) - _, _ = fmt.Fprintf(stdout, "endpoints: %d\n", result.Summary.EndpointCount) - _, _ = fmt.Fprintf(stdout, "capabilities: %d\n", result.Summary.CapabilityCount) - _, _ = fmt.Fprintf(stdout, "generated_fresh: %t\n", result.Summary.GeneratedFresh) - _, _ = fmt.Fprintf(stdout, "readiness_gates: %d passed, %d failed\n", result.Summary.ReadinessGatePassed, result.Summary.ReadinessGateFailed) -} - func renderJSON(writer io.Writer, result reportResult, pretty bool) error { result.ResultKind = resultKindReport result.SchemaVersion = schemaVersionReport diff --git a/cmd/nucleus/internal/report/platform.go b/cmd/nucleus/internal/report/platform.go deleted file mode 100644 index af4c101..0000000 --- a/cmd/nucleus/internal/report/platform.go +++ /dev/null @@ -1,292 +0,0 @@ -package report - -import ( - "strings" - - "github.com/nucleuskit/contract/diagnostic" - "github.com/nucleuskit/contract/inspect" - "github.com/nucleuskit/contract/manifest" - "github.com/nucleuskit/contract/openapi" - "github.com/nucleuskit/contract/proto" -) - -type platformReadinessReport struct { - Service string `json:"service"` - Version string `json:"version"` - EndpointCount int `json:"endpoint_count"` - GRPCServiceCount int `json:"grpc_service_count"` - CapabilityCount int `json:"capability_count"` - Capabilities []string `json:"capabilities"` - GeneratedFresh bool `json:"generated_fresh"` - GeneratedFreshness []inspect.GeneratedFreshness `json:"generated_freshness"` - VerificationCommands []string `json:"verification_commands"` - PlatformMapping string `json:"platform_mapping"` - ReleaseMatrix []string `json:"release_matrix"` - PlatformUploadPayload platformUploadPayload `json:"platform_upload_payload"` - ReleaseDryRun releaseDryRunPayload `json:"release_dry_run"` - ReadinessGates []readinessGate `json:"readiness_gates"` - RiskGates []riskGate `json:"risk_gates"` - ProviderStrategy []providerStrategy `json:"provider_strategy"` - ControlPlaneIncluded bool `json:"control_plane_included"` - ExternalSDKRequired bool `json:"external_sdk_required"` - ProductionBridgeScope string `json:"production_bridge_scope"` -} - -type readinessGate struct { - ID string `json:"id"` - Pass bool `json:"pass"` - Artifact string `json:"artifact,omitempty"` - Reason string `json:"reason,omitempty"` -} - -type riskGate struct { - ID string `json:"id"` - Pass bool `json:"pass"` - Severity string `json:"severity"` - Reason string `json:"reason"` -} - -type providerStrategy struct { - Capability string `json:"capability"` - Provider string `json:"provider,omitempty"` - SDKStatus string `json:"sdk_status"` - Gaps []string `json:"gaps"` -} - -type platformUploadPayload struct { - Artifact string `json:"artifact"` - NetworkRequired bool `json:"network_required"` - Service manifest.Service `json:"service"` - Capabilities []string `json:"capabilities"` - Endpoints []openapi.Endpoint `json:"endpoints"` - GRPCServices []proto.Service `json:"grpc_services"` - GeneratedFresh bool `json:"generated_fresh"` - VerificationCommands []string `json:"verification_commands"` -} - -type releaseDryRunPayload struct { - Artifact string `json:"artifact"` - NetworkRequired bool `json:"network_required"` - ReleaseMatrix []string `json:"release_matrix"` - VerificationCommands []string `json:"verification_commands"` -} - -func buildPlatformReadinessResult(dir string) reportResult { - description, err := inspect.Describe(dir) - if err != nil { - return finalizeReportResult(reportResult{ - Mode: reportModePlatformReadiness, - Diagnostics: diagnostic.Diagnostics{ - reportErrorDiagnostic(safeReportPath(dir), reportDiagnosticInspectFailed, safeErrorMessage(err)), - }, - }) - } - report := platformReadinessFromDescription(description) - summary := reportSummaryFromPlatformReadiness(report) - return finalizeReportResult(reportResult{ - Mode: reportModePlatformReadiness, - Summary: summary, - PlatformReadiness: &report, - }) -} - -func platformReadinessFromDescription(description inspect.Description) platformReadinessReport { - generatedFresh := generatedFreshnessPass(description.GeneratedFreshness) - releaseMatrix := []string{"linux/amd64", "linux/arm64"} - platformPayload := platformUploadPayload{ - Artifact: "local:artifacts/nucleus/platform-upload-payload.json", - NetworkRequired: false, - Service: description.Service, - Capabilities: copyStringSlice(description.Capabilities), - Endpoints: copyEndpointSlice(description.Endpoints), - GRPCServices: copyGRPCServiceSlice(description.GRPCServices), - GeneratedFresh: generatedFresh, - VerificationCommands: copyStringSlice(description.Verification.Commands), - } - releaseDryRun := releaseDryRunPayload{ - Artifact: "local:artifacts/nucleus/release-dry-run.json", - NetworkRequired: false, - ReleaseMatrix: releaseMatrix, - VerificationCommands: copyStringSlice(description.Verification.Commands), - } - return platformReadinessReport{ - Service: description.Service.Name, - Version: description.Service.Version, - EndpointCount: len(description.Endpoints), - GRPCServiceCount: len(description.GRPCServices), - CapabilityCount: len(description.Capabilities), - Capabilities: copyStringSlice(description.Capabilities), - GeneratedFresh: generatedFresh, - GeneratedFreshness: description.GeneratedFreshness, - VerificationCommands: copyStringSlice(description.Verification.Commands), - PlatformMapping: "docs/platform-mapping.md", - ReleaseMatrix: releaseMatrix, - PlatformUploadPayload: platformPayload, - ReleaseDryRun: releaseDryRun, - ReadinessGates: buildReadinessGates(generatedFresh, description.Verification.Commands), - RiskGates: buildRiskGates(generatedFresh), - ProviderStrategy: buildProviderStrategy(description.CapabilityGraph), - ControlPlaneIncluded: false, - ExternalSDKRequired: false, - ProductionBridgeScope: "capability protocol metadata only; provider SDKs stay optional bridge/user code", - } -} - -func generatedFreshnessPass(items []inspect.GeneratedFreshness) bool { - for _, item := range items { - if !item.Fresh { - return false - } - } - return true -} - -func reportSummaryFromPlatformReadiness(report platformReadinessReport) reportSummary { - readinessPassed, readinessFailed := gateCounts(report.ReadinessGates) - riskPassed, riskFailed := riskGateCounts(report.RiskGates) - return reportSummary{ - EndpointCount: report.EndpointCount, - GRPCServiceCount: report.GRPCServiceCount, - CapabilityCount: report.CapabilityCount, - GeneratedFresh: report.GeneratedFresh, - ReadinessGateCount: len(report.ReadinessGates), - ReadinessGatePassed: readinessPassed, - ReadinessGateFailed: readinessFailed, - RiskGateCount: len(report.RiskGates), - RiskGatePassed: riskPassed, - RiskGateFailed: riskFailed, - } -} - -func buildReadinessGates(generatedFresh bool, verificationCommands []string) []readinessGate { - return []readinessGate{ - { - ID: "platform_upload_payload", - Pass: true, - Artifact: "local:artifacts/nucleus/platform-upload-payload.json", - Reason: "local artifact metadata generated; no platform network call required", - }, - { - ID: "release_dry_run", - Pass: true, - Artifact: "local:artifacts/nucleus/release-dry-run.json", - Reason: "release dry-run metadata is local-only", - }, - { - ID: "generated_freshness", - Pass: generatedFresh, - Reason: chooseBoolReason(generatedFresh, "generated artifacts are fresh", "generated artifacts are stale or missing"), - }, - { - ID: "verification_commands", - Pass: len(verificationCommands) > 0, - Reason: chooseBoolReason(len(verificationCommands) > 0, "verification commands are declared", "verification commands are missing"), - }, - } -} - -func buildRiskGates(generatedFresh bool) []riskGate { - return []riskGate{ - { - ID: "external_provider_sdk", - Pass: true, - Severity: "info", - Reason: "provider SDKs remain optional bridge or user code; report does not import SDKs", - }, - { - ID: "control_plane_network", - Pass: true, - Severity: "info", - Reason: "platform upload and release dry-run are local artifacts only", - }, - { - ID: "generated_freshness", - Pass: generatedFresh, - Severity: chooseBoolReason(generatedFresh, "info", "warning"), - Reason: chooseBoolReason(generatedFresh, "generated artifacts are fresh", "stale generated artifacts block release readiness"), - }, - } -} - -func buildProviderStrategy(nodes []inspect.CapabilityNode) []providerStrategy { - strategies := make([]providerStrategy, 0, len(nodes)) - for _, node := range nodes { - strategy := providerStrategy{ - Capability: node.Capability, - Provider: node.Provider, - SDKStatus: providerSDKStatus(node.Provider), - Gaps: []string{ - "health_check: define provider-specific readiness probe before production rollout", - "fallback: define noop/local fallback behavior for provider outage", - "observability: define provider metrics, traces, and error labels", - }, - } - strategies = append(strategies, strategy) - } - return strategies -} - -func providerSDKStatus(provider string) string { - switch strings.TrimSpace(provider) { - case "": - return "optional_no_provider_detected" - case "noop", "memory", "file": - return "optional_local_provider_detected" - default: - return "optional_external_provider_detected" - } -} - -func gateCounts(gates []readinessGate) (int, int) { - passed := 0 - failed := 0 - for _, gate := range gates { - if gate.Pass { - passed++ - } else { - failed++ - } - } - return passed, failed -} - -func riskGateCounts(gates []riskGate) (int, int) { - passed := 0 - failed := 0 - for _, gate := range gates { - if gate.Pass { - passed++ - } else { - failed++ - } - } - return passed, failed -} - -func chooseBoolReason(ok bool, pass string, fail string) string { - if ok { - return pass - } - return fail -} - -func copyStringSlice(values []string) []string { - if len(values) == 0 { - return []string{} - } - return append([]string(nil), values...) -} - -func copyEndpointSlice(values []openapi.Endpoint) []openapi.Endpoint { - if len(values) == 0 { - return []openapi.Endpoint{} - } - return append([]openapi.Endpoint(nil), values...) -} - -func copyGRPCServiceSlice(values []proto.Service) []proto.Service { - if len(values) == 0 { - return []proto.Service{} - } - return append([]proto.Service(nil), values...) -} diff --git a/cmd/nucleus/internal/report/report_test.go b/cmd/nucleus/internal/report/report_test.go index 0b49449..4b1a4b3 100644 --- a/cmd/nucleus/internal/report/report_test.go +++ b/cmd/nucleus/internal/report/report_test.go @@ -1,9 +1,13 @@ package report import ( + "bytes" "os" "path/filepath" + "strings" "testing" + + decisioncmd "github.com/nucleuskit/nucleus/cmd/nucleus/internal/decision" ) func TestAIQualityReportCountsScenarioAndRealEvidenceSources(t *testing.T) { @@ -26,7 +30,8 @@ func TestAIQualityReportCountsScenarioAndRealEvidenceSources(t *testing.T) { "kind": "nucleus.evidence_replay", "labels": ["single_service", "repairable"], "evidence": { - "kind": "nucleus.repair_evidence", + "result_kind": "nucleus.repair_evidence", + "ok": true, "status": "repaired", "verification_pass": true, "rounds": [ @@ -62,7 +67,8 @@ func TestAIQualityReportDoesNotInferReplaySuccessWithoutEvidence(t *testing.T) { "kind": "nucleus.evidence_replay", "labels": ["single_service"], "evidence": { - "kind": "nucleus.repair_evidence", + "result_kind": "nucleus.repair_evidence", + "ok": false, "status": "needs_manual_action" } }`) @@ -89,7 +95,7 @@ func TestAIQualityReportOnlyReplaysExplicitEvidenceWrappers(t *testing.T) { "apply_pass": true, "verify_pass": true, "evidence": { - "kind": "nucleus.verify_result", + "result_kind": "nucleus.verify_result", "ok": false } }`) @@ -116,7 +122,9 @@ func TestAIQualityReportTreatsVerifyStepPassFalseAsLocatedFailure(t *testing.T) { "id": "generated_freshness", "phase": "generated_freshness", - "pass": false + "kind": "generated_freshness", + "status": "failed", + "ok": false } ] } @@ -149,7 +157,8 @@ func TestAIQualityReportSummarizesTaskTypeLabelsAndStrategies(t *testing.T) { "task_type": "generated_freshness", "labels": ["single_service", "repairable"], "evidence": { - "kind": "nucleus.repair_evidence", + "result_kind": "nucleus.repair_evidence", + "ok": false, "status": "needs_manual_action", "verification_pass": false, "failure_type": "generated_freshness", @@ -192,7 +201,8 @@ func TestAIQualityReportSummarizesCapabilityEvents(t *testing.T) { "kind": "nucleus.evidence_replay", "labels": ["single_service"], "evidence": { - "kind": "nucleus.verify_result", + "result_kind": "nucleus.verify_result", + "ok": false, "status": "failed", "verification_pass": false, "capability_events": [ @@ -228,96 +238,76 @@ func TestAIQualityReportSummarizesCapabilityEvents(t *testing.T) { } } -func TestPlatformReadinessReportSummarizesServiceMetadata(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" -service: - name: demo - version: "0.1.0" -capabilities: - - http -ai: - generated: - - contract/gen -`) - writeFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 -paths: - /healthz: - get: - operationId: getHealthz -`) - writeFile(t, dir, "api/errors.yaml", `errors: - - code: 0 - message: ok - http_status: 200 -`) - - report := mustPlatformReadinessReport(t, dir) - if report.Service != "demo" || report.Version != "0.1.0" { - t.Fatalf("unexpected service metadata: %#v", report) - } - if report.EndpointCount != 1 || report.CapabilityCount != 1 { - t.Fatalf("unexpected counts: %#v", report) - } - if report.GeneratedFresh != false { - t.Fatalf("missing generated marker should make generated_fresh false: %#v", report) - } -} - -func TestPlatformReadinessReportIncludesLocalArtifactsAndProviderStrategy(t *testing.T) { - dir := t.TempDir() - writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" -service: - name: demo - version: "0.1.0" -capabilities: - - log -ai: - generated: - - contract/gen -`) - writeFile(t, dir, "api/openapi.yaml", "openapi: 3.0.3\npaths: {}\n") - writeFile(t, dir, "api/errors.yaml", "errors: []\n") - writeFile(t, dir, "cmd/demo/main.go", `package main - -import ( - _ "github.com/nucleuskit/bridge/zap" - _ "github.com/nucleuskit/cap/log" -) - -func main() {} +func TestAIQualityReportSummarizesDecisionQualityAndRecipeCandidates(t *testing.T) { + serviceDir := t.TempDir() + tasksDir := filepath.Join(serviceDir, "artifacts", "nucleus", "ai-tasks") + writeReportServiceScaffold(t, serviceDir) + writeFile(t, serviceDir, ".nucleus/decisions/order-store.yaml", `schema_version: "decision.v1" +id: order-store-provider +capability: order_store +decision: + provider: gorm + library: gorm.io/gorm + status: proposed + locked: false +reason: + - project needs an explicit provider decision +impact: + files: + - internal/order/store.go +verification: + commands: + - go test ./internal/order `) + acceptReportDecision(t, serviceDir, ".nucleus/decisions/order-store.yaml") + writeFile(t, tasksDir, "plan-evidence.json", `{ + "id": "plan-evidence", + "source": "scenario", + "plan_pass": true, + "apply_pass": true, + "verify_pass": true, + "evidence": { + "result_kind": "nucleus.plan_result", + "context": { + "recipe_candidates": [ + { + "id": "mysql-gorm", + "decision_required": true + }, + { + "id": "mysql-sqlx", + "decision_required": true + } + ] + } + } +}`) - report := mustPlatformReadinessReport(t, dir) - if report.PlatformUploadPayload.NetworkRequired || report.PlatformUploadPayload.Artifact != "local:artifacts/nucleus/platform-upload-payload.json" { - t.Fatalf("platform payload should be local-only artifact metadata: %#v", report.PlatformUploadPayload) - } - if report.ReleaseDryRun.Artifact != "local:artifacts/nucleus/release-dry-run.json" { - t.Fatalf("unexpected release dry-run artifact: %#v", report.ReleaseDryRun) - } - if len(report.ReadinessGates) == 0 { - t.Fatalf("expected readiness gates: %#v", report) + result := buildAIQualityResult(serviceDir, tasksDir, true) + if !result.OK { + t.Fatalf("buildAIQualityResult returned diagnostics: %#v", result.Diagnostics) } - if len(report.RiskGates) == 0 { - t.Fatalf("expected risk gates: %#v", report) + report := result.AIQuality + if report == nil { + t.Fatal("AIQuality is nil") } - if len(report.ProviderStrategy) != 1 { - t.Fatalf("expected one provider strategy: %#v", report) + if report.DecisionQuality.Files != 1 || report.DecisionQuality.Valid != 1 || report.DecisionQuality.AcceptedLocked != 1 { + t.Fatalf("decision_quality = %#v, want one accepted locked decision", report.DecisionQuality) } - if report.ProviderStrategy[0].Capability != "log" || report.ProviderStrategy[0].Provider != "zap" { - t.Fatalf("unexpected provider strategy identity: %#v", report.ProviderStrategy[0]) + if report.RecipeCandidateUsage.CandidateCount != 2 || report.RecipeCandidateUsage.DecisionRequiredCount != 2 { + t.Fatalf("recipe_candidate_usage = %#v, want two decision-required candidates", report.RecipeCandidateUsage) } - if report.ProviderStrategy[0].SDKStatus != "optional_external_provider_detected" { - t.Fatalf("provider SDK should be recommendation state, not required default: %#v", report.ProviderStrategy[0]) + if got := strings.Join(report.RecipeCandidateUsage.UniqueCandidateIDs, ","); got != "mysql-gorm,mysql-sqlx" { + t.Fatalf("unique_candidate_ids = %q", got) } - if len(report.ProviderStrategy[0].Gaps) != 3 { - t.Fatalf("expected health, fallback, and observability gaps: %#v", report.ProviderStrategy[0]) + if result.Summary.DecisionFileCount != 1 || result.Summary.LockedDecisionCount != 1 || result.Summary.RecipeCandidateCount != 2 { + t.Fatalf("summary = %#v, want decision and recipe candidate counts", result.Summary) } } func mustAIQualityReport(t *testing.T, dir string) aiQualityReport { t.Helper() - result := buildAIQualityResult(dir, true) + result := buildAIQualityResult(dir, dir, true) if !result.OK { t.Fatalf("buildAIQualityResult returned diagnostics: %#v", result.Diagnostics) } @@ -327,18 +317,6 @@ func mustAIQualityReport(t *testing.T, dir string) aiQualityReport { return *result.AIQuality } -func mustPlatformReadinessReport(t *testing.T, dir string) platformReadinessReport { - t.Helper() - result := buildPlatformReadinessResult(dir) - if !result.OK { - t.Fatalf("buildPlatformReadinessResult returned diagnostics: %#v", result.Diagnostics) - } - if result.PlatformReadiness == nil { - t.Fatal("PlatformReadiness is nil") - } - return *result.PlatformReadiness -} - func writeFile(t *testing.T, dir string, name string, data string) { t.Helper() path := filepath.Join(dir, name) @@ -349,3 +327,36 @@ func writeFile(t *testing.T, dir string, name string, data string) { t.Fatal(err) } } + +func writeReportServiceScaffold(t *testing.T, dir string) { + t.Helper() + writeFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") + writeFile(t, dir, "demo.go", "package demo\n") + writeFile(t, dir, "internal/order/store.go", "package order\n\ntype Store interface{}\n") + writeFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +capabilities: + - id: order_store + kind: sql +ai: + intent: test + allowed_changes: + - internal/** + - .nucleus/** +`) +} + +func acceptReportDecision(t *testing.T, dir string, path string) { + t.Helper() + cmd := decisioncmd.NewCommand(decisioncmd.Config{Dir: &dir}) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&stderr) + cmd.SetArgs([]string{"accept", path, "--by", "human", "--accepted-at", "2026-07-03T00:00:00Z", "--json"}) + if err := cmd.Execute(); err != nil { + t.Fatalf("accept decision: %v\nstdout=%s\nstderr=%s", err, stdout.String(), stderr.String()) + } +} diff --git a/cmd/nucleus/internal/report/run.go b/cmd/nucleus/internal/report/run.go index 88f0a58..117b853 100644 --- a/cmd/nucleus/internal/report/run.go +++ b/cmd/nucleus/internal/report/run.go @@ -1,32 +1,25 @@ package report import ( - "fmt" "path/filepath" "strings" ) func run(config Config, opts *options) (reportResult, error) { - if err := validateOptions(opts); err != nil { - return reportResult{}, err - } dir := stringValue(config.Dir, defaultDir) - if opts.platform { - return buildPlatformReadinessResult(dir), nil - } tasksDir := strings.TrimSpace(opts.aiTasksPath) explicit := tasksDir != "" if !explicit { tasksDir = filepath.Join(dir, defaultAITasksDir) } - return buildAIQualityResult(tasksDir, explicit), nil + return buildAIQualityResult(dir, tasksDir, explicit), nil } -func validateOptions(opts *options) error { - if opts.platform && strings.TrimSpace(opts.aiTasksPath) != "" { - return fmt.Errorf("--%s cannot be combined with --%s", flagPlatform, flagAITasks) - } - return nil +// BuildForMCP returns the same structured report result used by the CLI. +func BuildForMCP(dir string, aiTasksPath string) any { + opts := &options{aiTasksPath: aiTasksPath} + result, _ := run(Config{Dir: &dir}, opts) + return result } func stringValue(value *string, fallback string) string { diff --git a/cmd/nucleus/internal/root/adopt_example_test.go b/cmd/nucleus/internal/root/adopt_example_test.go new file mode 100644 index 0000000..45c162b --- /dev/null +++ b/cmd/nucleus/internal/root/adopt_example_test.go @@ -0,0 +1,43 @@ +package root + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestAdoptCommandFromRootCreatesProtocolIndex(t *testing.T) { + dir := t.TempDir() + writeRootFixtureFile(t, dir, "go.mod", "module example.com/adopted\n\ngo 1.26.3\n") + + cmd := New() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--dir", dir, "adopt", "--json", "--pretty"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute adopt: %v", err) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode adopt output: %v\n%s", err, stdout.String()) + } + assertString(t, output, "result_kind", "nucleus.adopt_result") + if output["ok"] != true { + t.Fatalf("ok = %#v, want true; output=%s", output["ok"], stdout.String()) + } + for _, name := range []string{"nucleus.yaml", ".nucleus/decisions/.gitkeep", ".nucleus/README.md"} { + if _, err := os.Stat(filepath.Join(dir, filepath.FromSlash(name))); err != nil { + t.Fatalf("%s should exist: %v", name, err) + } + } + if _, err := os.Stat(filepath.Join(dir, "go.sum")); err == nil { + t.Fatal("adopt must not create go.sum") + } else if !os.IsNotExist(err) { + t.Fatalf("stat go.sum: %v", err) + } +} diff --git a/cmd/nucleus/internal/root/capability_example_test.go b/cmd/nucleus/internal/root/capability_example_test.go deleted file mode 100644 index dff4ed5..0000000 --- a/cmd/nucleus/internal/root/capability_example_test.go +++ /dev/null @@ -1,34 +0,0 @@ -package root - -import ( - "bytes" - "encoding/json" - "testing" -) - -func TestCapabilityCommandWithGlobalDir(t *testing.T) { - exampleDir := writeRootExampleService(t) - - cmd := New() - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs([]string{"--dir", exampleDir, "capability", "add", "redis", "--dry-run", "--json"}) - - if err := cmd.Execute(); err != nil { - t.Fatalf("execute capability: %v\nstderr=%s\nstdout=%s", err, stderr.String(), stdout.String()) - } - if stderr.Len() != 0 { - t.Fatalf("stderr = %q, want empty", stderr.String()) - } - - var output map[string]any - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("decode capability output: %v\n%s", err, stdout.String()) - } - assertString(t, output, "result_kind", "nucleus.capability_result") - assertBool(t, output, "ok", true) - assertString(t, output, "capability", "redis") - assertString(t, output, "provider", "redis") -} diff --git a/cmd/nucleus/internal/root/decision_example_test.go b/cmd/nucleus/internal/root/decision_example_test.go new file mode 100644 index 0000000..0f6e306 --- /dev/null +++ b/cmd/nucleus/internal/root/decision_example_test.go @@ -0,0 +1,57 @@ +package root + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestDecisionValidateFromRoot(t *testing.T) { + dir := t.TempDir() + writeRootFixtureFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +capabilities: + - id: order_store + kind: relational_store +ai: + intent: test + allowed_changes: + - internal/** +`) + writeRootFixtureFile(t, dir, ".nucleus/decisions/order-store.yaml", `schema_version: "decision.v1" +id: order-store-provider +capability: order_store +decision: + provider: gorm + library: gorm.io/gorm + status: proposed + locked: false +reason: + - project already uses gorm +impact: + files: + - internal/order/store.go +verification: + commands: + - go test ./internal/order +`) + + cmd := New() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--dir", dir, "decision", "validate", ".nucleus/decisions/order-store.yaml", "--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute decision validate: %v\n%s", err, stdout.String()) + } + + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode decision output: %v\n%s", err, stdout.String()) + } + assertString(t, output, "result_kind", "nucleus.decision_validate_result") + assertBool(t, output, "ok", true) +} diff --git a/cmd/nucleus/internal/root/describe_example_test.go b/cmd/nucleus/internal/root/describe_example_test.go index b017c7b..c8183c1 100644 --- a/cmd/nucleus/internal/root/describe_example_test.go +++ b/cmd/nucleus/internal/root/describe_example_test.go @@ -16,7 +16,7 @@ func TestDescribeCommandWithHelloHTTPExample(t *testing.T) { var stdout bytes.Buffer cmd.SetOut(&stdout) cmd.SetErr(&bytes.Buffer{}) - cmd.SetArgs([]string{"--dir", exampleDir, "--schema", "9.9", "describe", "--json", "--flow"}) + cmd.SetArgs([]string{"--dir", exampleDir, "describe", "--json", "--flow"}) if err := cmd.Execute(); err != nil { t.Fatalf("execute describe: %v", err) @@ -27,7 +27,9 @@ func TestDescribeCommandWithHelloHTTPExample(t *testing.T) { t.Fatalf("decode describe output: %v\n%s", err, stdout.String()) } - assertString(t, output, "schema_version", "9.9") + assertString(t, output, "result_kind", "nucleus.describe_result") + assertString(t, output, "schema_version", "describe-result.v1") + assertString(t, output, "schema_ref", "contract/schema/describe-result.v1.schema.json") service := assertMap(t, output, "service") assertString(t, service, "name", "hello-http") diff --git a/cmd/nucleus/internal/root/gen_example_test.go b/cmd/nucleus/internal/root/gen_example_test.go index 2908aed..fe5977d 100644 --- a/cmd/nucleus/internal/root/gen_example_test.go +++ b/cmd/nucleus/internal/root/gen_example_test.go @@ -39,7 +39,7 @@ func TestGenCommandJSONRootWiring(t *testing.T) { func writeRootGenService(t *testing.T, dir string) { t.Helper() - writeRootGenFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeRootGenFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -49,7 +49,8 @@ ai: - contract/gen - internal/adapter/http/gen capabilities: - - http + - id: http + kind: http `) writeRootGenFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 paths: diff --git a/cmd/nucleus/internal/root/lint_example_test.go b/cmd/nucleus/internal/root/lint_example_test.go index 494d13a..3414eb3 100644 --- a/cmd/nucleus/internal/root/lint_example_test.go +++ b/cmd/nucleus/internal/root/lint_example_test.go @@ -10,7 +10,7 @@ import ( func TestLintCommandWithValidManifest(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "nucleus.yaml"), []byte(`schema_version: "1.0" + if err := os.WriteFile(filepath.Join(dir, "nucleus.yaml"), []byte(`schema_version: "2.0" service: name: demo version: "0.1.0" diff --git a/cmd/nucleus/internal/root/mark_example_test.go b/cmd/nucleus/internal/root/mark_example_test.go new file mode 100644 index 0000000..1eb778e --- /dev/null +++ b/cmd/nucleus/internal/root/mark_example_test.go @@ -0,0 +1,65 @@ +package root + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestMarkFromRoot(t *testing.T) { + dir := t.TempDir() + writeRootFixtureFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: mark-root + version: "0.1.0" +ai: + intent: root mark test +`) + writeRootFixtureFile(t, dir, "go.mod", "module example.com/rootmark\n\ngo 1.26.3\n") + writeRootFixtureFile(t, dir, "store/store.go", "package store\ntype OrderStore interface{}\n") + + output := executeRootJSON(t, dir, "mark", "capability", "order_store", "--kind", "relational_store", "--symbol", "OrderStore", "--json") + assertString(t, output, "result_kind", "nucleus.mark_result") + assertBool(t, output, "ok", true) + assertBool(t, output, "changed", true) + symbols := assertSlice(t, output, "symbols") + if len(symbols) != 1 { + t.Fatalf("symbols = %#v", symbols) + } + + verifyOutput := executeRootJSON(t, dir, "mark", "verify", "go test ./...", "--json") + assertString(t, verifyOutput, "result_kind", "nucleus.mark_result") + assertBool(t, verifyOutput, "ok", true) +} + +func TestMarkAmbiguousSymbolFromRootRendersJSON(t *testing.T) { + dir := t.TempDir() + writeRootFixtureFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: mark-root + version: "0.1.0" +ai: + intent: root mark test +`) + writeRootFixtureFile(t, dir, "go.mod", "module example.com/rootmark\n\ngo 1.26.3\n") + writeRootFixtureFile(t, dir, "a/a.go", "package a\ntype Store interface{}\n") + writeRootFixtureFile(t, dir, "b/b.go", "package b\ntype Store interface{}\n") + + cmd := New() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--dir", dir, "mark", "capability", "store", "--kind", "relational_store", "--symbol", "Store", "--json"}) + if err := cmd.Execute(); err == nil { + t.Fatal("execute error = nil, want failure") + } + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + assertString(t, output, "result_kind", "nucleus.mark_result") + assertBool(t, output, "ok", false) + if got := assertSlice(t, output, "candidates"); len(got) != 2 { + t.Fatalf("candidates = %#v", got) + } +} diff --git a/cmd/nucleus/internal/root/mcp_example_test.go b/cmd/nucleus/internal/root/mcp_example_test.go new file mode 100644 index 0000000..8b750cb --- /dev/null +++ b/cmd/nucleus/internal/root/mcp_example_test.go @@ -0,0 +1,22 @@ +package root + +import ( + "bytes" + "strings" + "testing" +) + +func TestMCPCommandIsWiredFromRoot(t *testing.T) { + cmd := New() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"mcp", "--help"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute mcp help: %v", err) + } + if !strings.Contains(stdout.String(), "--stdio") { + t.Fatalf("mcp help missing --stdio:\n%s", stdout.String()) + } +} diff --git a/cmd/nucleus/internal/root/migrate_example_test.go b/cmd/nucleus/internal/root/migrate_example_test.go deleted file mode 100644 index 2f02154..0000000 --- a/cmd/nucleus/internal/root/migrate_example_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package root - -import ( - "bytes" - "encoding/json" - "os" - "path/filepath" - "testing" -) - -func TestMigrateCommandJSONRootWiring(t *testing.T) { - dir := t.TempDir() - writeRootMigrateFile(t, dir, "nucleus.yaml", `schema_version: "1.0" -service: - name: demo - version: "0.1.0" -ai: - intent: Exercise root migrate wiring. - generated: [] -`) - writeRootMigrateFile(t, dir, "api/openapi.yaml", "openapi: 3.0.3\npaths: {}\n") - writeRootMigrateFile(t, dir, "api/errors.yaml", "errors: []\n") - - cmd := New() - var stdout bytes.Buffer - var stderr bytes.Buffer - cmd.SetOut(&stdout) - cmd.SetErr(&stderr) - cmd.SetArgs([]string{"--dir", dir, "migrate", "--from-version", "v0.1.0", "--to-version", "v0.2.0", "--json"}) - - if err := cmd.Execute(); err != nil { - t.Fatalf("execute migrate: %v\nstderr=%s", err, stderr.String()) - } - - var output map[string]any - if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { - t.Fatalf("decode migrate output: %v\n%s", err, stdout.String()) - } - assertString(t, output, "result_kind", "nucleus.migrate_result") - assertString(t, output, "schema_ref", "contract/schema/migrate.schema.json") - assertString(t, output, "mode", "plan") - assertBool(t, output, "ok", true) - summary := assertMap(t, output, "summary") - assertString(t, summary, "service", "demo") -} - -func writeRootMigrateFile(t *testing.T, dir string, name string, data string) { - t.Helper() - path := filepath.Join(dir, name) - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, []byte(data), 0o600); err != nil { - t.Fatal(err) - } -} diff --git a/cmd/nucleus/internal/root/report_example_test.go b/cmd/nucleus/internal/root/report_example_test.go index 2d6cd68..34f0f60 100644 --- a/cmd/nucleus/internal/root/report_example_test.go +++ b/cmd/nucleus/internal/root/report_example_test.go @@ -35,7 +35,7 @@ func TestReportCommandJSONRootWiring(t *testing.T) { t.Fatalf("decode report output: %v\n%s", err, stdout.String()) } assertString(t, output, "result_kind", "nucleus.report_result") - assertString(t, output, "schema_ref", "contract/schema/report.schema.json") + assertString(t, output, "schema_ref", "contract/schema/report.v1.schema.json") assertString(t, output, "mode", "ai_quality") assertBool(t, output, "ok", true) summary := assertMap(t, output, "summary") diff --git a/cmd/nucleus/internal/root/root.go b/cmd/nucleus/internal/root/root.go index 6beeb56..d65869c 100644 --- a/cmd/nucleus/internal/root/root.go +++ b/cmd/nucleus/internal/root/root.go @@ -1,19 +1,22 @@ package root import ( + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/adopt" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/apply" - "github.com/nucleuskit/nucleus/cmd/nucleus/internal/capability" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/decision" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/describe" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/execute" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/gen" - "github.com/nucleuskit/nucleus/cmd/nucleus/internal/initcmd" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/impact" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/lint" - "github.com/nucleuskit/nucleus/cmd/nucleus/internal/migrate" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/mark" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/mcp" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/plan" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/repair" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/report" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/scenario" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/serve" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/trace" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/validate" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/verify" "github.com/spf13/cobra" @@ -23,7 +26,6 @@ import ( type options struct { dir string // service root directory verbose bool // verbose output - schema string // schema version override } // New creates the root nucleus command and wires top-level subcommands. @@ -31,16 +33,20 @@ func New() *cobra.Command { opts := &options{dir: "."} cmd := &cobra.Command{ Use: "nucleus", - Short: "Nucleus service kernel CLI", + Short: "Nucleus agent-native protocol CLI", SilenceUsage: true, SilenceErrors: true, } cmd.PersistentFlags().StringVar(&opts.dir, "dir", ".", "service root directory") cmd.PersistentFlags().BoolVar(&opts.verbose, "verbose", false, "enable verbose output") - cmd.PersistentFlags().StringVar(&opts.schema, "schema", "", "override describe schema version") + cmd.AddCommand(adopt.NewCommand(adopt.Config{ + Dir: &opts.dir, + })) + cmd.AddCommand(decision.NewCommand(decision.Config{ + Dir: &opts.dir, + })) cmd.AddCommand(describe.NewCommand(describe.Config{ - Dir: &opts.dir, - SchemaOverride: &opts.schema, + Dir: &opts.dir, })) cmd.AddCommand(validate.NewCommand(validate.Config{ Dir: &opts.dir, @@ -57,28 +63,31 @@ func New() *cobra.Command { cmd.AddCommand(plan.NewCommand(plan.Config{ Dir: &opts.dir, })) - cmd.AddCommand(apply.NewCommand(apply.Config{ + cmd.AddCommand(trace.NewCommand(trace.Config{ Dir: &opts.dir, })) - cmd.AddCommand(execute.NewCommand(execute.Config{ + cmd.AddCommand(impact.NewCommand(impact.Config{ Dir: &opts.dir, })) - cmd.AddCommand(repair.NewCommand(repair.Config{ + cmd.AddCommand(mark.NewCommand(mark.Config{ Dir: &opts.dir, })) - cmd.AddCommand(report.NewCommand(report.Config{ + cmd.AddCommand(mcp.NewCommand(mcp.Config{ Dir: &opts.dir, })) - cmd.AddCommand(scenario.NewCommand(scenario.Config{ + cmd.AddCommand(apply.NewCommand(apply.Config{ + Dir: &opts.dir, + })) + cmd.AddCommand(execute.NewCommand(execute.Config{ Dir: &opts.dir, })) - cmd.AddCommand(initcmd.NewCommand(initcmd.Config{ + cmd.AddCommand(repair.NewCommand(repair.Config{ Dir: &opts.dir, })) - cmd.AddCommand(capability.NewCommand(capability.Config{ + cmd.AddCommand(report.NewCommand(report.Config{ Dir: &opts.dir, })) - cmd.AddCommand(migrate.NewCommand(migrate.Config{ + cmd.AddCommand(scenario.NewCommand(scenario.Config{ Dir: &opts.dir, })) cmd.AddCommand(serve.NewCommand(serve.Config{ diff --git a/cmd/nucleus/internal/root/serve_example_test.go b/cmd/nucleus/internal/root/serve_example_test.go index 7a771eb..b3441fa 100644 --- a/cmd/nucleus/internal/root/serve_example_test.go +++ b/cmd/nucleus/internal/root/serve_example_test.go @@ -10,12 +10,13 @@ import ( func TestServeCommandJSONRootWiring(t *testing.T) { dir := t.TempDir() - writeRootServeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeRootServeFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" capabilities: - - http + - id: http + kind: http `) writeRootServeFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 paths: @@ -44,7 +45,7 @@ paths: t.Fatalf("decode serve output: %v\n%s", err, stdout.String()) } assertString(t, output, "result_kind", "nucleus.serve_result") - assertString(t, output, "schema_ref", "contract/schema/serve.schema.json") + assertString(t, output, "schema_ref", "contract/schema/serve-result.v1.schema.json") assertString(t, output, "mode", "check") assertBool(t, output, "ok", true) summary := assertMap(t, output, "summary") diff --git a/cmd/nucleus/internal/root/service_fixture_test.go b/cmd/nucleus/internal/root/service_fixture_test.go index 508c751..2a4eb5e 100644 --- a/cmd/nucleus/internal/root/service_fixture_test.go +++ b/cmd/nucleus/internal/root/service_fixture_test.go @@ -9,18 +9,18 @@ import ( func writeRootExampleService(t *testing.T) string { t.Helper() dir := t.TempDir() - writeRootFixtureFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeRootFixtureFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: hello-http version: "0.1.0" - env: test owner: nucleus-maintainers tier: example - namespace: examples description: Contract-first HTTP service used by root command tests. capabilities: - - http - - log + - id: http + kind: http + - id: log + kind: log dependencies: - name: greeting-profile contract: api/openapi.yaml#/paths/~1hello~1{name} @@ -38,10 +38,6 @@ ai: - internal/adapter/http/gen forbidden: - configs/*.local.yaml -nucleus: - providers: - log: - provider: noop `) writeRootFixtureFile(t, dir, "api/openapi.yaml", `openapi: 3.1.0 info: diff --git a/cmd/nucleus/internal/root/trace_impact_example_test.go b/cmd/nucleus/internal/root/trace_impact_example_test.go new file mode 100644 index 0000000..ad1a8a5 --- /dev/null +++ b/cmd/nucleus/internal/root/trace_impact_example_test.go @@ -0,0 +1,55 @@ +package root + +import ( + "bytes" + "encoding/json" + "testing" +) + +func TestTraceAndImpactFromRoot(t *testing.T) { + dir := t.TempDir() + writeRootFixtureFile(t, dir, "go.mod", "module example.com/orders\n\ngo 1.26.3\n") + writeRootFixtureFile(t, dir, "order/service.go", `package order + +func CreateOrder() { validateOrder() } +func validateOrder() {} +func Caller() { CreateOrder() } +`) + writeRootFixtureFile(t, dir, "order/service_test.go", `package order + +func TestCreateOrder(t *testing.T) {} +`) + + traceOutput := executeRootJSON(t, dir, "trace", "symbol", "CreateOrder", "--json") + assertString(t, traceOutput, "result_kind", "nucleus.trace_result") + assertBool(t, traceOutput, "ok", true) + callers := assertSlice(t, traceOutput, "callers") + if len(callers) != 1 { + t.Fatalf("trace callers = %#v", callers) + } + + impactOutput := executeRootJSON(t, dir, "impact", "symbol", "CreateOrder", "--json") + assertString(t, impactOutput, "result_kind", "nucleus.impact_result") + assertBool(t, impactOutput, "ok", true) + tests := assertSlice(t, impactOutput, "affected_tests") + if len(tests) != 1 { + t.Fatalf("impact tests = %#v", tests) + } +} + +func executeRootJSON(t *testing.T, dir string, args ...string) map[string]any { + t.Helper() + cmd := New() + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs(append([]string{"--dir", dir}, args...)) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute %v: %v\n%s", args, err, stdout.String()) + } + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + return output +} diff --git a/cmd/nucleus/internal/root/validate_example_test.go b/cmd/nucleus/internal/root/validate_example_test.go index 74a1041..1fcdc79 100644 --- a/cmd/nucleus/internal/root/validate_example_test.go +++ b/cmd/nucleus/internal/root/validate_example_test.go @@ -35,7 +35,7 @@ func TestValidateCommandWithHelloHTTPExample(t *testing.T) { func TestValidateCommandReportsDiagnostics(t *testing.T) { dir := t.TempDir() - if err := os.WriteFile(filepath.Join(dir, "nucleus.yaml"), []byte(`schema_version: "1.0" + if err := os.WriteFile(filepath.Join(dir, "nucleus.yaml"), []byte(`schema_version: "2.0" service: version: "0.1.0" ai: diff --git a/cmd/nucleus/internal/root/verify_example_test.go b/cmd/nucleus/internal/root/verify_example_test.go index ad1ae34..6983e26 100644 --- a/cmd/nucleus/internal/root/verify_example_test.go +++ b/cmd/nucleus/internal/root/verify_example_test.go @@ -12,7 +12,7 @@ func TestVerifyCommandJSONRootWiring(t *testing.T) { dir := t.TempDir() writeRootVerifyFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") writeRootVerifyFile(t, dir, "demo.go", "package demo\n") - writeRootVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeRootVerifyFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" diff --git a/cmd/nucleus/internal/scenario/cases.go b/cmd/nucleus/internal/scenario/cases.go index bfcfc1d..e3c1c4c 100644 --- a/cmd/nucleus/internal/scenario/cases.go +++ b/cmd/nucleus/internal/scenario/cases.go @@ -87,7 +87,7 @@ func RunHTTPCases(options HTTPRunnerOptions, cases []HTTPCase) (map[string]any, assertions := make([]map[string]any, 0, len(cases)) for _, testCase := range cases { step, sample, results := runHTTPCase(runner, testCase, maxBodyBytes) - if stepPass, _ := step["pass"].(bool); !stepPass { + if stepOK, _ := step["ok"].(bool); !stepOK { pass = false } for _, result := range results { @@ -104,12 +104,13 @@ func RunHTTPCases(options HTTPRunnerOptions, cases []HTTPCase) (map[string]any, status = "failed" } return map[string]any{ + "result_kind": httpEvidenceKind, "schema_version": "evidence.v1", "schema_ref": evidenceSchemaRef, - "kind": httpEvidenceKind, - "pass": pass, + "ok": pass, "status": status, "steps": steps, + "diagnostics": []map[string]any{}, "http_samples": samples, "assertion_results": assertionResults(assertions), "redaction_applied": true, @@ -129,7 +130,7 @@ func runHTTPCase(runner httpRunner, testCase HTTPCase, maxBodyBytes int64) (map[ "method": testCase.Method, "path": testCase.Path, "status": "failed", - "pass": false, + "ok": false, "reason": err.Error(), } return step, map[string]any{"id": testCase.ID, "response": map[string]any{"error": err.Error()}}, []map[string]any{failedHTTPAssertion(testCase.ID, err.Error())} @@ -143,7 +144,7 @@ func runHTTPCase(runner httpRunner, testCase HTTPCase, maxBodyBytes int64) (map[ "method": testCase.Method, "path": testCase.Path, "status": "failed", - "pass": false, + "ok": false, "reason": err.Error(), } return step, map[string]any{ @@ -171,7 +172,7 @@ func runHTTPCase(runner httpRunner, testCase HTTPCase, maxBodyBytes int64) (map[ "method": testCase.Method, "path": testCase.Path, "status": status, - "pass": casePass, + "ok": casePass, "http_status": response.StatusCode, } addEnvelopeFields(step, envelope, hasEnvelope) diff --git a/cmd/nucleus/internal/scenario/cases_test.go b/cmd/nucleus/internal/scenario/cases_test.go index faaf326..29704b8 100644 --- a/cmd/nucleus/internal/scenario/cases_test.go +++ b/cmd/nucleus/internal/scenario/cases_test.go @@ -44,7 +44,7 @@ func TestLoadAndRunHTTPCasesWithAssertionDSL(t *testing.T) { t.Fatal(err) } - if evidence["pass"] != true { + if evidence["result_kind"] != httpEvidenceKind || evidence["schema_ref"] != evidenceSchemaRef || evidence["ok"] != true { t.Fatalf("expected case evidence to pass: %#v", evidence) } results := evidence["assertion_results"].([]map[string]any) @@ -110,7 +110,7 @@ func TestRunHTTPCasesWithBaseURLPreservesQueryString(t *testing.T) { if err != nil { t.Fatal(err) } - if evidence["pass"] != true { + if evidence["ok"] != true { t.Fatalf("unexpected evidence: %#v", evidence) } } diff --git a/cmd/nucleus/internal/scenario/command_test.go b/cmd/nucleus/internal/scenario/command_test.go index 9c93ff4..7a8927f 100644 --- a/cmd/nucleus/internal/scenario/command_test.go +++ b/cmd/nucleus/internal/scenario/command_test.go @@ -39,6 +39,9 @@ func TestCommandJSONPlanOutput(t *testing.T) { if output["result_kind"] != resultKindScenarioPlan || output["ok"] != true { t.Fatalf("unexpected result metadata: %#v", output) } + if output["schema_ref"] != scenarioSchemaRef { + t.Fatalf("schema_ref = %#v, want %s", output["schema_ref"], scenarioSchemaRef) + } if len(output["scenarios"].([]any)) == 0 { t.Fatalf("expected scenarios in output: %#v", output) } @@ -62,10 +65,12 @@ func TestCommandJSONDraftCasesOutput(t *testing.T) { t.Fatalf("stderr = %q, want empty", stderr.String()) } var output struct { - ResultKind string `json:"result_kind"` - OK bool `json:"ok"` - Kind string `json:"kind"` - Cases []HTTPCase `json:"cases"` + ResultKind string `json:"result_kind"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Kind string `json:"kind"` + Diagnostics []any `json:"diagnostics"` + Cases []HTTPCase `json:"cases"` } if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) @@ -73,6 +78,12 @@ func TestCommandJSONDraftCasesOutput(t *testing.T) { if output.ResultKind != resultKindHTTPCaseDrafts || !output.OK || output.Kind != httpCaseDraftsKind { t.Fatalf("unexpected draft output: %#v", output) } + if output.SchemaRef != scenarioSchemaRef { + t.Fatalf("schema_ref = %q, want %q", output.SchemaRef, scenarioSchemaRef) + } + if output.Diagnostics == nil { + t.Fatal("diagnostics = nil, want empty array") + } if len(output.Cases) == 0 { t.Fatalf("expected cases in output: %#v", output) } @@ -147,7 +158,7 @@ func TestCommandRunHTTPFailureReturnsSentinel(t *testing.T) { if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) } - if output["kind"] != httpEvidenceKind || output["pass"] != false || output["status"] != "failed" { + if output["result_kind"] != httpEvidenceKind || output["ok"] != false || output["status"] != "failed" { t.Fatalf("unexpected evidence: %#v", output) } } diff --git a/cmd/nucleus/internal/scenario/constants.go b/cmd/nucleus/internal/scenario/constants.go index 8bef64f..c710372 100644 --- a/cmd/nucleus/internal/scenario/constants.go +++ b/cmd/nucleus/internal/scenario/constants.go @@ -34,5 +34,6 @@ const ( resultKindHTTPCaseDrafts = "nucleus.http_case_drafts_result" planKind = "nucleus.scenario_plan" httpCaseDraftsKind = "nucleus.http_case_drafts" - scenarioSchemaVersion = "scenario.v1" + scenarioSchemaVersion = "scenario-result.v1" + scenarioSchemaRef = "contract/schema/scenario-result.v1.schema.json" ) diff --git a/cmd/nucleus/internal/scenario/http_runner.go b/cmd/nucleus/internal/scenario/http_runner.go index 1dde9fc..d387fc7 100644 --- a/cmd/nucleus/internal/scenario/http_runner.go +++ b/cmd/nucleus/internal/scenario/http_runner.go @@ -16,7 +16,7 @@ import ( const ( httpEvidenceKind = "nucleus.http_scenario_evidence" - evidenceSchemaRef = "contract/schema/evidence.schema.json" + evidenceSchemaRef = "contract/schema/evidence.v1.schema.json" defaultHTTPBodyMaxBytes = 8192 defaultHTTPTimeout = 30 * time.Second ) @@ -64,7 +64,7 @@ func RunHTTPScenarios(dir string, options HTTPRunnerOptions) (map[string]any, er shape := shapes[openapi.RequestShapeKey(route.Method, route.Path, route.OperationID)] scenario := successScenarioForRoute(plan, route, shape) step, sample, assertion := runHTTPScenario(runner, route, shape, scenario, index, options.RequestHook, maxBodyBytes) - if stepPass, _ := step["pass"].(bool); !stepPass { + if stepOK, _ := step["ok"].(bool); !stepOK { pass = false } steps = append(steps, step) @@ -77,13 +77,14 @@ func RunHTTPScenarios(dir string, options HTTPRunnerOptions) (map[string]any, er status = "failed" } return map[string]any{ + "result_kind": httpEvidenceKind, "schema_version": "evidence.v1", "schema_ref": evidenceSchemaRef, - "kind": httpEvidenceKind, - "pass": pass, + "ok": pass, "status": status, "scenario_plan": plan, "steps": steps, + "diagnostics": []map[string]any{}, "http_samples": samples, "assertion_results": assertionResults(assertions), "redaction_applied": true, @@ -163,7 +164,7 @@ func runHTTPScenario(runner httpRunner, route openapi.Route, shape openapi.Reque "path": route.Path, "operation_id": route.OperationID, "status": status, - "pass": pass, + "ok": pass, "http_status": response.StatusCode, "duration_ms": durationMilliseconds(duration), } @@ -468,7 +469,7 @@ func failedHTTPBuildStep(id string, route openapi.Route, err error) map[string]a "path": route.Path, "operation_id": route.OperationID, "status": "failed", - "pass": false, + "ok": false, "reason": err.Error(), } } diff --git a/cmd/nucleus/internal/scenario/output.go b/cmd/nucleus/internal/scenario/output.go index 51874d8..be0f52d 100644 --- a/cmd/nucleus/internal/scenario/output.go +++ b/cmd/nucleus/internal/scenario/output.go @@ -59,7 +59,7 @@ func isEvidence(result any) bool { if !ok { return false } - kind, _ := evidence["kind"].(string) + kind, _ := evidence["result_kind"].(string) return kind == httpEvidenceKind } @@ -68,8 +68,8 @@ func evidencePass(result any) bool { if !ok { return true } - pass, ok := evidence["pass"].(bool) - return !ok || pass + okField, ok := evidence["ok"].(bool) + return !ok || okField } func valueLen(value any) int { diff --git a/cmd/nucleus/internal/scenario/scenario.go b/cmd/nucleus/internal/scenario/scenario.go index b99a42e..0312cb6 100644 --- a/cmd/nucleus/internal/scenario/scenario.go +++ b/cmd/nucleus/internal/scenario/scenario.go @@ -71,7 +71,9 @@ func BuildScenarioPlan(dir string) (map[string]any, error) { "ok": true, "kind": planKind, "schema_version": scenarioSchemaVersion, + "schema_ref": scenarioSchemaRef, "summary": scenarioSummary(scenarios), + "diagnostics": []map[string]any{}, "source": map[string]any{ "openapi": "api/openapi.yaml", "errors": "api/errors.yaml", @@ -115,10 +117,12 @@ func buildHTTPCaseDraftOutput(dir string) (map[string]any, error) { "ok": true, "kind": httpCaseDraftsKind, "schema_version": scenarioSchemaVersion, + "schema_ref": scenarioSchemaRef, "summary": map[string]any{ "cases": len(cases), }, - "cases": cases, + "diagnostics": []map[string]any{}, + "cases": cases, }, nil } diff --git a/cmd/nucleus/internal/scenario/scenario_test.go b/cmd/nucleus/internal/scenario/scenario_test.go index 7c7254f..1ac3763 100644 --- a/cmd/nucleus/internal/scenario/scenario_test.go +++ b/cmd/nucleus/internal/scenario/scenario_test.go @@ -52,6 +52,12 @@ paths: if plan["schema_version"] != scenarioSchemaVersion { t.Fatalf("unexpected schema version: %#v", plan["schema_version"]) } + if plan["schema_ref"] != scenarioSchemaRef { + t.Fatalf("unexpected schema ref: %#v", plan["schema_ref"]) + } + if _, ok := plan["diagnostics"].([]map[string]any); !ok { + t.Fatalf("unexpected diagnostics shape: %#v", plan["diagnostics"]) + } summary, ok := plan["summary"].(map[string]any) if !ok || summary["scenarios"] != 3 { t.Fatalf("unexpected summary: %#v", plan["summary"]) @@ -174,7 +180,7 @@ paths: if err != nil { t.Fatal(err) } - if evidence["kind"] != httpEvidenceKind || evidence["pass"] != true { + if evidence["result_kind"] != httpEvidenceKind || evidence["ok"] != true { t.Fatalf("unexpected evidence: %#v", evidence) } if evidence["schema_ref"] != evidenceSchemaRef { @@ -279,7 +285,7 @@ paths: if err != nil { t.Fatal(err) } - if evidence["pass"] != true { + if evidence["ok"] != true { t.Fatalf("unexpected evidence: %#v", evidence) } } @@ -352,11 +358,11 @@ paths: if err != nil { t.Fatal(err) } - if evidence["kind"] != httpEvidenceKind || evidence["pass"] != true || evidence["status"] != "passed" { + if evidence["result_kind"] != httpEvidenceKind || evidence["ok"] != true || evidence["status"] != "passed" { t.Fatalf("unexpected evidence: %#v", evidence) } steps := evidence["steps"].([]map[string]any) - if len(steps) != 1 || steps[0]["pass"] != true { + if len(steps) != 1 || steps[0]["ok"] != true { t.Fatalf("unexpected steps: %#v", steps) } if steps[0]["envelope_code"] != 0 || steps[0]["envelope_message"] != "ok" { @@ -403,7 +409,7 @@ paths: if err != nil { t.Fatal(err) } - if evidence["pass"] != false || evidence["status"] != "failed" { + if evidence["ok"] != false || evidence["status"] != "failed" { t.Fatalf("unexpected evidence: %#v", evidence) } steps := evidence["steps"].([]map[string]any) diff --git a/cmd/nucleus/internal/serve/command_test.go b/cmd/nucleus/internal/serve/command_test.go index 51d94b4..451cbf8 100644 --- a/cmd/nucleus/internal/serve/command_test.go +++ b/cmd/nucleus/internal/serve/command_test.go @@ -299,7 +299,7 @@ func waitForJSONOutput(t *testing.T, buffer *lockedBuffer) map[string]any { return nil } -func TestCheckCommandRendersJSONDiagnosticsOnInspectFailure(t *testing.T) { +func TestCheckCommandRendersJSONForProjectWithoutManifest(t *testing.T) { dir := t.TempDir() cmd := NewCommand(Config{Dir: &dir}) var stdout bytes.Buffer @@ -307,18 +307,25 @@ func TestCheckCommandRendersJSONDiagnosticsOnInspectFailure(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) cmd.SetArgs([]string{"--check", "--json"}) - if err := cmd.Execute(); err == nil { - t.Fatalf("expected serve --check --json to fail for missing manifest") + if err := cmd.Execute(); err != nil { + t.Fatalf("execute serve --check --json: %v", err) } var output map[string]any if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) } - if output["ok"] != false { - t.Fatalf("ok = %v, want false", output["ok"]) + if output["ok"] != true { + t.Fatalf("ok = %v, want true", output["ok"]) + } + summary := requireMap(t, output, "summary") + if summary["service"] == "" { + t.Fatalf("summary.service should be inferred: %#v", summary) + } + diagnostics := requireSlice(t, output, "diagnostics") + if len(diagnostics) != 0 { + t.Fatalf("diagnostics = %#v, want empty", diagnostics) } - assertDiagnosticCode(t, output, diagnosticInspectFailed) } func assertDiagnosticCode(t *testing.T, output map[string]any, want string) { diff --git a/cmd/nucleus/internal/serve/constants.go b/cmd/nucleus/internal/serve/constants.go index ad1d70c..bfe9a3e 100644 --- a/cmd/nucleus/internal/serve/constants.go +++ b/cmd/nucleus/internal/serve/constants.go @@ -25,8 +25,8 @@ const ( const ( resultKindServe = "nucleus.serve_result" - schemaVersionServe = "serve.v1" - schemaRefServe = "contract/schema/serve.schema.json" + schemaVersionServe = "serve-result.v1" + schemaRefServe = "contract/schema/serve-result.v1.schema.json" jsonIndentPrefix = "" jsonIndentValue = " " ) diff --git a/cmd/nucleus/internal/serve/server_test.go b/cmd/nucleus/internal/serve/server_test.go index df4b1d8..3019169 100644 --- a/cmd/nucleus/internal/serve/server_test.go +++ b/cmd/nucleus/internal/serve/server_test.go @@ -73,12 +73,13 @@ func TestHandlerRejectsNonGETMetadataRequests(t *testing.T) { func writeServiceFixture(t *testing.T) string { t.Helper() dir := t.TempDir() - writeFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" capabilities: - - http + - id: http + kind: http `) writeFile(t, dir, "api/openapi.yaml", `openapi: 3.1.0 info: diff --git a/cmd/nucleus/internal/trace/command.go b/cmd/nucleus/internal/trace/command.go new file mode 100644 index 0000000..be265ae --- /dev/null +++ b/cmd/nucleus/internal/trace/command.go @@ -0,0 +1,124 @@ +package trace + +import ( + "errors" + + "github.com/spf13/cobra" +) + +// Config carries root-level flag values used by the trace command. +type Config struct { + Dir *string +} + +type options struct { + json bool + pretty bool +} + +var ErrTraceFailed = errors.New("trace failed") + +// NewCommand creates the trace command group. +func NewCommand(config Config) *cobra.Command { + cmd := &cobra.Command{ + Use: commandUseTrace, + Short: commandShortTrace, + SilenceUsage: true, + SilenceErrors: true, + } + cmd.AddCommand(newSymbolCommand(config)) + cmd.AddCommand(newRouteCommand(config)) + cmd.AddCommand(newCapabilityCommand(config)) + return cmd +} + +func newSymbolCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseSymbol, + Short: commandShortSymbol, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := traceSymbol(config, args[0]) + if opts.json { + if err := renderJSON(cmd.OutOrStdout(), output, opts.pretty); err != nil { + return err + } + } else { + renderHuman(cmd.OutOrStdout(), output) + } + if !output.OK { + return ErrTraceFailed + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + return cmd +} + +func newRouteCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseRoute, + Short: commandShortRoute, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := traceRoute(config, args[0]) + if opts.json { + if err := renderJSON(cmd.OutOrStdout(), output, opts.pretty); err != nil { + return err + } + } else { + renderHuman(cmd.OutOrStdout(), output) + } + if !output.OK { + return ErrTraceFailed + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + return cmd +} + +func newCapabilityCommand(config Config) *cobra.Command { + opts := &options{} + cmd := &cobra.Command{ + Use: commandUseCapability, + Short: commandShortCapability, + SilenceUsage: true, + SilenceErrors: true, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + output := traceCapability(config, args[0]) + if opts.json { + if err := renderJSON(cmd.OutOrStdout(), output, opts.pretty); err != nil { + return err + } + } else { + renderHuman(cmd.OutOrStdout(), output) + } + if !output.OK { + return ErrTraceFailed + } + return nil + }, + } + cmd.Flags().BoolVar(&opts.json, flagJSON, false, flagHelpJSON) + cmd.Flags().BoolVar(&opts.pretty, flagPretty, false, flagHelpPretty) + return cmd +} + +func stringValue(value *string, fallback string) string { + if value == nil { + return fallback + } + return *value +} diff --git a/cmd/nucleus/internal/trace/command_test.go b/cmd/nucleus/internal/trace/command_test.go new file mode 100644 index 0000000..f7a8eab --- /dev/null +++ b/cmd/nucleus/internal/trace/command_test.go @@ -0,0 +1,190 @@ +package trace + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" +) + +func TestTraceSymbolReturnsCallersAndCallees(t *testing.T) { + dir := t.TempDir() + writeTraceFile(t, dir, "go.mod", "module example.com/orders\n\ngo 1.26.3\n") + writeTraceFile(t, dir, "order/service.go", `package order + +func CreateOrder() { validateOrder() } +func validateOrder() {} +func Caller() { CreateOrder() } +`) + + output := traceSymbol(Config{Dir: &dir}, "CreateOrder") + if !output.OK { + t.Fatalf("ok=false diagnostics=%#v", output.Diagnostics) + } + if output.Query.ResolvedID != "go://example.com/orders/order#CreateOrder" { + t.Fatalf("resolved id = %q", output.Query.ResolvedID) + } + if len(output.Callers) != 1 || output.Callers[0].Node.Name != "Caller" { + t.Fatalf("callers = %#v", output.Callers) + } + if len(output.Callees) != 1 || output.Callees[0].Node.Name != "validateOrder" { + t.Fatalf("callees = %#v", output.Callees) + } + for _, edge := range output.Edges { + if edge.Source == "" || edge.Confidence == "" { + t.Fatalf("edge missing metadata: %#v", edge) + } + } +} + +func TestTraceSymbolReportsAmbiguousShortName(t *testing.T) { + dir := t.TempDir() + writeTraceFile(t, dir, "go.mod", "module example.com/ambiguous\n\ngo 1.26.3\n") + writeTraceFile(t, dir, "a/a.go", "package a\nfunc Run() {}\n") + writeTraceFile(t, dir, "b/b.go", "package b\nfunc Run() {}\n") + + output := traceSymbol(Config{Dir: &dir}, "Run") + if output.OK { + t.Fatal("ok=true, want ambiguous failure") + } + if len(output.Candidates) != 2 { + t.Fatalf("candidates = %#v", output.Candidates) + } + assertTraceDiagnostic(t, output, "graph.symbol_ambiguous") +} + +func TestTraceRouteUsesFlowGraph(t *testing.T) { + dir := t.TempDir() + writeTraceFile(t, dir, "api/openapi.yaml", `openapi: 3.0.3 +paths: + /orders: + post: + operationId: createOrder + responses: + "200": + description: ok +`) + writeTraceFile(t, dir, "api/errors.yaml", `errors: + - code: 0 + message: ok + http_status: 200 +`) + + output := traceRoute(Config{Dir: &dir}, "POST /orders") + if !output.OK { + t.Fatalf("ok=false diagnostics=%#v", output.Diagnostics) + } + if len(output.FlowNodes) == 0 || len(output.FlowEdges) == 0 { + t.Fatalf("flow trace missing nodes or edges: %#v %#v", output.FlowNodes, output.FlowEdges) + } +} + +func TestTraceCapabilityUsesManifestSymbolAnchors(t *testing.T) { + dir := t.TempDir() + writeTraceFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: orders + version: "0.1.0" +capabilities: + - id: order_flow + kind: workflow + symbols: + - id: go://example.com/orders/order#CreateOrder + status: resolved +ai: + intent: test +`) + writeTraceFile(t, dir, "go.mod", "module example.com/orders\n\ngo 1.26.3\n") + writeTraceFile(t, dir, "order/service.go", `package order + +func CreateOrder() { validateOrder() } +func validateOrder() {} +func Caller() { CreateOrder() } +`) + + output := traceCapability(Config{Dir: &dir}, "order_flow") + if !output.OK { + t.Fatalf("ok=false diagnostics=%#v", output.Diagnostics) + } + if output.Query.ResolvedID != "order_flow" { + t.Fatalf("resolved id = %q", output.Query.ResolvedID) + } + if len(output.Callers) != 1 || output.Callers[0].Node.Name != "Caller" { + t.Fatalf("callers = %#v", output.Callers) + } + if len(output.Callees) != 1 || output.Callees[0].Node.Name != "validateOrder" { + t.Fatalf("callees = %#v", output.Callees) + } +} + +func TestTraceCapabilityWarnsOnDeclaredMissingSymbol(t *testing.T) { + dir := t.TempDir() + writeTraceFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: orders + version: "0.1.0" +capabilities: + - id: future_store + kind: relational_store + symbols: + - name: FutureStore + status: declared +ai: + intent: test +`) + writeTraceFile(t, dir, "go.mod", "module example.com/orders\n\ngo 1.26.3\n") + + output := traceCapability(Config{Dir: &dir}, "future_store") + if !output.OK { + t.Fatalf("ok=false diagnostics=%#v", output.Diagnostics) + } + assertTraceDiagnostic(t, output, "trace.capability_symbol_unresolved") + if len(output.Edges) != 0 { + t.Fatalf("edges = %#v, want empty for declared missing symbol", output.Edges) + } +} + +func TestTraceCommandRendersJSONBeforeFailure(t *testing.T) { + dir := t.TempDir() + writeTraceFile(t, dir, "go.mod", "module example.com/empty\n\ngo 1.26.3\n") + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"symbol", "Missing", "--json"}) + + err := cmd.Execute() + if !errors.Is(err, ErrTraceFailed) { + t.Fatalf("execute error = %v, want ErrTraceFailed", err) + } + var output map[string]any + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode output: %v\n%s", err, stdout.String()) + } + if output["ok"] != false || output["result_kind"] != resultKindTrace { + t.Fatalf("unexpected output: %#v", output) + } +} + +func writeTraceFile(t *testing.T, dir string, name string, data string) { + t.Helper() + path := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(data), 0o600); err != nil { + t.Fatal(err) + } +} + +func assertTraceDiagnostic(t *testing.T, output result, code string) { + t.Helper() + for _, item := range output.Diagnostics { + if item.Code == code { + return + } + } + t.Fatalf("diagnostic %q not found in %#v", code, output.Diagnostics) +} diff --git a/cmd/nucleus/internal/trace/constants.go b/cmd/nucleus/internal/trace/constants.go new file mode 100644 index 0000000..cbe2fa1 --- /dev/null +++ b/cmd/nucleus/internal/trace/constants.go @@ -0,0 +1,31 @@ +package trace + +const ( + commandUseTrace = "trace" + commandShortTrace = "query symbol and route chains" + commandUseSymbol = "symbol " + commandShortSymbol = "trace callers and callees for a symbol" + commandUseRoute = "route " + commandShortRoute = "trace a route through the flow graph" + commandUseCapability = "capability " + commandShortCapability = "trace callers and callees for capability anchor symbols" + defaultDir = "." + resultKindTrace = "nucleus.trace_result" + schemaVersionTrace = "trace-result.v1" + schemaRefTrace = "contract/schema/trace-result.v1.schema.json" + jsonIndentPrefix = "" + jsonIndentValue = " " + routeNodeKind = "route" + edgeKindCalls = "calls" + flowEdgeKindDispatch = "dispatch" +) + +const ( + flagJSON = "json" + flagPretty = "pretty" +) + +const ( + flagHelpJSON = "emit machine-readable trace result" + flagHelpPretty = "pretty-print JSON output" +) diff --git a/cmd/nucleus/internal/trace/output.go b/cmd/nucleus/internal/trace/output.go new file mode 100644 index 0000000..b94bcdf --- /dev/null +++ b/cmd/nucleus/internal/trace/output.go @@ -0,0 +1,94 @@ +package trace + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" +) + +type result struct { + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Query query `json:"query"` + Target *inspect.SymbolNode `json:"target,omitempty"` + Nodes []inspect.SymbolNode `json:"nodes"` + Edges []inspect.SymbolEdge `json:"edges"` + Callers []traceHop `json:"callers,omitempty"` + Callees []traceHop `json:"callees,omitempty"` + FlowNodes []inspect.FlowNode `json:"flow_nodes,omitempty"` + FlowEdges []inspect.FlowEdge `json:"flow_edges,omitempty"` + Candidates []inspect.SymbolNode `json:"candidates,omitempty"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` +} + +type query struct { + Kind string `json:"kind"` + Value string `json:"value"` + ResolvedID string `json:"resolved_id,omitempty"` +} + +type traceHop struct { + Node inspect.SymbolNode `json:"node"` + Edge inspect.SymbolEdge `json:"edge"` +} + +func renderJSON(writer io.Writer, output result, pretty bool) error { + output = normalizeResult(output) + encoder := json.NewEncoder(writer) + if pretty { + encoder.SetIndent(jsonIndentPrefix, jsonIndentValue) + } + return encoder.Encode(output) +} + +func renderHuman(writer io.Writer, output result) { + output = normalizeResult(output) + if output.OK { + _, _ = fmt.Fprintln(writer, "OK trace") + } else { + _, _ = fmt.Fprintln(writer, "FAILED trace") + } + _, _ = fmt.Fprintf(writer, "query: %s %s\n", output.Query.Kind, output.Query.Value) + _, _ = fmt.Fprintf(writer, "nodes: %d\n", len(output.Nodes)+len(output.FlowNodes)) + _, _ = fmt.Fprintf(writer, "edges: %d\n", len(output.Edges)+len(output.FlowEdges)) + for _, item := range output.Diagnostics { + _, _ = fmt.Fprintf(writer, " - %s %s: %s\n", item.Severity, item.Code, item.Message) + } +} + +func normalizeResult(output result) result { + if output.Nodes == nil { + output.Nodes = []inspect.SymbolNode{} + } + if output.Edges == nil { + output.Edges = []inspect.SymbolEdge{} + } + if output.Callers == nil { + output.Callers = []traceHop{} + } + if output.Callees == nil { + output.Callees = []traceHop{} + } + if output.FlowNodes == nil { + output.FlowNodes = []inspect.FlowNode{} + } + if output.FlowEdges == nil { + output.FlowEdges = []inspect.FlowEdge{} + } + if output.Candidates == nil { + output.Candidates = []inspect.SymbolNode{} + } + if output.Diagnostics == nil { + output.Diagnostics = diagnostic.Diagnostics{} + } + output.ResultKind = resultKindTrace + output.SchemaVersion = schemaVersionTrace + output.SchemaRef = schemaRefTrace + output.OK = !output.Diagnostics.Failed() + return output +} diff --git a/cmd/nucleus/internal/trace/run.go b/cmd/nucleus/internal/trace/run.go new file mode 100644 index 0000000..24f0e2a --- /dev/null +++ b/cmd/nucleus/internal/trace/run.go @@ -0,0 +1,281 @@ +package trace + +import ( + "errors" + "os" + "strings" + + "github.com/nucleuskit/contract/diagnostic" + "github.com/nucleuskit/contract/inspect" + "github.com/nucleuskit/contract/manifest" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/graphquery" +) + +func traceSymbol(config Config, value string) result { + dir := stringValue(config.Dir, defaultDir) + description, err := inspect.Describe(dir) + if err != nil { + return normalizeResult(result{ + Query: query{Kind: "symbol", Value: value}, + Diagnostics: diagnostic.Diagnostics{errorDiagnostic("trace.describe_failed", err.Error())}, + }) + } + resolved := graphquery.ResolveSymbol(description.SymbolGraph, value) + if !resolved.OK { + return normalizeResult(result{ + Query: query{Kind: "symbol", Value: value}, + Candidates: resolved.Candidates, + Diagnostics: resolved.Diagnostic, + }) + } + incoming := graphquery.IncomingEdges(description.SymbolGraph, resolved.Node.ID, graphquery.EdgeKindCalls) + outgoing := graphquery.OutgoingEdges(description.SymbolGraph, resolved.Node.ID, graphquery.EdgeKindCalls) + edges := append([]inspect.SymbolEdge{}, incoming...) + edges = append(edges, outgoing...) + nodes := graphquery.NodesForEdges(description.SymbolGraph, edges, resolved.Node.ID) + return normalizeResult(result{ + Query: query{Kind: "symbol", Value: value, ResolvedID: resolved.Node.ID}, + Target: &resolved.Node, + Nodes: nodes, + Edges: edges, + Callers: hops(description.SymbolGraph, incoming, true), + Callees: hops(description.SymbolGraph, outgoing, false), + }) +} + +// SymbolForMCP returns the same structured trace result used by the CLI. +func SymbolForMCP(dir string, value string) any { + return traceSymbol(Config{Dir: &dir}, value) +} + +func traceRoute(config Config, value string) result { + dir := stringValue(config.Dir, defaultDir) + graph, err := inspect.BuildFlowGraphFromDir(dir) + if err != nil { + return normalizeResult(result{ + Query: query{Kind: "route", Value: value}, + Diagnostics: diagnostic.Diagnostics{errorDiagnostic("trace.flow_graph_failed", err.Error())}, + }) + } + route, ok := findRouteNode(graph, value) + if !ok { + return normalizeResult(result{ + Query: query{Kind: "route", Value: value}, + Diagnostics: diagnostic.Diagnostics{errorDiagnostic("trace.route_not_found", "route was not found in flow graph")}, + }) + } + edges := flowReachableEdges(graph, route.ID) + nodes := flowNodesForEdges(graph, route.ID, edges) + return normalizeResult(result{ + Query: query{Kind: "route", Value: value, ResolvedID: route.ID}, + FlowNodes: nodes, + FlowEdges: edges, + }) +} + +// RouteForMCP returns the same structured route trace result used by the CLI. +func RouteForMCP(dir string, value string) any { + return traceRoute(Config{Dir: &dir}, value) +} + +func traceCapability(config Config, value string) result { + dir := stringValue(config.Dir, defaultDir) + m, err := manifest.Load(dir) + if err != nil { + code := "trace.manifest_load_failed" + message := err.Error() + if errors.Is(err, os.ErrNotExist) { + code = "trace.manifest_missing" + message = "nucleus.yaml is required to trace capability anchors" + } + return normalizeResult(result{ + Query: query{Kind: "capability", Value: value}, + Diagnostics: diagnostic.Diagnostics{errorDiagnostic(code, message)}, + }) + } + capability, ok := findCapability(m, value) + if !ok { + return normalizeResult(result{ + Query: query{Kind: "capability", Value: value}, + Diagnostics: diagnostic.Diagnostics{errorDiagnostic("trace.capability_not_found", "capability was not found in nucleus.yaml")}, + }) + } + if len(capability.Symbols) == 0 { + return normalizeResult(result{ + Query: query{Kind: "capability", Value: value, ResolvedID: capability.ID}, + Diagnostics: diagnostic.Diagnostics{warningDiagnostic("trace.capability_no_symbols", "capability has no symbol anchors")}, + }) + } + description, err := inspect.Describe(dir) + if err != nil { + return normalizeResult(result{ + Query: query{Kind: "capability", Value: value, ResolvedID: capability.ID}, + Diagnostics: diagnostic.Diagnostics{errorDiagnostic("trace.describe_failed", err.Error())}, + }) + } + var diagnostics diagnostic.Diagnostics + var candidates []inspect.SymbolNode + var incoming []inspect.SymbolEdge + var outgoing []inspect.SymbolEdge + var anchorIDs []string + for _, symbol := range capability.Symbols { + symbolQuery := symbol.ID + if symbolQuery == "" { + symbolQuery = symbol.Name + } + resolved := graphquery.ResolveSymbol(description.SymbolGraph, symbolQuery) + if resolved.OK { + anchorIDs = append(anchorIDs, resolved.Node.ID) + incoming = append(incoming, graphquery.IncomingEdges(description.SymbolGraph, resolved.Node.ID, graphquery.EdgeKindCalls)...) + outgoing = append(outgoing, graphquery.OutgoingEdges(description.SymbolGraph, resolved.Node.ID, graphquery.EdgeKindCalls)...) + continue + } + if len(resolved.Candidates) > 0 { + candidates = append(candidates, resolved.Candidates...) + diagnostics = append(diagnostics, errorDiagnostic("trace.symbol_ambiguous", "capability symbol matched multiple candidates; rerun mark with a stable symbol id")) + continue + } + diagnostics = append(diagnostics, warningDiagnostic("trace.capability_symbol_unresolved", "capability symbol anchor is not present in the current symbol graph")) + } + edges := append([]inspect.SymbolEdge{}, incoming...) + edges = append(edges, outgoing...) + edges = uniqueEdges(edges) + nodes := graphquery.NodesForEdges(description.SymbolGraph, edges, anchorIDs...) + return normalizeResult(result{ + Query: query{Kind: "capability", Value: value, ResolvedID: capability.ID}, + Nodes: nodes, + Edges: edges, + Callers: hops(description.SymbolGraph, uniqueEdges(incoming), true), + Callees: hops(description.SymbolGraph, uniqueEdges(outgoing), false), + Candidates: candidates, + Diagnostics: diagnostics, + }) +} + +// CapabilityForMCP returns the same structured capability trace result used by the CLI. +func CapabilityForMCP(dir string, value string) any { + return traceCapability(Config{Dir: &dir}, value) +} + +func hops(graph inspect.SymbolGraph, edges []inspect.SymbolEdge, incoming bool) []traceHop { + nodeByID := map[string]inspect.SymbolNode{} + for _, node := range graph.Nodes { + nodeByID[node.ID] = node + } + result := make([]traceHop, 0, len(edges)) + for _, edge := range edges { + id := edge.To + if incoming { + id = edge.From + } + node, ok := nodeByID[id] + if !ok { + continue + } + result = append(result, traceHop{Node: node, Edge: edge}) + } + return result +} + +func findRouteNode(graph inspect.FlowGraph, value string) (inspect.FlowNode, bool) { + value = strings.TrimSpace(value) + for _, node := range graph.Nodes { + if node.Kind != routeNodeKind { + continue + } + if node.Name == value || strings.EqualFold(node.Method+" "+node.Path, value) || node.ID == value { + return node, true + } + } + return inspect.FlowNode{}, false +} + +func flowReachableEdges(graph inspect.FlowGraph, routeID string) []inspect.FlowEdge { + seenNodes := map[string]bool{routeID: true} + var result []inspect.FlowEdge + changed := true + for changed { + changed = false + for _, edge := range graph.Edges { + if !seenNodes[edge.From] { + continue + } + if containsFlowEdge(result, edge) { + continue + } + result = append(result, edge) + if !seenNodes[edge.To] { + seenNodes[edge.To] = true + changed = true + } + } + } + return result +} + +func flowNodesForEdges(graph inspect.FlowGraph, routeID string, edges []inspect.FlowEdge) []inspect.FlowNode { + nodeByID := map[string]inspect.FlowNode{} + for _, node := range graph.Nodes { + nodeByID[node.ID] = node + } + seen := map[string]bool{} + var result []inspect.FlowNode + add := func(id string) { + if seen[id] { + return + } + node, ok := nodeByID[id] + if !ok { + return + } + seen[id] = true + result = append(result, node) + } + add(routeID) + for _, edge := range edges { + add(edge.From) + add(edge.To) + } + return result +} + +func containsFlowEdge(edges []inspect.FlowEdge, want inspect.FlowEdge) bool { + for _, edge := range edges { + if edge.From == want.From && edge.To == want.To && edge.Kind == want.Kind { + return true + } + } + return false +} + +func findCapability(m manifest.Manifest, id string) (manifest.Capability, bool) { + id = strings.TrimSpace(id) + for _, capability := range m.Capabilities { + if capability.ID == id { + return capability, true + } + } + return manifest.Capability{}, false +} + +func uniqueEdges(edges []inspect.SymbolEdge) []inspect.SymbolEdge { + seen := map[string]bool{} + var result []inspect.SymbolEdge + for _, edge := range edges { + key := edge.From + "\x00" + edge.Kind + "\x00" + edge.To + if seen[key] { + continue + } + seen[key] = true + result = append(result, edge) + } + return result +} + +func errorDiagnostic(code string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: code, Message: message} +} + +func warningDiagnostic(code string, message string) diagnostic.Diagnostic { + return diagnostic.Diagnostic{Severity: diagnostic.SeverityWarning, Code: code, Message: message} +} diff --git a/cmd/nucleus/internal/validate/constants.go b/cmd/nucleus/internal/validate/constants.go index 71e7612..43fde48 100644 --- a/cmd/nucleus/internal/validate/constants.go +++ b/cmd/nucleus/internal/validate/constants.go @@ -14,7 +14,9 @@ const ( ) const ( - resultKindValidate = "nucleus.validate_result" - jsonIndentPrefix = "" - jsonIndentValue = " " + resultKindValidate = "nucleus.validate_result" + schemaVersionValidate = "validate-result.v1" + schemaRefValidate = "contract/schema/validate-result.v1.schema.json" + jsonIndentPrefix = "" + jsonIndentValue = " " ) diff --git a/cmd/nucleus/internal/validate/output.go b/cmd/nucleus/internal/validate/output.go index c5fd6fe..3d6775f 100644 --- a/cmd/nucleus/internal/validate/output.go +++ b/cmd/nucleus/internal/validate/output.go @@ -10,10 +10,12 @@ import ( ) type validateResult struct { - ResultKind string `json:"result_kind"` - OK bool `json:"ok"` - Summary validateSummary `json:"summary"` - Diagnostics diagnostic.Diagnostics `json:"diagnostics"` + ResultKind string `json:"result_kind"` + SchemaVersion string `json:"schema_version"` + SchemaRef string `json:"schema_ref"` + OK bool `json:"ok"` + Summary validateSummary `json:"summary"` + Diagnostics diagnostic.Diagnostics `json:"diagnostics"` } type validateSummary struct { @@ -44,10 +46,12 @@ func renderJSON(writer io.Writer, diagnostics diagnostic.Diagnostics, summary va diagnostics = diagnostic.Diagnostics{} } result := validateResult{ - ResultKind: resultKindValidate, - OK: !diagnostics.Failed(), - Summary: summary, - Diagnostics: diagnostics, + ResultKind: resultKindValidate, + SchemaVersion: schemaVersionValidate, + SchemaRef: schemaRefValidate, + OK: !diagnostics.Failed(), + Summary: summary, + Diagnostics: diagnostics, } encoder := json.NewEncoder(writer) if pretty { diff --git a/cmd/nucleus/internal/validate/output_test.go b/cmd/nucleus/internal/validate/output_test.go index feba538..effad7c 100644 --- a/cmd/nucleus/internal/validate/output_test.go +++ b/cmd/nucleus/internal/validate/output_test.go @@ -89,6 +89,12 @@ func TestRenderJSONOutput(t *testing.T) { if output["result_kind"] != resultKindValidate { t.Fatalf("result_kind = %v, want %s", output["result_kind"], resultKindValidate) } + if output["schema_version"] != schemaVersionValidate { + t.Fatalf("schema_version = %v, want %s", output["schema_version"], schemaVersionValidate) + } + if output["schema_ref"] != schemaRefValidate { + t.Fatalf("schema_ref = %v, want %s", output["schema_ref"], schemaRefValidate) + } if output["ok"] != false { t.Fatalf("ok = %v, want false", output["ok"]) } diff --git a/cmd/nucleus/internal/verify/command_test.go b/cmd/nucleus/internal/verify/command_test.go index 644827f..68fe79e 100644 --- a/cmd/nucleus/internal/verify/command_test.go +++ b/cmd/nucleus/internal/verify/command_test.go @@ -58,9 +58,46 @@ func TestCommandJSONSuccess(t *testing.T) { if output.Summary.Failed != 0 { t.Fatalf("summary.failed = %d, want 0", output.Summary.Failed) } - assertSuccessfulStepOrder(t, output.Steps, []string{"validate", "lint", "generated_freshness", "tidy", "import", "build", "test"}) - if output.Summary.Steps != 7 || output.Summary.Passed != 7 { - t.Fatalf("summary = %#v, want 7 steps passed", output.Summary) + assertSuccessfulStepOrder(t, output.Steps, []string{"validate", "lint", "decision", "generated_freshness"}) + if output.Summary.Steps != 4 || output.Summary.Passed != 4 { + t.Fatalf("summary = %#v, want 4 steps passed", output.Summary) + } +} + +func TestCommandJSONRunsManifestVerifyCommands(t *testing.T) { + dir := t.TempDir() + writeVerifyModuleWithCommands(t, dir, []string{"go test ./..."}) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--json"}) + + if err := cmd.Execute(); err != nil { + t.Fatalf("execute verify: %v\nstdout=%s", err, stdout.String()) + } + + var output struct { + OK bool `json:"ok"` + Summary verifySummary `json:"summary"` + Steps []verifyStep `json:"steps"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if !output.OK { + t.Fatal("ok = false, want true") + } + step, ok := findStep(output.Steps, "verify_command") + if !ok { + t.Fatalf("steps = %#v, want manifest verify command step", output.Steps) + } + if step.ID != "verify_command_1" || step.Command != "go test ./..." || !step.OK { + t.Fatalf("verify command step = %#v", step) + } + if output.Summary.Steps != 5 || output.Summary.Passed != 5 { + t.Fatalf("summary = %#v, want protocol steps plus one declared verify command", output.Summary) } } @@ -68,7 +105,7 @@ func TestCommandJSONValidationFailure(t *testing.T) { dir := t.TempDir() writeVerifyFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") writeVerifyFile(t, dir, "demo.go", "package demo\n") - writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: version: "0.1.0" ai: @@ -160,8 +197,8 @@ func TestCommandJSONGeneratedFreshnessFailure(t *testing.T) { if item := step.GeneratedFreshness[0]; item.Target != "contract/gen" || item.Fresh { t.Fatalf("generated_freshness item = %#v, want stale contract/gen", item) } - if _, ok := findStep(output.Steps, "tidy"); ok { - t.Fatalf("steps = %#v, tidy should not run after generated freshness failure", output.Steps) + if _, ok := findStep(output.Steps, "verify_command"); ok { + t.Fatalf("steps = %#v, verify commands should not run after generated freshness failure", output.Steps) } foundL010 := false for _, finding := range output.Findings { @@ -178,6 +215,69 @@ func TestCommandJSONGeneratedFreshnessFailure(t *testing.T) { } } +func TestCommandJSONDecisionFailure(t *testing.T) { + dir := t.TempDir() + writeVerifyModule(t, dir) + writeVerifyFile(t, dir, ".nucleus/decisions/bad.yaml", `schema_version: "decision.v1" +id: bad +capability: missing +decision: + provider: gorm + status: proposed + locked: false +reason: + - missing capability declaration +verification: + commands: + - go test ./... +`) + + cmd := NewCommand(Config{Dir: &dir}) + var stdout bytes.Buffer + cmd.SetOut(&stdout) + cmd.SetErr(&bytes.Buffer{}) + cmd.SetArgs([]string{"--json"}) + + err := cmd.Execute() + if !errors.Is(err, ErrVerifyFailed) { + t.Fatalf("execute verify error = %v, want ErrVerifyFailed", err) + } + + var output struct { + OK bool `json:"ok"` + Steps []verifyStep `json:"steps"` + Diagnostics []struct { + Code string `json:"code"` + } `json:"diagnostics"` + } + if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { + t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) + } + if output.OK { + t.Fatal("ok = true, want false") + } + step, ok := findStep(output.Steps, "decision") + if !ok { + t.Fatalf("steps = %#v, want decision phase", output.Steps) + } + if step.OK || step.DecisionQuality == nil || step.DecisionQuality.Errors == 0 { + t.Fatalf("decision step = %#v, want failed decision quality", step) + } + if _, ok := findStep(output.Steps, "generated_freshness"); ok { + t.Fatalf("steps = %#v, generated_freshness should not run after decision failure", output.Steps) + } + found := false + for _, item := range output.Diagnostics { + if item.Code == "decision.capability_missing" { + found = true + break + } + } + if !found { + t.Fatalf("diagnostics = %#v, want decision.capability_missing", output.Diagnostics) + } +} + func TestCommandJSONGeneratedFreshnessSuccess(t *testing.T) { dir := t.TempDir() writeVerifyGeneratedModule(t, dir) @@ -220,7 +320,7 @@ func TestCommandJSONGeneratedFreshnessSuccess(t *testing.T) { } } -func TestCommandJSONTidyChangedModuleFilesFailure(t *testing.T) { +func TestCommandJSONDoesNotRunImplicitTidy(t *testing.T) { dir := t.TempDir() originalGoMod := `module example.com/demo @@ -233,7 +333,7 @@ replace example.com/unused => ./unused writeVerifyFile(t, dir, "go.mod", originalGoMod) writeVerifyFile(t, dir, "demo.go", "package demo\n") writeVerifyFile(t, dir, "unused/go.mod", "module example.com/unused\n\ngo 1.26.3\n") - writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -248,9 +348,8 @@ capabilities: [] cmd.SetErr(&bytes.Buffer{}) cmd.SetArgs([]string{"--json"}) - err := cmd.Execute() - if !errors.Is(err, ErrVerifyFailed) { - t.Fatalf("execute verify error = %v, want ErrVerifyFailed", err) + if err := cmd.Execute(); err != nil { + t.Fatalf("execute verify: %v\nstdout=%s", err, stdout.String()) } var output struct { @@ -260,24 +359,13 @@ capabilities: [] if err := json.Unmarshal(stdout.Bytes(), &output); err != nil { t.Fatalf("decode JSON: %v\n%s", err, stdout.String()) } - if output.OK { - t.Fatal("ok = true, want false") - } - step, ok := findStep(output.Steps, "tidy") - if !ok { - t.Fatalf("steps = %#v, want tidy phase", output.Steps) - } - if step.OK { - t.Fatalf("tidy step OK = true, want false") - } - if step.Error != "go mod tidy changed module files" { - t.Fatalf("tidy error = %q, want changed module files", step.Error) - } - if !containsString(step.ChangedPaths, "go.mod") { - t.Fatalf("changed_paths = %#v, want go.mod", step.ChangedPaths) + if !output.OK { + t.Fatal("ok = false, want true") } - if _, ok := findStep(output.Steps, "import"); ok { - t.Fatalf("steps = %#v, import should not run after tidy failure", output.Steps) + for _, forbidden := range []string{"tidy", "import", "build", "test"} { + if _, ok := findStep(output.Steps, forbidden); ok { + t.Fatalf("steps = %#v, verify should not run implicit %s step", output.Steps, forbidden) + } } data, err := os.ReadFile(filepath.Join(dir, "go.mod")) if err != nil { @@ -311,7 +399,7 @@ func writeVerifyModule(t *testing.T, dir string) { t.Helper() writeVerifyFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") writeVerifyFile(t, dir, "demo.go", "package demo\n") - writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -321,6 +409,30 @@ capabilities: [] `) } +func writeVerifyModuleWithCommands(t *testing.T, dir string, commands []string) { + t.Helper() + writeVerifyFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") + writeVerifyFile(t, dir, "demo.go", "package demo\n") + var verifyBlock strings.Builder + if len(commands) > 0 { + verifyBlock.WriteString("verify:\n commands:\n") + for _, command := range commands { + data, _ := json.Marshal(command) + verifyBlock.WriteString(" - ") + verifyBlock.Write(data) + verifyBlock.WriteString("\n") + } + } + writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "2.0" +service: + name: demo + version: "0.1.0" +ai: + intent: test +capabilities: [] +`+verifyBlock.String()) +} + func writeVerifyGeneratedModule(t *testing.T, dir string) { t.Helper() writeVerifyFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") @@ -337,7 +449,7 @@ paths: "204": description: ok `) - writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "1.0" + writeVerifyFile(t, dir, "nucleus.yaml", `schema_version: "2.0" service: name: demo version: "0.1.0" @@ -382,6 +494,9 @@ func assertSuccessfulStepOrder(t *testing.T, steps []verifyStep, want []string) if step.ID != phase { t.Fatalf("steps[%d].id = %q, want %q", index, step.ID, phase) } + if step.Kind != phase { + t.Fatalf("steps[%d].kind = %q, want %q", index, step.Kind, phase) + } if step.Sequence != index+1 { t.Fatalf("steps[%d].sequence = %d, want %d", index, step.Sequence, index+1) } diff --git a/cmd/nucleus/internal/verify/constants.go b/cmd/nucleus/internal/verify/constants.go index 0746a9c..d13ac85 100644 --- a/cmd/nucleus/internal/verify/constants.go +++ b/cmd/nucleus/internal/verify/constants.go @@ -15,8 +15,8 @@ const ( const ( resultKindVerify = "nucleus.verify_result" - schemaVersion = "verify.v1" - schemaRef = "contract/schema/evidence.schema.json" + schemaVersion = "evidence.v1" + schemaRef = "contract/schema/evidence.v1.schema.json" jsonIndentPrefix = "" jsonIndentValue = " " ) @@ -24,21 +24,21 @@ const ( const ( phaseValidate = "validate" phaseLint = "lint" + phaseDecision = "decision" phaseGeneratedFreshness = "generated_freshness" - phaseTidy = "tidy" - phaseImport = "import" - phaseBuild = "build" - phaseTest = "test" + phaseVerifyCommand = "verify_command" ) const ( - commandValidate = "nucleus validate --dir ." - commandLintStrict = "nucleus lint --dir . --strict" - commandGeneratedFreshness = "nucleus describe --dir . --json" - commandWorkingDir = "." - statusPassed = "passed" - statusFailed = "failed" - redactedValue = "[REDACTED]" - truncatedOutputNotice = "[output truncated]" - maxCommandOutputRunes = 32768 + commandValidate = "nucleus validate --dir ." + commandLintStrict = "nucleus lint --dir . --strict" + commandDecisionValidate = "nucleus decision validate --dir ." + commandGeneratedFreshness = "nucleus describe --dir . --json" + commandWorkingDir = "." + statusPassed = "passed" + statusFailed = "failed" + redactedValue = "[REDACTED]" + truncatedOutputNotice = "[output truncated]" + maxCommandOutputRunes = 32768 + projectVerifyTimeoutSeconds = 120 ) diff --git a/cmd/nucleus/internal/verify/output.go b/cmd/nucleus/internal/verify/output.go index 91754c7..7aecc49 100644 --- a/cmd/nucleus/internal/verify/output.go +++ b/cmd/nucleus/internal/verify/output.go @@ -8,6 +8,7 @@ import ( "github.com/nucleuskit/contract/diagnostic" "github.com/nucleuskit/contract/inspect" contractlint "github.com/nucleuskit/contract/lint" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/decision" ) type verifyResult struct { @@ -32,6 +33,7 @@ type verifySummary struct { type verifyStep struct { ID string `json:"id"` + Kind string `json:"kind"` Sequence int `json:"sequence"` Phase string `json:"phase"` Command string `json:"command"` @@ -45,6 +47,7 @@ type verifyStep struct { Error string `json:"error,omitempty"` ChangedPaths []string `json:"changed_paths,omitempty"` GeneratedFreshness []inspect.GeneratedFreshness `json:"generated_freshness,omitempty"` + DecisionQuality *decision.QualitySummary `json:"decision_quality,omitempty"` } func renderHuman(stdout io.Writer, stderr io.Writer, result verifyResult) { diff --git a/cmd/nucleus/internal/verify/run.go b/cmd/nucleus/internal/verify/run.go index c95f7a8..0072b05 100644 --- a/cmd/nucleus/internal/verify/run.go +++ b/cmd/nucleus/internal/verify/run.go @@ -2,12 +2,17 @@ package verify import ( "bytes" + "context" + "fmt" "os/exec" "strings" + "time" "github.com/nucleuskit/contract/diagnostic" contractlint "github.com/nucleuskit/contract/lint" + "github.com/nucleuskit/contract/manifest" "github.com/nucleuskit/contract/validation" + "github.com/nucleuskit/nucleus/cmd/nucleus/internal/decision" ) func run(config Config) (verifyResult, error) { @@ -24,6 +29,12 @@ func run(config Config) (verifyResult, error) { result := buildResult(steps, diagnostics, nil) return result, ErrVerifyFailed } + m, err := manifest.Load(dir) + if err != nil { + diagnostics = append(diagnostics, diagnostic.Diagnostic{Severity: diagnostic.SeverityError, Code: "verify.manifest_read_failed", Path: "nucleus.yaml", Message: err.Error()}) + result := buildResult(steps, diagnostics, nil) + return result, ErrVerifyFailed + } findings := contractlint.Run(dir, true) steps = append(steps, verifyStep{ @@ -32,29 +43,23 @@ func run(config Config) (verifyResult, error) { OK: len(findings) == 0, }) - freshnessStep := runGeneratedFreshness(dir) - steps = append(steps, freshnessStep) - if len(findings) > 0 || !freshnessStep.OK { + decisionStep, decisionDiagnostics := runDecisionValidation(dir) + steps = append(steps, decisionStep) + diagnostics = append(diagnostics, decisionDiagnostics...) + if decisionDiagnostics.Failed() { result := buildResult(steps, diagnostics, findings) return result, ErrVerifyFailed } - tidyStep := runTidyCommand(dir) - steps = append(steps, tidyStep) - if !tidyStep.OK { + freshnessStep := runGeneratedFreshness(dir) + steps = append(steps, freshnessStep) + if len(findings) > 0 || !freshnessStep.OK { result := buildResult(steps, diagnostics, findings) return result, ErrVerifyFailed } - for _, command := range []struct { - phase string - args []string - }{ - {phaseImport, []string{"list", "./..."}}, - {phaseBuild, []string{"test", "./...", "-run", "^$"}}, - {phaseTest, []string{"test", "./..."}}, - } { - step := runGoCommand(dir, command.phase, command.args) + for index, command := range m.Verify.Commands { + step := runProjectVerifyCommand(dir, command, index) steps = append(steps, step) if !step.OK { result := buildResult(steps, diagnostics, findings) @@ -72,18 +77,38 @@ func BuildResultForDir(dir string) verifyResult { return result } -func runGoCommand(dir string, phase string, args []string) verifyStep { - cmd := exec.Command("go", args...) +func runProjectVerifyCommand(dir string, command string, index int) verifyStep { + args, parseErr := splitCommand(command) + step := verifyStep{ + ID: fmt.Sprintf("%s_%d", phaseVerifyCommand, index+1), + Phase: phaseVerifyCommand, + Command: sanitizeCommandOutput(command, dir), + OK: parseErr == nil && len(args) > 0, + } + if !step.OK { + step.Error = "invalid verify command" + if parseErr != nil { + step.Error = parseErr.Error() + } + step.ExitCode = 1 + return step + } + ctx, cancel := context.WithTimeout(context.Background(), time.Duration(projectVerifyTimeoutSeconds)*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, args[0], args[1:]...) cmd.Dir = dir var output bytes.Buffer cmd.Stdout = &output cmd.Stderr = &output err := cmd.Run() - step := verifyStep{ - Phase: phase, - Command: "go " + strings.Join(args, " "), - OK: err == nil, - Output: sanitizeCommandOutput(output.String(), dir), + step.OK = err == nil + step.Output = sanitizeCommandOutput(output.String(), dir) + if ctx.Err() == context.DeadlineExceeded { + step.OK = false + step.Error = fmt.Sprintf("command timed out after %d seconds", projectVerifyTimeoutSeconds) + step.ExitCode = 124 + return step } if err != nil { step.Error = err.Error() @@ -96,6 +121,68 @@ func runGoCommand(dir string, phase string, args []string) verifyStep { return step } +func splitCommand(command string) ([]string, error) { + var result []string + var current strings.Builder + var quote rune + escaped := false + for _, r := range command { + switch { + case escaped: + current.WriteRune(r) + escaped = false + case r == '\\': + escaped = true + case quote != 0: + if r == quote { + quote = 0 + } else { + current.WriteRune(r) + } + case r == '\'' || r == '"': + quote = r + case r == ' ' || r == '\t' || r == '\n': + if current.Len() > 0 { + result = append(result, current.String()) + current.Reset() + } + default: + current.WriteRune(r) + } + } + if escaped { + current.WriteRune('\\') + } + if quote != 0 { + return nil, fmt.Errorf("unterminated quote in command") + } + if current.Len() > 0 { + result = append(result, current.String()) + } + return result, nil +} + +func runDecisionValidation(dir string) (verifyStep, diagnostic.Diagnostics) { + quality := decision.QualityForDir(dir) + step := verifyStep{ + Phase: phaseDecision, + Command: commandDecisionValidate, + OK: !quality.Diagnostics.Failed(), + DecisionQuality: &quality, + Output: fmt.Sprintf( + "decisions: files=%d valid=%d accepted_locked=%d supersedes=%d drift=%d diagnostics=%d errors, %d warnings", + quality.Files, + quality.Valid, + quality.AcceptedLocked, + quality.Supersedes, + quality.Drift, + quality.Errors, + quality.Warnings, + ), + } + return step, quality.Diagnostics +} + func buildResult(steps []verifyStep, diagnostics diagnostic.Diagnostics, findings []contractlint.Finding) verifyResult { steps = completeStepMetadata(steps) summary := verifySummary{ @@ -131,6 +218,9 @@ func completeStepMetadata(steps []verifyStep) []verifyStep { if step.ID == "" { step.ID = step.Phase } + if step.Kind == "" { + step.Kind = step.Phase + } if step.Sequence == 0 { step.Sequence = index + 1 } diff --git a/cmd/nucleus/internal/verify/tidy.go b/cmd/nucleus/internal/verify/tidy.go deleted file mode 100644 index 6aa78f1..0000000 --- a/cmd/nucleus/internal/verify/tidy.go +++ /dev/null @@ -1,114 +0,0 @@ -package verify - -import ( - "crypto/sha256" - "errors" - "os" - "path/filepath" - "sort" -) - -type fileSnapshot struct { - exists bool - hash [sha256.Size]byte - mode os.FileMode - data []byte -} - -func runTidyCommand(dir string) verifyStep { - before, err := snapshotModuleFiles(dir) - if err != nil { - return verifyStep{ - Phase: phaseTidy, - Command: "go mod tidy", - OK: false, - Error: "read module files failed", - } - } - step := runGoCommand(dir, phaseTidy, []string{"mod", "tidy"}) - after, err := snapshotModuleFiles(dir) - if err != nil { - step.OK = false - step.Error = "read module files failed" - return step - } - step.ChangedPaths = changedModuleFiles(before, after) - if len(step.ChangedPaths) > 0 { - if err := restoreModuleFiles(dir, before); err != nil { - step.OK = false - step.Error = "restore module files failed" - return step - } - } - if step.OK && len(step.ChangedPaths) > 0 { - step.OK = false - step.Error = "go mod tidy changed module files" - } - return step -} - -func snapshotModuleFiles(dir string) (map[string]fileSnapshot, error) { - files := []string{"go.mod", "go.sum"} - snapshots := make(map[string]fileSnapshot, len(files)) - for _, name := range files { - data, err := os.ReadFile(filepath.Join(dir, name)) - if err != nil { - if errors.Is(err, os.ErrNotExist) { - snapshots[name] = fileSnapshot{} - continue - } - return nil, err - } - info, err := os.Stat(filepath.Join(dir, name)) - if err != nil { - return nil, err - } - snapshots[name] = fileSnapshot{ - exists: true, - hash: sha256.Sum256(data), - mode: info.Mode().Perm(), - data: data, - } - } - return snapshots, nil -} - -func restoreModuleFiles(dir string, snapshots map[string]fileSnapshot) error { - for name, snapshot := range snapshots { - path := filepath.Join(dir, name) - if !snapshot.exists { - if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - continue - } - mode := snapshot.mode - if mode == 0 { - mode = 0o644 - } - if err := os.WriteFile(path, snapshot.data, mode); err != nil { - return err - } - } - return nil -} - -func changedModuleFiles(before map[string]fileSnapshot, after map[string]fileSnapshot) []string { - seen := map[string]struct{}{} - for name := range before { - seen[name] = struct{}{} - } - for name := range after { - seen[name] = struct{}{} - } - changed := make([]string, 0, len(seen)) - for name := range seen { - left := before[name] - right := after[name] - if left.exists != right.exists || left.hash != right.hash { - changed = append(changed, name) - } - } - sort.Strings(changed) - return changed -} diff --git a/cmd/nucleus/main.go b/cmd/nucleus/main.go index 06604a8..6d3e902 100644 --- a/cmd/nucleus/main.go +++ b/cmd/nucleus/main.go @@ -1,6 +1,7 @@ package main import ( + "fmt" "os" "github.com/nucleuskit/nucleus/cmd/nucleus/internal/root" @@ -8,6 +9,7 @@ import ( func main() { if err := root.New().Execute(); err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) os.Exit(1) } } diff --git a/contract b/contract index a4f36a6..3eb5338 160000 --- a/contract +++ b/contract @@ -1 +1 @@ -Subproject commit a4f36a63dc933fd792ae6e72269c184387f13f60 +Subproject commit 3eb5338fd875043d8cfdb7e6085cb19f3552246d diff --git a/docs/adr/0001-project-scope.md b/docs/adr/0001-project-scope.md index e29ae39..19ee4ed 100644 --- a/docs/adr/0001-project-scope.md +++ b/docs/adr/0001-project-scope.md @@ -10,12 +10,14 @@ Nucleus needs a clear public boundary before the source is imported. ## Decision -Nucleus is an AI-first Go microservice kernel. It focuses on contract-driven service generation, manifest-driven metadata, capability protocols, thin runtime assembly, and verifiable AI-safe change loops. +Nucleus is an agent-native Go microservice protocol layer. It focuses on contract-driven behavior facts, manifest-driven protocol indexes, source graph inspection, decision evidence, edit-surface policy, and verifiable AI-safe change loops. -Nucleus will not become a default middleware bundle, a business domain framework, a platform UI, or a private SDK compatibility layer. +Nucleus will not become a default middleware bundle, a business domain framework, a platform UI, a provider SDK collection, a project scaffold, or a private SDK compatibility layer. ## Consequences -New capabilities must fit one of the project areas: `core`, `contract`, `cap`, `bridge`, `runtime`, `cmd/nucleus`, `examples`, `template`, or `docs`. +New capabilities must fit one of the project areas: `contract`, `cmd/nucleus`, `docs`, local protocol schemas, graph inspection, decision evidence, or narrowly scoped runtime adapters explicitly chosen by a user project. + +Provider choices, library choices, drivers, directory layouts, repository implementations, migrations, Dockerfiles, and application wiring are not Nucleus-owned concerns. Large scope changes require an ADR or design issue before implementation. diff --git a/docs/adr/0002-agent-native-protocol-layer.md b/docs/adr/0002-agent-native-protocol-layer.md new file mode 100644 index 0000000..e56c2d4 --- /dev/null +++ b/docs/adr/0002-agent-native-protocol-layer.md @@ -0,0 +1,53 @@ +# ADR 0002: Agent-Native Protocol Layer + +## Status + +Accepted. + +## Context + +The project is pre-release and has not been adopted as a production framework. Early implementation and documentation still contained scaffold, provider, bridge, and platform-readiness concepts that conflict with the desired direction. + +AI agents need structured facts, graph context, edit boundaries, decisions, and evidence. They do not need Nucleus to choose an ORM, database driver, service registry, directory layout, or provider SDK. + +## Decision + +Nucleus will be implemented as an agent-native protocol layer that can be added to any Go project. + +The primary workflow is: + +```text +adopt -> describe graph -> trace -> impact -> plan -> decision -> verify +``` + +Nucleus owns: + +- protocol schemas +- local manifest indexes +- source graph inspection +- edit-surface policy +- generated freshness metadata +- technical decision evidence +- local verification evidence +- local quality reports + +Nucleus does not own: + +- project scaffolding +- provider SDK adapters +- bridge modules +- ORM or driver choices +- application wiring +- repository implementations +- migrations +- platform upload or control-plane workflows + +## Consequences + +The pre-release codebase may remove incompatible commands and modules instead of preserving compatibility. + +`nucleus.yaml` v2 becomes a thin protocol index. Provider, library, and driver choices live in `.nucleus/decisions/*.yaml`, not in the manifest. + +Capability kinds are advisory vocabulary data, not Go-coded enums. Unknown kinds and unknown provider decisions are valid when their schema and evidence are complete. + +`bridge` is removed from the core module boundary. Provider knowledge may exist as recipes or external examples, but recipes cannot execute commands or write files. diff --git a/docs/concepts/ai-first-microservice-kernel.md b/docs/concepts/agent-native-protocol-layer.md similarity index 81% rename from docs/concepts/ai-first-microservice-kernel.md rename to docs/concepts/agent-native-protocol-layer.md index 3765a3e..7c379e8 100644 --- a/docs/concepts/ai-first-microservice-kernel.md +++ b/docs/concepts/agent-native-protocol-layer.md @@ -1,4 +1,4 @@ -# AI-First Microservice Kernel +# Agent-Native Protocol Layer Nucleus treats an AI agent as a first-class service maintainer. @@ -7,10 +7,13 @@ The project shape is designed so an agent can answer these questions before edit - What public contracts define this service? - Which generated files are stale? - Which files are safe to edit? -- Which capabilities are declared and injected? +- Which capabilities are declared as semantic anchors? +- Which provider, library, or driver choices have explicit decision evidence? - Which commands must pass before the change is reviewable? -The framework is therefore centered on contracts, manifests, schemas, and evidence, rather than on hidden defaults or a large middleware bundle. +The framework is therefore centered on contracts, manifests, source graphs, +schemas, decisions, and evidence, rather than on hidden defaults, provider +selection, project templates, or a large middleware bundle. ## Scenario Evidence diff --git a/docs/concepts/migrate-command.md b/docs/concepts/migrate-command.md deleted file mode 100644 index 08a14c3..0000000 --- a/docs/concepts/migrate-command.md +++ /dev/null @@ -1,63 +0,0 @@ -# Migrate Command - -`nucleus migrate` produces a local migration plan for moving a service between -Nucleus or manifest schema versions. It is intentionally report-only: the -command does not rewrite service files, call provider SDKs, or contact a control -plane. - -## Modes - -The default mode is `plan`. It loads the service through `contract/inspect`, -combines the discovered manifest, contract, capability, generated freshness, and -verification metadata with CLI-local migration rules, then emits an auditable -plan. - -`--check` switches to readiness checking. The output shape remains the same, but -readiness failures such as stale generated artifacts or missing verification -commands become errors and produce a non-zero exit code. - -Both modes require `--from-version` and `--to-version`. Versions use -`MAJOR.MINOR` or `MAJOR.MINOR.PATCH` format with an optional leading `v`. - -## Output - -Human output is the default. `--json` emits a stable CLI result envelope: - -```json -{ - "result_kind": "nucleus.migrate_result", - "schema_version": "migrate.v1", - "schema_ref": "contract/schema/migrate.schema.json", - "ok": true, - "mode": "plan", - "summary": {}, - "diagnostics": [], - "migration": {} -} -``` - -Use `--pretty` with `--json` for indented JSON. Use `--report ` to write -the same JSON envelope to a report file. Report paths must resolve inside the -service directory; relative paths are resolved from that directory. - -## Migration Scope - -The migration plan is contract-first. Manifest and agent instructions are listed -before contract surfaces, generated artifacts, capability wiring, and -verification commands. Exact version rules are recorded in the CLI command; when -no exact rule is registered, the command emits a warning and falls back to a -generic forward migration checklist. - -`migrate` does not replace the AI-safe change loop. Service owners should apply -the planned edits through: - -```bash -nucleus describe --dir . --json -nucleus plan --dir . --task "" --json -nucleus gen --dir . -nucleus lint --dir . --strict -nucleus verify --dir . --json -``` - -This keeps migration behavior auditable without expanding Nucleus into a -runtime framework or hidden auto-upgrader. diff --git a/docs/concepts/report-command.md b/docs/concepts/report-command.md index 8005021..f836e14 100644 --- a/docs/concepts/report-command.md +++ b/docs/concepts/report-command.md @@ -1,24 +1,16 @@ # Report Command `nucleus report` produces local, auditable summaries for AI-assisted change -quality and release readiness. It is intentionally read-only: the command does -not upload artifacts, call a control plane, or contact provider SDKs. +quality. It is intentionally read-only: the command does not upload artifacts, +call a control plane, or contact provider SDKs. ## Modes -The default mode is `ai_quality`. It reads task result JSON files from +The only mode is `ai_quality`. It reads task result JSON files from `artifacts/nucleus/ai-tasks` under the service directory. Use `--ai-tasks` to point at an explicit directory. A missing default directory is reported as a warning and yields a zero-task report; a missing explicit directory is an error. -`--platform` switches to `platform_readiness`. This mode reuses -`contract/inspect` service metadata, generated freshness, verification commands, -and capability graph facts. Platform upload and release dry-run fields are local -artifact metadata, not network actions. - -`--platform` and `--ai-tasks` are mutually exclusive because they select -different input models. - ## Output Human output is the default. `--json` emits a stable CLI result envelope: @@ -27,7 +19,7 @@ Human output is the default. `--json` emits a stable CLI result envelope: { "result_kind": "nucleus.report_result", "schema_version": "report.v1", - "schema_ref": "contract/schema/report.schema.json", + "schema_ref": "contract/schema/report.v1.schema.json", "ok": true, "mode": "ai_quality", "summary": {}, @@ -35,9 +27,8 @@ Human output is the default. `--json` emits a stable CLI result envelope: } ``` -The report schema lives at `contract/schema/report.schema.json`. Mode-specific -payloads are nested under `ai_quality` or `platform_readiness`; shared rollups -stay in `summary`. +The report schema lives at `contract/schema/report.v1.schema.json`. Quality +payloads are nested under `ai_quality`; shared rollups stay in `summary`. ## AI Quality Inputs @@ -50,17 +41,3 @@ network or provider dependencies. Capability event summaries only count explicit event records. The command does not infer provider behavior from secrets, environment variables, or hidden global state. - -## Platform Readiness - -Platform readiness gates summarize whether local metadata is ready for a -subsequent release or upload step: - -- `platform_upload_payload`: local payload metadata can be emitted. -- `release_dry_run`: local release matrix metadata can be emitted. -- `generated_freshness`: generated targets match their contract sources. -- `verification_commands`: service verification commands are declared. - -Risk gates are advisory. They describe provider SDK scope, control-plane network -scope, and generated freshness risk. Failed gates do not prevent the report from -being emitted; consumers should inspect gate fields before release automation. diff --git a/docs/concepts/serve-command.md b/docs/concepts/serve-command.md index c93d894..b4b7488 100644 --- a/docs/concepts/serve-command.md +++ b/docs/concepts/serve-command.md @@ -40,8 +40,8 @@ Human output is the default. `--json` emits a stable CLI result envelope: ```json { "result_kind": "nucleus.serve_result", - "schema_version": "serve.v1", - "schema_ref": "contract/schema/serve.schema.json", + "schema_version": "serve-result.v1", + "schema_ref": "contract/schema/serve-result.v1.schema.json", "ok": true, "mode": "check", "summary": { diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 2666853..244120d 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -1,89 +1,117 @@ # Implementation Status -This document records what the current checkout actually implements. It is meant -to keep the public positioning honest: Nucleus is an AI-first microservice -kernel, not a production-complete replacement for go-zero, Kratos, go-kit, or a -full middleware platform. +This document records what the current checkout actually implements after the +agent-native redesign work. Nucleus is being narrowed into an AI-facing protocol +layer for Go services, not a project scaffold, provider SDK collection, or +platform control plane. ## Status Key | Status | Meaning | | --- | --- | | Implemented | Code exists, has tests, and is expected to work in the current checkout. | -| Published alpha | Code is available through published alpha module tags without local `replace` directives. | -| Scaffold | The CLI generates service-owned code or metadata, but does not wire a real provider automatically. | -| Fake/static | Useful for local tests, examples, or metadata checks; not a real external provider integration. | -| Metadata-only | Produces inspection or readiness evidence without starting business handlers or provider SDKs. | +| Metadata-only | Produces local inspection or evidence metadata without wiring business handlers or provider SDKs. | +| Removed | The old scaffold/provider/platform behavior has been deleted from the CLI surface. | +| In progress | The redesign document calls for more work before this area is complete. | ## CLI Surface | Command | Current status | Notes | | --- | --- | --- | -| `nucleus init` | Implemented | Generates service, worker, and library templates. The service template is covered by tests that run validation, strict lint, and `go test ./...`. | -| `nucleus validate` | Implemented | Validates `nucleus.yaml`, `api/openapi.yaml`, `api/errors.yaml`, and lightweight proto facts. | -| `nucleus gen` | Implemented | Generates contract metadata, HTTP route binders, gRPC metadata, error metadata, docs, TypeScript schema exports, and minimal clients. | -| `nucleus lint` | Implemented | Checks architecture boundaries, route registration, capability graph, generated freshness, schema versions, and selected legacy imports. | -| `nucleus verify` | Implemented | Runs validate, strict lint, generated freshness, `go mod tidy`, import, build, and tests as evidence steps. | -| `nucleus describe` | Implemented | Emits manifest, contract, capability, module, edit-surface, freshness, and verification facts. | -| `nucleus plan` | Implemented | Produces task-oriented edit surfaces, commands, and risk notes. Natural-language classification is heuristic. | -| `nucleus apply` | Implemented | Applies plan file edits only after edit-surface, path traversal, and symlink checks. It deliberately does not execute shell commands. | -| `nucleus repair` | Implemented | Supports bounded repair for generated freshness and single explicit patch candidates with expected hashes. It is not a general code-repair engine. | -| `nucleus scenario` | Implemented | Builds OpenAPI/error-derived scenario suggestions and can run explicit HTTP scenario checks when a base URL or handler is provided. | -| `nucleus serve` | Metadata-only | Serves `/healthz`, `/readyz`, and `/.well-known/nucleus.json` metadata. It does not start generated business handlers. | -| `nucleus report` | Metadata-only | Summarizes local AI task quality and platform-readiness metadata without network calls. | -| `nucleus migrate` | Metadata-only | Produces migration checklists and readiness checks. It does not rewrite services. | -| `nucleus capability add` | Scaffold | Updates manifest metadata and generates service-owned component/app/docs scaffolds. Most providers still need explicit bridge wiring by the service. | +| `nucleus adopt` | Implemented | Adds a minimal protocol index to an existing project. Writes only `nucleus.yaml` and `.nucleus/*`; does not generate business code or modify `go.mod/go.sum`. | +| `nucleus init` | Removed | The old service/worker/library template generator was deleted. Adoption is the entry path. | +| `nucleus capability add` | Removed | Provider scaffold, provider defaults, `postgres` special cases, and dependency writes were deleted from the CLI surface. Capability/provider choices must be recorded as decisions. | +| `nucleus describe` | Implemented | Emits structured project facts. A project without `nucleus.yaml` is still describable and receives a `manifest.missing` warning. | +| `nucleus mark` | Implemented | Declares `contract`, `capability`, and `verify` anchors in `nucleus.yaml` only. Capability symbols are written as stable resolved IDs when found, recorded as declared intent when missing, and rejected with candidates when ambiguous. | +| `nucleus trace` | Implemented | MVP supports `trace symbol` over `symbol_graph`, `trace route` over the conservative flow graph, and `trace capability` over manifest capability anchors. Ambiguous short symbol names fail with candidates instead of guessing. | +| `nucleus impact` | Implemented | MVP supports `impact symbol`, `impact file`, and `impact contract`; outputs affected symbols, files, tests, routes, and graph edges with confidence metadata. | +| `nucleus plan` | Implemented | Produces task-oriented edit surfaces, commands, risks, decision-only capability context, read-only recipe candidates, `blocked_decisions` for locked provider/library/driver conflicts, and best-effort `impact_summary` with affected symbols, files, routes, contracts, tests, capabilities, graph edges, and verification commands. It no longer emits provider hints or provider scaffold commands. | +| `nucleus decision validate` | Implemented | Validates `.nucleus/decisions` evidence against `decision.v1`, manifest capability declarations, edit surfaces, verification commands, locked decision hashes, and supersede hashes. It remains read-only. | +| `nucleus decision accept` | Implemented | Explicitly accepts one decision file by setting `status: accepted`, `locked: true`, `accepted_by`, `accepted_at`, and canonical `decision_hash`. Writes only the selected decision file. | +| `nucleus decision supersede` | Implemented | Explicitly fills `supersedes_hash` for one supersede decision from the referenced prior decision. Writes only the selected decision file and does not accept it. | +| `nucleus validate` | Implemented | Validates manifest and contract files when present. | +| `nucleus gen` | Implemented | Generates contract-derived artifacts. It should remain contract-only and must not create application wiring or provider glue. | +| `nucleus lint` | Implemented | Checks protocol and safety rules without binding capabilities to fixed provider/library imports. HTTP remains contract-backed. | +| `nucleus verify` | Implemented | Runs protocol validation, strict lint, decision quality validation, generated freshness, and only the verification commands declared in `nucleus.yaml`. Decision diagnostics are included in the top-level `evidence.v1` output. | +| `nucleus apply` | Implemented | Applies plan file edits only after edit-surface, path traversal, and symlink checks. It deliberately does not execute shell commands and emits `evidence.v1`. | +| `nucleus execute` | Implemented | Executes allowlisted plan commands and emits `evidence.v1`. | +| `nucleus repair` | Implemented | Supports bounded repair for generated freshness and single explicit patch candidates with expected hashes. It emits `evidence.v1` and is not a general code-repair engine. | +| `nucleus scenario` | Implemented | Builds OpenAPI/error-derived scenario suggestions and can run explicit HTTP scenario checks when a base URL or handler is provided. Runnable HTTP checks emit `evidence.v1`. | +| `nucleus serve` | Metadata-only | Serves `/healthz`, `/readyz`, and `/.well-known/nucleus.json` metadata. It can inspect projects without a manifest. | +| `nucleus report` | Metadata-only | Summarizes local AI task quality, decision quality, locked decision drift, and recipe candidate usage. Platform readiness, upload payloads, release dry-runs, and control-plane fields were removed. | +| `nucleus mcp --stdio` | Metadata-only | Serves local stdio MCP tools for agents. Tools expose service description, edit surfaces, contracts, capabilities, trace, impact, symbol lookup, decision validation, reports, plans, and read-only recipes. | +| `nucleus migrate` | Removed | Version migration and compatibility planning was removed because the project is pre-release and should not carry downgrade/compatibility paths. | ## Contract and Manifest Layer | Area | Current status | Notes | | --- | --- | --- | -| Manifest parsing | Implemented | Uses structured YAML parsing and validates required service metadata, capability names, dependencies, and AI edit surfaces. | -| OpenAPI inspection | Implemented | Loads route metadata, parameters, request-body facts, examples, and validation hints from `api/openapi.yaml`. It is a lightweight metadata parser, not a complete OpenAPI toolchain. | -| Proto inspection | Implemented | Extracts package, service, method, streaming, and `google.api.http` facts with a lightweight parser. It is not a replacement for `protoc`. | +| Manifest parsing | Implemented | Runtime structs now use manifest v2 objects for contracts and capabilities. Strict YAML parsing rejects removed `nucleus`, provider, library, driver, and platform fields instead of silently accepting them. | +| No-manifest inspection | Implemented | `describe` infers service name/version from `go.mod` or directory name and emits structured facts instead of failing. | +| Symbol graph | Implemented | `describe` emits `symbol_graph` with stable Go symbol IDs, package/file/type/interface/function/method nodes, calls, implements, test relation edges, and `source/confidence/stale` edge metadata. | +| OpenAPI inspection | Implemented | Loads route metadata, request parameters, request body shapes, examples, and validation hints from `api/openapi.yaml`. | +| Proto inspection | Implemented | Extracts package, service, method, streaming, and `google.api.http` facts with a lightweight parser. | | Error catalog | Implemented | Validates stable error codes, messages, and HTTP status mappings from `api/errors.yaml`. | | Generated freshness | Implemented | Compares contract source hashes with generated target markers declared in `ai.generated`. | -## Runtime Modules +## JSON Result Contracts -| Module | Current status | Notes | -| --- | --- | --- | -| `github.com/nucleuskit/http` | Published alpha | HTTP server, route registration, response envelopes, middleware, clients, CORS, SSE, static assets, and well-known metadata are implemented and tested. | -| `github.com/nucleuskit/grpc` | Published alpha | gRPC server/client wrappers, discovery resolver support, health/reflection options, and interceptor chains are implemented and tested. | -| `github.com/nucleuskit/worker` | Published alpha | Cron, interval, batch, consumer, stream, map-reduce, timing-wheel, hooks, and manager primitives are implemented and tested. | +Core CLI JSON outputs are being converged on `result_kind`, `schema_version`, +`schema_ref`, `ok`, and `diagnostics`. Result schemas are materialized for +adoption, marking, description, validation, linting, generation, scenarios, +planning, decisions, tracing, impact analysis, serving, reporting, MCP +structured results, recipe result views, and execution evidence. `plan` fails +`ok` when top-level diagnostics contain errors, not only when edit or decision +blockers are present. -The runtime modules use canonical imports such as `github.com/nucleuskit/cap` -and `github.com/nucleuskit/core`, and the current alpha tags are verified -without local replacements. +## Capabilities And Providers -## Capabilities and Bridges +Capabilities are semantic anchors. They are not provider implementations. -| Area | Current status | Notes | +Provider, library, driver, ORM, SDK, queue, database, and observability choices +belong in structured decision evidence under `.nucleus/decisions`, not in +`nucleus.yaml` and not in Go hard-coded defaults. + +Capability kind suggestions are stored as data in +`cmd/nucleus/internal/capvocab/capability-kinds.v1.json`. That vocabulary is +advisory only: it contains names, aliases, descriptions, and planning flags, but +no providers, drivers, libraries, default choices, DSNs, or dependency metadata. + +Recipe knowledge is loaded from project-local `.nucleus/recipes/*.yaml`, +`.yml`, or `.json` files plus a small built-in read-only recipe set. Project +recipes override built-ins with the same id. Recipes are strict, read-only +knowledge documents: unknown fields such as executable `commands` fail +validation, and recipe data is not allowed to write files, modify dependencies, +or become an accepted decision. `plan` exposes safe matches as +`context.recipe_candidates` with `source` and a `candidate_only` selection +policy. Recipe suggestions do not modify plan commands, generated outputs, +provider decisions, dependencies, or locked decision state. + +## Runtime Modules + +The runtime modules remain separate Go modules. They should be treated as +optional user/project dependencies, not as dependencies introduced by `adopt` or +capability commands. + +| Module | Current status | Notes | | --- | --- | --- | -| `github.com/nucleuskit/cap` | Implemented | Small capability interfaces, options, no-op implementations, and tests exist for auth, config, discovery, health, HTTP client, KV, lock, log, metric, Mongo, MQ, profiler, Redis, Sentinel, SQL, store, trace, and transport. | -| In-memory/test bridges | Fake/static | `memory`, `memorylock`, `redis`, `kv`, and several health/config helpers are useful for tests and local examples. They should not be presented as external managed services. | -| Real provider bridges | Implemented | `goredis`, `sarama`, `sql`, `gorm`, `zap`, `otel`, `prometheus`, `redislock`, and `nacosofficial` contain real provider-facing adapter code and tests. | -| Static provider bridges | Fake/static | `nacos` provides local/static config and discovery behavior. It is not the official Nacos SDK integration. | -| Provider scaffolds | Scaffold | `capability add` records provider intent and generates service-owned compile-safe code. Except for the focused PostgreSQL scaffold, it does not automatically import or wire provider SDKs. | +| `github.com/nucleuskit/http` | Existing runtime module | HTTP server, route registration, response envelopes, middleware, clients, CORS, SSE, static assets, and well-known metadata exist outside the protocol CLI. | +| `github.com/nucleuskit/grpc` | Existing runtime module | gRPC server/client wrappers, discovery resolver support, health/reflection options, and interceptor chains exist outside the protocol CLI. | +| `github.com/nucleuskit/worker` | Existing runtime module | Worker primitives exist outside the protocol CLI. | -## Examples +## Adoption Guidance -The checked-in `examples/service`, `examples/worker`, and `examples/library` -directories describe templates rather than hosting full generated services. To -inspect a runnable service today, generate one with: +Use `nucleus adopt --dir . --json` inside an existing Go project to add the +minimal protocol index. Then use: ```bash -nucleus init --template service --name demo-api --module example.com/demo-api --dir ./demo-api -cd ./demo-api -nucleus validate --dir . -nucleus lint --dir . --strict +nucleus mark capability order_store --kind relational_store --symbol OrderStore --json +nucleus describe --dir . --json --flow +nucleus plan --dir . --task "" --json +nucleus mcp --stdio nucleus verify --dir . --json -go test ./... ``` -## Adoption Guidance - -Use the current checkout as an AI-safe contract, manifest, generation, and -evidence loop. Treat it as a production framework replacement only after more -real services have validated the runtime modules, bridge modules, examples, and -upgrade path under production-like constraints. +Do not use Nucleus to generate a project layout, select providers, or wire +third-party SDKs. Those choices belong to the user project and must be captured +as explicit decision evidence. diff --git a/docs/plans/2026-06-13-complete-validate-command.md b/docs/plans/2026-06-13-complete-validate-command.md index 317e76b..4d34097 100644 --- a/docs/plans/2026-06-13-complete-validate-command.md +++ b/docs/plans/2026-06-13-complete-validate-command.md @@ -553,7 +553,7 @@ Run: ```bash tmp=$(mktemp -d) -printf 'schema_version: "1.0"\nservice:\n name: demo\n version: "0.1.0"\n' > "$tmp/nucleus.yaml" +printf 'schema_version: "2.0"\nservice:\n name: demo\n version: "0.1.0"\nai:\n intent: validate command smoke test\n' > "$tmp/nucleus.yaml" mkdir -p "$tmp/api" printf 'openapi: [\n' > "$tmp/api/openapi.yaml" rtk go run ./cmd/nucleus validate --dir "$tmp" @@ -620,7 +620,7 @@ git commit -m "docs: align describe validation contract" **Files:** - Modify: `README.md` - Modify: `CONTRIBUTING.md` -- Optionally modify: `docs/concepts/ai-first-microservice-kernel.md` +- Optionally modify: `docs/concepts/agent-native-protocol-layer.md` **Step 1: Add concise validate semantics** @@ -706,4 +706,3 @@ git log --oneline -n 12 ``` Expected: only intended validate-related changes remain. - diff --git a/docs/plans/2026-06-17-scenario-command-implementation.md b/docs/plans/2026-06-17-scenario-command-implementation.md index 5ede591..910a5f2 100644 --- a/docs/plans/2026-06-17-scenario-command-implementation.md +++ b/docs/plans/2026-06-17-scenario-command-implementation.md @@ -59,7 +59,7 @@ ### Task 4: Integrate Evidence and Documentation **Files:** -- Modify: `contract/schema/evidence.schema.json` +- Modify: `contract/schema/evidence.v1.schema.json` - Modify: `cmd/nucleus/internal/plan/constants.go` - Modify: `cmd/nucleus/internal/plan/executable.go` - Modify: `cmd/nucleus/internal/describe/constants.go` diff --git a/docs/plans/2026-07-03-agent-native-implementation-sync.md b/docs/plans/2026-07-03-agent-native-implementation-sync.md new file mode 100644 index 0000000..82e6fb9 --- /dev/null +++ b/docs/plans/2026-07-03-agent-native-implementation-sync.md @@ -0,0 +1,337 @@ +# Agent-Native Nucleus Implementation Sync + +This tracker follows [2026-07-03-agent-native-nucleus-redesign.md](2026-07-03-agent-native-nucleus-redesign.md). + +## Current Focus + +Implement the redesign in breaking-change order. The project is pre-release, so incompatible scaffold, provider, bridge, and platform paths should be removed or rewritten instead of preserved. + +## Status + +### Completed + +- [x] Added ADR 0002 for the agent-native protocol-layer direction. +- [x] Updated ADR 0001 to remove scaffold, bridge, provider SDK, and platform ownership from project scope. +- [x] Added protocol schema files: + - `contract/schema/manifest.v2.schema.json` + - `contract/schema/decision.v1.schema.json` + - `contract/schema/recipe.v1.schema.json` + - `contract/schema/graph.v1.schema.json` + - `contract/schema/mark-result.v1.schema.json` + - `contract/schema/adopt-result.v1.schema.json` + - `contract/schema/mcp-result.v1.schema.json` + - `contract/schema/recipe-result.v1.schema.json` + - `contract/schema/decision-result.v1.schema.json` + - `contract/schema/trace-result.v1.schema.json` + - `contract/schema/impact-result.v1.schema.json` + - `contract/schema/serve-result.v1.schema.json` + - `contract/schema/report.v1.schema.json` + - `contract/schema/describe-result.v1.schema.json` + - `contract/schema/validate-result.v1.schema.json` + - `contract/schema/lint-result.v1.schema.json` + - `contract/schema/gen-result.v1.schema.json` + - `contract/schema/scenario-result.v1.schema.json` + - `contract/schema/plan-result.v1.schema.json` + - `contract/schema/plan-executable.v1.schema.json` + - `contract/schema/diagnostic.v1.schema.json` + - `contract/schema/evidence.v1.schema.json` +- [x] Updated README positioning from old kernel/scaffold/platform language to agent-native protocol-layer language. +- [x] Restored the root CLI compile path by adding `contract/openapi.RequestShape`, `LoadRequestShapes`, and request-body/schema resolution tests. +- [x] Changed `cmd/nucleus/main.go` to print command errors to stderr instead of returning a silent `exit status 1`. +- [x] Changed `inspect.Describe` so a Go project without `nucleus.yaml` still returns structured inferred facts plus a `manifest.missing` warning diagnostic. +- [x] Tightened default edit surfaces to protocol files only: `nucleus.yaml`, `.nucleus/**`, `api/**`, and `docs/**`; `go.mod`, `go.sum`, and fixed `internal/**` paths are no longer allowed by default. +- [x] Removed provider/module decisions from capability planning: + - no `provider_hint` + - no `capability add --provider` command + - no fixed `github.com/nucleuskit/cap/...` module mapping in plan context + - provider/library/driver selection is represented as decision evidence only +- [x] Removed `inspect.CapabilityModule()` and changed strict capability lint so it no longer binds capabilities to fixed provider/library imports. HTTP remains contract-backed and is still checked against OpenAPI/generated route evidence. +- [x] Added regression tests for decision-only capability planning and no-manifest describe behavior. +- [x] Added `cmd/nucleus/internal/adopt` and root `nucleus adopt` command. + - writes only `nucleus.yaml` and `.nucleus/*` + - emits `nucleus.adopt_result` + - detects `go.mod`, package summaries, contract candidates, test command candidates, generated file candidates, and symbol counts + - does not execute commands, access the network, generate business code, or modify `go.mod/go.sum` +- [x] Deleted the old `cmd/nucleus/internal/initcmd` template generator and removed `nucleus init` from the root command surface. +- [x] Deleted the old `cmd/nucleus/internal/capability` provider scaffold command and removed `nucleus capability add` from the root command surface. +- [x] Removed `report --platform`, platform readiness report structures, upload/release dry-run fields, provider strategy report fields, and platform mapping documentation. +- [x] Deleted obsolete template and local platform examples: + - removed `examples/service`, `examples/worker`, and `examples/library` README entries that documented `nucleus init --template` + - removed `examples/deploy/docker` local stack files for Nacos, Redis, Kafka, OTel, Jaeger, and Prometheus +- [x] Updated serve tests for the no-manifest inspection model. +- [x] Updated implementation status documentation to reflect the current agent-native CLI surface. +- [x] Replaced the old Go `capcatalog` provider/default-provider catalog with data vocabulary: + - added `cmd/nucleus/internal/capvocab/capability-kinds.v1.json` + - added `capvocab.MatchTask` + - removed `cmd/nucleus/internal/capcatalog` + - vocabulary contains capability kind names/aliases/descriptions only, with no provider/library/driver/default metadata + - fixed the old natural-language false positive where `catalog` matched `log` +- [x] Rewrote runtime manifest parsing to the v2 protocol model: + - `contract/manifest.Manifest` now uses `contracts`, capability objects, `ai`, `verify`, and `dependencies` + - removed the old `Nucleus` config block and platform/provider fields from the Go manifest type + - `manifest.Load` now uses strict YAML field decoding, so removed fields such as `nucleus`, `platform_url`, `providers`, `provider`, `library`, and `driver` fail instead of being ignored + - lint, validation, and describe now read capability `kind`/`id` from v2 capability objects instead of v1 string capabilities + - manifest schema now matches runtime requirements for `service.name` and `service.version`, while `ai.allowed_changes` remains optional because CLI defaults provide protocol edit boundaries +- [x] Added `nucleus decision validate`: + - validates explicit files, directories, or default `.nucleus/decisions` + - emits `nucleus.decision_validate_result` + - checks `decision.v1` shape, manifest capability existence, provider/library/driver evidence, impact file edit surfaces, verification commands, locked `decision_hash`, and `supersedes_hash` + - computes canonical `sha256:` hashes from JSON payloads excluding `accepted_at` + - does not create locked decisions, accept decisions, write files, execute commands, or modify `go.mod/go.sum` +- [x] Added `describe` symbol graph MVP in `contract/inspect`: + - emits `symbol_graph` with `graph.v1` + - defines stable Go symbol IDs such as `go:///#` and `go:///#.` + - outputs package, file, type, interface, function, method, const, and var nodes + - outputs `contains`, `imports`, `declares`, `calls`, `implements`, `accepts`, `returns`, and heuristic `tests` edges + - every edge carries `source`, `confidence`, and `stale` + - coverage includes node, edge, file, package, and edge-kind counts +- [x] Added `nucleus trace` and `nucleus impact` MVP: + - `trace symbol` resolves stable symbol IDs or unambiguous short names and returns callers/callees over `symbol_graph` + - `trace route` returns reachable flow graph nodes and edges for a route query + - `trace capability` reads manifest capability anchors and returns callers/callees for resolved symbols + - `impact symbol` returns affected symbols, files, tests, and graph edges + - `impact file` expands from file declarations to affected symbols/files/tests + - `impact contract` returns affected flow graph routes and edges for contract changes + - ambiguous short symbol names return candidate symbols instead of guessing +- [x] Added `nucleus mark`: + - supports `mark contract --kind --path ` + - supports `mark capability --kind --symbol ` without provider/library/driver fields + - writes resolved capability symbols as stable `go://...` IDs when found + - writes missing symbols as declared intent instead of generating implementation code + - fails ambiguous short symbol names with candidates and does not write a partial manifest + - supports `mark verify ""` for project-owned verification commands + - writes only `nucleus.yaml` and does not modify `go.mod/go.sum` +- [x] Added anti-capability regression gates: + - `mark capability` accepts unknown capability kinds without enum validation + - `mark capability` does not write provider/library/driver fields + - `mark capability` does not modify `go.mod` or create `go.sum` + - `decision validate` accepts unknown provider/library/driver choices as structured evidence + - `report --platform` is rejected because platform readiness is no longer a report mode +- [x] Added plan impact summary over symbol/flow graph facts: + - default and executable plan outputs include `impact_summary` + - summary covers affected symbols, files, routes, contracts, tests, capabilities, graph edges, and suggested verification commands + - symbol impact uses direct `calls`, `tests`, `implements`, `accepts`, and `returns` edges + - route impact maps matched task text back to concrete OpenAPI routes and contract files + - capability impact can be inferred from manifest capability anchors and matched symbols + - missing direct graph matches emit a warning instead of inventing affected code +- [x] Added local stdio MCP tools for agents: + - root command exposes `nucleus mcp --stdio` + - supports JSON-RPC `initialize`, `ping`, `tools/list`, and `tools/call` with Content-Length framing + - tool results include both MCP text content and `structuredContent` + - tools cover service description, edit surfaces, contracts, capabilities, trace, impact, symbol lookup, callers/callees, decision validation, reports, plans, and recipes + - recipe tools read project-local `.nucleus/recipes/*.yaml`, `.yml`, `.json`, and built-in read-only recipes as strict knowledge; unknown executable fields fail validation + - MCP does not write files, execute verification commands, access the network, choose providers, or scaffold implementation code +- [x] Fed safe recipe matches into `plan` as candidate suggestions: + - moved strict recipe parsing into shared `cmd/nucleus/internal/recipe` + - `plan` now emits `context.recipe_candidates`, `context.recipe_diagnostics`, and `context.recipe_policy` + - candidates are selected only by kind/task/detect matches and carry `selection: candidate_only` + - recipe suggestions do not modify `commands`, `generated_outputs`, provider decisions, `go.mod/go.sum`, or accepted/locked decision state + - invalid recipes are ignored as candidates, surfaced in `recipe_diagnostics`, and add a plan risk +- [x] Added built-in read-only recipe loading with project override precedence: + - built-in recipes are embedded as data-only YAML under `cmd/nucleus/internal/recipe/builtin` + - project-local recipes with the same id override built-in recipes + - recipe summaries and candidates include `source: project|builtin` + - the initial built-in recipe is provider-neutral (`sql-port-boundary`) and does not encode ORM, driver, DSN, dependency, command, or file-write defaults + - plan candidates remain `candidate_only` and still require decision evidence before provider/library/driver implementation +- [x] Removed the legacy platform report schema split: + - `nucleus report` now emits `schema_ref: contract/schema/report.v1.schema.json` + - deleted the legacy platform report schema file that still described `platform_readiness`, upload payloads, and release dry-runs + - `contract/schema/report_schema_test.go` now asserts that local quality report schema has only `ai_quality` mode and no platform/control-plane defs +- [x] Aligned `verify` with manifest-declared verification: + - `verify` now runs protocol validation, strict lint, decision quality validation, and generated freshness before project checks + - project checks come only from `nucleus.yaml` `verify.commands`; no implicit `go mod tidy`, `go list`, `go test -run ^$`, or `go test ./...` steps are added + - declared project commands are emitted as `verify_command` evidence steps with sanitized output and a bounded timeout + - tests cover declared command execution and the absence of implicit tidy/module mutation +- [x] Unified execution evidence on `contract/schema/evidence.v1.schema.json`: + - deleted the legacy split evidence schema file + - `verify`, `apply`, `execute`, `repair`, and runnable HTTP `scenario` evidence now emit `result_kind`, `schema_version: evidence.v1`, `schema_ref`, `ok`, `steps`, and `diagnostics` + - evidence steps now use `ok` plus schema-valid status values; scenario assertion results keep assertion-local `pass` + - `describe.verification` now documents only the protocol verification chain and points project checks to `nucleus.yaml` `verify.commands` +- [x] Added result schemas for non-evidence CLI JSON outputs: + - `describe`, `validate`, `lint`, `gen`, scenario plan/drafts, plan, and executable plan now emit concrete `schema_ref` values backed by schema files + - `lint`, scenario plan/drafts, plan, and executable plan now include top-level `diagnostics` + - plan `ok` now also fails when top-level diagnostics contain errors + - removed the root `--schema` override because agent-facing schema versions must be stable +- [x] Added explicit locked-decision accept/supersede flow: + - `nucleus decision accept ` writes `status: accepted`, `locked: true`, `accepted_by`, `accepted_at`, and canonical `decision_hash` + - `nucleus decision supersede ` resolves `supersedes` from `.nucleus/decisions` and writes `supersedes_hash` + - `decision validate` remains read-only + - `plan` now emits `blocked_decisions` and `ok: false` when a task attempts provider/library/driver replacement covered by a locked decision without a valid supersede decision + - once a valid supersede decision exists, the same provider replacement task is no longer blocked by the locked original decision +- [x] Tightened report/verify summaries around decision quality, locked decision drift, and recipe candidate usage: + - exported a compact `decision.QualitySummary` for CLI/report/verify reuse + - `verify` now includes a `decision` evidence step between strict lint and generated freshness + - decision diagnostics are merged into verify top-level diagnostics and fail verify when hashes, supersedes hashes, or decision evidence are invalid + - `report` now emits `ai_quality.decision_quality`, `ai_quality.recipe_candidate_usage`, and summary counters for decision files, locked decisions, drift, and recipe candidate usage + - report schema now describes the current `mode`, `ai_quality`, decision quality, and recipe candidate usage output shape +- [x] Audited MCP and recipe structured outputs: + - added `mcp-result.v1` and `recipe-result.v1` schema files + - `get_service_description` now reuses the canonical `describe-result.v1` output + - `get_edit_surfaces`, `get_contracts`, `get_capabilities`, `find_symbol`, `list_callers`, and `list_callees` now emit `mcp-result.v1` + - `list_recipes`, `get_recipe`, and recipe candidates now emit `recipe-result.v1` + - MCP tests assert agent-facing envelope fields on the common structuredContent tools +- [x] Removed the legacy `migrate` command: + - deleted `cmd/nucleus/internal/migrate` + - removed root command wiring and root migrate example tests + - deleted the migrate concept doc and migrate schema + - migration/compatibility planning is out of scope while the project is pre-release +- [x] Added result schemas for remaining agent-facing CLI outputs: + - `decision validate`, `decision accept`, and `decision supersede` now emit `decision-result.v1` + - `trace` now emits `trace-result.v1` instead of pointing at graph data schema + - `impact` now emits `impact-result.v1` instead of pointing at graph data schema + - `serve` now emits `serve-result.v1` +- [x] Cleaned final documentation and inspect leftovers: + - renamed `docs/concepts/ai-first-microservice-kernel.md` to `docs/concepts/agent-native-protocol-layer.md` + - updated the concept page from kernel language to protocol-layer language + - removed the old `github.com/nucleuskit/cap/httpclient` flow-graph special case + - removed unused capability graph provider/module fields and stale inspect constants +- [x] Removed `bridge` from the core workspace boundary: + - deleted the `bridge` submodule from `.gitmodules` and the worktree + - removed bridge-specific lint/import rules + - removed `bridge` as a capability planning keyword + - `git submodule status` no longer lists `bridge` + +### In Progress + +- [ ] Audit the remaining redesign completion criteria against the current worktree and close any gaps in docs, tests, or CLI behavior. + +### Next Tasks + +- [ ] Audit README, agent skill docs, and examples against the final redesign acceptance criteria. + +## Verification Notes + +- `rtk node -e ... schema/*.schema.json` in `contract`: passed earlier in this implementation slice. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in `contract`: passed. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/plan`: passed. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/plan ./cmd/nucleus/internal/describe ./cmd/nucleus/internal/root`: passed before `capcatalog` deletion. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/capvocab ./cmd/nucleus/internal/plan ./cmd/nucleus/internal/root`: passed after replacing `capcatalog`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/scenario`: passed when run outside the sandbox because `httptest` needs to bind a local port. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus describe --dir . --json --pretty`: passed and now returns inferred service facts plus `manifest.missing` warning when no `nucleus.yaml` exists. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus plan --dir . --task "add mysql capability" --json --pretty`: passed and emits decision-only capability context with no provider hint, no provider scaffold command, and no default `go.mod/go.sum` writes. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in `contract`: passed. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --help`: passed and lists `adopt`; it no longer lists `init` or `capability`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus plan --dir . --task "move capability kind suggestions from Go catalog to vocab data" --json --pretty`: passed and no longer false-matches `log` from `catalog`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus plan --dir . --task "接入消息队列并增加指标" --json --pretty`: passed and matches `mq` plus `metric` from vocabulary aliases. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after manifest v2 runtime rewrite. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after manifest v2 runtime rewrite. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus plan --dir . --task "接入消息队列并增加指标" --json --pretty`: passed after manifest v2 runtime rewrite; requested capabilities still come from data vocabulary and remain decision-only. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/decision ./cmd/nucleus/internal/root ./cmd/nucleus/internal/plan`: passed after adding `decision validate`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --help`: passed and lists `decision`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" decision validate --json --pretty`: passed against a temporary adopted project with `.nucleus/decisions/order-store.yaml`. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./inspect`: passed after adding symbol graph tests. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" describe --json`: passed against a temporary Go module and emitted `symbol_graph` with direct `calls` and inferred `implements` edges. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/root ./cmd/nucleus/internal/trace ./cmd/nucleus/internal/impact ./cmd/nucleus/internal/plan`: passed after adding trace and impact. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" trace symbol CreateOrder --json --pretty`: passed against a temporary Go module and returned callers/callees with direct call edges. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" impact symbol CreateOrder --json --pretty`: passed against a temporary Go module and returned affected symbols, files, tests, and graph edges. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --help`: passed and lists `trace` plus `impact`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after the trace/impact implementation and documentation sync. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after the trace/impact implementation and documentation sync. +- `rtk node -e ... schema/*.schema.json`: passed after the trace/impact implementation and documentation sync. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/mark ./cmd/nucleus/internal/root`: passed after adding `mark`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" mark contract http --kind openapi --path api/openapi.yaml --json --pretty`: passed against a temporary Go module and wrote only a manifest contract declaration. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" mark capability order_store --kind relational_store --symbol OrderStore --json --pretty`: passed against a temporary Go module and wrote a resolved stable symbol ID. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" mark verify "go test ./..." --json --pretty`: passed against a temporary Go module and appended a project-owned verification command. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --help`: passed and lists `mark`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/mark ./cmd/nucleus/internal/decision ./cmd/nucleus/internal/report ./cmd/nucleus/internal/plan ./cmd/nucleus/internal/root`: passed after adding anti-capability regression gates. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/trace ./cmd/nucleus/internal/root`: passed after adding `trace capability`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" trace capability order_flow --json --pretty`: passed after `mark capability` wrote a resolved anchor and returned callers/callees. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after adding `mark`, anti-capability gates, and `trace capability`. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after adding `mark`, anti-capability gates, and `trace capability`. +- `rtk node -e ... schema/*.schema.json`: passed after adding `mark-result.v1.schema.json`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/plan`: passed after adding plan `impact_summary`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" plan --task "add order status filter to ListOrders" --json --pretty`: passed against a temporary Go module and returned affected symbol, route, test, capability, contract, and graph edge facts. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --dir "$tmp" plan --task "change ListOrders" --json --executable --pretty`: passed and retained `impact_summary` in executable plan output. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after adding plan `impact_summary`. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after adding plan `impact_summary`. +- `rtk node -e ... schema/*.schema.json`: passed after adding plan `impact_summary`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/mcp ./cmd/nucleus/internal/root ./cmd/nucleus/internal/trace ./cmd/nucleus/internal/impact ./cmd/nucleus/internal/decision ./cmd/nucleus/internal/report`: passed after adding MCP tools. +- `rtk node -e ... go run ./cmd/nucleus --dir . mcp --stdio`: passed and returned 19 MCP tools, including `build_plan`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after MCP and documentation sync. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after MCP and documentation sync. +- `rtk node -e ... schema/*.schema.json`: passed after MCP and documentation sync. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/recipe ./cmd/nucleus/internal/plan ./cmd/nucleus/internal/mcp`: passed after plan recipe candidates. +- `rtk sh -c ... go run ./cmd/nucleus --dir "$tmp" plan --task "add mysql capability" --json --pretty`: passed with one safe `gorm-sql` candidate, one unsafe recipe diagnostic, and no recipe command leakage. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/repair`: passed after making the repair fixture's runtime module replaces tidy-stable and local-only. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after plan recipe candidates and repair fixture cleanup. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after plan recipe candidates. +- `rtk node -e ... schema/*.schema.json`: passed after plan recipe candidates. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/decision ./cmd/nucleus/internal/plan ./cmd/nucleus/internal/root`: passed after adding `decision accept`, `decision supersede`, and plan `blocked_decisions`. +- `rtk sh -c ... decision accept ... plan ... decision supersede ... plan`: passed against a temporary service; locked provider replacement was blocked before supersede and unblocked after `supersedes_hash` was written. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after locked decision flow and submodule status check. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after locked decision flow and submodule status check. +- `rtk node -e ... schema/*.schema.json`: passed after locked decision flow. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/decision ./cmd/nucleus/internal/verify ./cmd/nucleus/internal/report`: passed after decision quality and recipe candidate report summaries. +- `rtk node -e ... contract/schema/report.v1.schema.json contract/schema/evidence.v1.schema.json`: passed after report schema sync. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after decision quality/report/verify summary sync. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after decision quality/report/verify summary sync. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus report --dir . --json --pretty`: passed and emitted decision quality plus recipe candidate usage. It reports the default missing AI task directory as a warning. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus verify --dir . --json --pretty`: failed as expected for the repository root because there is no root `nucleus.yaml`; the JSON stopped at the existing validate step before decision validation. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/recipe ./cmd/nucleus/internal/plan ./cmd/nucleus/internal/mcp`: passed after built-in recipe loading and project override precedence. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus plan --dir . --task "add mysql capability" --json --pretty`: passed and emitted one provider-neutral built-in `sql-port-boundary` candidate with `source: builtin`; commands and generated outputs did not include recipe suggestions. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after built-in recipe loading. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after built-in recipe loading. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus verify --dir . --json --pretty`: failed as expected for the repository root because there is no root `nucleus.yaml`; the JSON stopped at `validate` with `manifest.read_failed`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/report ./cmd/nucleus/internal/root`: passed after switching report CLI to `report.v1.schema.json`. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./schema`: passed after deleting the old platform report schema. +- `rtk node -e ... contract/schema/report.v1.schema.json`: passed after report schema cleanup. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus report --dir . --json --pretty`: passed and emitted `schema_ref: contract/schema/report.v1.schema.json`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after report schema cleanup. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after report schema cleanup. +- `rtk node -e ... report.v1/recipe.v1/evidence.v1 schemas`: passed after report schema cleanup. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/verify ./cmd/nucleus/internal/root`: passed after manifest-declared verify command execution. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...` in the root module: passed after manifest-declared verify command execution. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...` in `contract`: passed after manifest-declared verify command execution. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus verify --dir . --json --pretty`: failed as expected for the repository root because there is no root `nucleus.yaml`; the JSON stopped at `validate` with `manifest.read_failed`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/verify ./cmd/nucleus/internal/describe ./cmd/nucleus/internal/apply ./cmd/nucleus/internal/execute ./cmd/nucleus/internal/scenario ./cmd/nucleus/internal/repair ./cmd/nucleus/internal/report ./cmd/nucleus/internal/plan ./cmd/nucleus/internal/root`: passed after unifying evidence on `evidence.v1`. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./schema`: passed after deleting the legacy split evidence schema. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...`: passed after evidence schema cleanup. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...`: passed after evidence schema cleanup. +- `rtk node -e 'const fs=require("fs"); for (const f of fs.readdirSync("schema").filter(f=>f.endsWith(".schema.json"))) JSON.parse(fs.readFileSync("schema/"+f,"utf8")); console.log("schema json ok")'` in `contract`: passed after evidence schema cleanup. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/validate ./cmd/nucleus/internal/lint ./cmd/nucleus/internal/gen ./cmd/nucleus/internal/scenario ./cmd/nucleus/internal/plan ./cmd/nucleus/internal/root`: passed after adding non-evidence CLI result schemas. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./schema`: passed after adding non-evidence CLI result schemas. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...`: passed after non-evidence CLI result schema cleanup. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...`: passed after non-evidence CLI result schema cleanup. +- `rtk node -e ... schema/*.schema.json` in `contract`: passed after non-evidence CLI result schema cleanup. +- `rtk rg -n -- "--schema|SchemaOverride|defaultSchemaVersion|schemaVersion\\(" cmd docs README.md examples`: passed; only the sync note about removing the root `--schema` override remains. +- `rtk node -e ... schema_ref existence scan`: passed with zero missing schema files for code/docs string references. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus --help`: passed and no longer lists the removed root `--schema` override. +- `rtk node -e ... describe/validate/plan/verify envelope sample`: passed against the repository root. `describe` emitted `nucleus.describe_result`, `describe-result.v1`, `contract/schema/describe-result.v1.schema.json`, `ok: true`, and a `manifest.missing` warning. `validate` emitted `nucleus.validate_result`, `validate-result.v1`, `contract/schema/validate-result.v1.schema.json`, and failed as expected with `manifest.read_failed`. `plan` emitted `nucleus.plan_result`, `plan-result.v1`, `contract/schema/plan-result.v1.schema.json`, `recipe_candidates`, and `impact_summary`, but `ok: false` because the root is not adopted and decision validation reports `decision.manifest_read_failed`. `verify` emitted `nucleus.verify_result`, `evidence.v1`, `contract/schema/evidence.v1.schema.json`, and failed as expected with a single `validate` step. +- `rtk node -e ... temporary adopted project envelope sample`: passed. `adopt` emitted `nucleus.adopt_result`, `adopt-result.v1`, `contract/schema/adopt-result.v1.schema.json`, `ok: true`; `plan` emitted `nucleus.plan_result`, `plan-result.v1`, `contract/schema/plan-result.v1.schema.json`, `ok: true`, `recipe_candidates`, and `impact_summary`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus verify --dir . --json --pretty`: failed as expected for the repository root because there is no root `nucleus.yaml`; the JSON now emits `schema_version: evidence.v1`, `schema_ref: contract/schema/evidence.v1.schema.json`, and a failed `validate` step with `kind` plus `ok: false`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/mcp ./cmd/nucleus/internal/recipe ./cmd/nucleus/internal/describe ./cmd/nucleus/internal/plan`: passed after MCP and recipe result envelope sync. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./schema`: passed after adding `mcp-result.v1` and `recipe-result.v1`. +- `rtk node -e ... schema/*.schema.json`: passed after adding `mcp-result.v1` and `recipe-result.v1`. +- `rtk node -e ... go run ./cmd/nucleus --dir . mcp --stdio`: passed a real `tools/call` frame for `get_edit_surfaces`; `structuredContent` emitted `nucleus.mcp.edit_surfaces_result`, `mcp-result.v1`, `contract/schema/mcp-result.v1.schema.json`, `ok: true`, and one root no-manifest warning diagnostic. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...`: passed after MCP/recipe envelope sync. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...`: passed after MCP/recipe envelope sync. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go run ./cmd/nucleus verify --dir . --json --pretty`: failed as expected for the repository root because there is no root `nucleus.yaml`; output remains structured `evidence.v1` with failed `validate`. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/decision ./cmd/nucleus/internal/trace ./cmd/nucleus/internal/impact ./cmd/nucleus/internal/serve ./cmd/nucleus/internal/root`: passed after deleting `migrate` and adding decision/trace/impact/serve result schemas. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./schema`: passed after deleting migrate schema and adding decision/trace/impact/serve result schemas. +- `rtk node -e ... decision/trace/impact/serve real CLI sample`: passed against a temporary service. `decision validate` emitted `decision-result.v1`, `trace symbol` emitted `trace-result.v1`, `impact symbol` emitted `impact-result.v1`, `serve --check` emitted `serve-result.v1`, and root `--help` no longer contained `migrate`. +- `rtk node -e ... schema_ref existence scan`: passed with zero missing schema files after deleting old migrate/serve schemas. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...`: passed after deleting `migrate` and adding remaining result schemas. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...`: passed after deleting `migrate` and adding remaining result schemas. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./inspect ./lint ./schema`: passed after removing inspect provider/module/httpclient leftovers. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/describe ./cmd/nucleus/internal/trace ./cmd/nucleus/internal/impact ./cmd/nucleus/internal/plan`: passed after removing inspect provider/module/httpclient leftovers. +- `rtk rg -n ... old schema and command residue`: passed; remaining `migrate` and old concept-file-name hits are deletion/rename records only. +- `rtk node -e ... schema_ref existence scan`: passed with zero missing schema files after final docs cleanup. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...`: passed after final docs and inspect cleanup. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...`: passed after final docs and inspect cleanup. +- `rtk node -e ... schema/*.schema.json`: passed after final docs and inspect cleanup. +- `rtk git submodule status`: passed after deleting the `bridge` submodule mapping and gitlink; remaining submodules are `cap`, `contract`, `core`, and `runtime/*`. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./lint ./inspect ./schema`: passed after removing bridge lint/import special cases. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./cmd/nucleus/internal/plan ./cmd/nucleus/internal/root`: passed after removing `bridge` as a planning keyword. +- `rtk rg -n ... bridge residue`: passed; the only remaining bridge hit is the redesign document's historical removal requirement. +- `rtk node -e ... schema_ref existence scan`: passed with zero missing schema files after bridge removal. +- `rtk env GOCACHE=/private/tmp/nucleus-gocache go test ./...`: passed after bridge removal. +- `rtk env GOCACHE=/private/tmp/nucleus-contract-gocache go test ./...`: passed after bridge removal. +- `rtk node -e ... schema/*.schema.json`: passed after bridge removal. + +## Completion Criteria + +- README, ADRs, schemas, CLI, lint, report, and tests all reflect the agent-native protocol-layer boundary. +- `adopt -> mark -> describe graph -> trace -> impact -> plan -> decision -> verify` works for a no-contract Go project without generating business code or modifying `go.mod/go.sum`. +- Provider/library/driver decisions are accepted only through decision evidence, never through `nucleus.yaml`. +- No scaffold, provider bridge, or platform-readiness paths remain in the core boundary. diff --git a/docs/plans/2026-07-03-agent-native-nucleus-redesign.md b/docs/plans/2026-07-03-agent-native-nucleus-redesign.md new file mode 100644 index 0000000..61174e2 --- /dev/null +++ b/docs/plans/2026-07-03-agent-native-nucleus-redesign.md @@ -0,0 +1,1717 @@ +# Nucleus Agent-Native Redesign + +Implementation tracker: [2026-07-03-agent-native-implementation-sync.md](2026-07-03-agent-native-implementation-sync.md). + +## 背景 + +这份文档记录一次核心方向调整:Nucleus 不应该继续向“微服务平台”或“项目脚手架”演进,而应该成为一套可加入任意 Go 项目的 AI 原生微服务协议层。 + +目标用户不是传统意义上的人类框架使用者,而是 AI agent、CI、代码审查者和少量需要审定 AI 决策的人类维护者。Nucleus 的核心价值不是替项目选择技术栈,也不是生成完整工程目录,而是给 AI 提供结构化事实、编辑边界、调用链、影响面和验证证据。 + +当前项目已经有正确的雏形: + +- `describe -> plan -> lint -> verify` 的 AI-safe loop。 +- `nucleus.yaml` 中的 manifest-first 和 edit surfaces。 +- contract-first 的 OpenAPI、proto、errors。 +- `flow_graph`、`capability_graph`、`generated_freshness` 等机器可读上下文。 +- `apply`、`execute`、`repair` 的证据化执行思路。 + +但当前实现也存在明显偏移: + +- `init --template service` 生成完整项目骨架,容易把 Nucleus 变成脚手架。 +- capability catalog 和 provider hint 中写死大量 provider、库和默认选择。 +- `sql/postgres` 等特例会自动引入驱动、修改 `go.mod/go.sum`、生成 repository 和 migration。 +- 文档中出现 platform readiness、docker platform local 等表述,容易把项目拉向平台化。 +- `describe` 已有图信息,但还不足以支持 AI 快速定位业务逻辑影响链。 + +由于项目尚未正式发布和生产使用,本次改造不设计降级路径、兼容层、legacy 命令或迁移保留。凡是与新定位冲突的能力,直接删除或重写。 + +## 一句话定位 + +Nucleus 是面向 AI agent 的 Go 微服务结构化协议层。 + +它不接管项目结构,不规定业务分层,不内置 provider 实现,不替用户选择 ORM、MQ、配置中心、日志库或监控库。 + +它只负责: + +- 读取任意 Go 项目的结构化事实。 +- 标记 contract、capability、symbol、edit surface、verification command。 +- 构建调用链、依赖链、影响面。 +- 约束 AI 只能在明确边界内修改。 +- 要求 AI 对技术选型留下结构化决策和验证证据。 + +## 设计原则 + +### 1. 框架只固化协议,不固化技术选型 + +允许硬编码: + +- manifest schema。 +- evidence schema。 +- edit surface 判定规则。 +- generated freshness 规则。 +- contract-first 工作流。 +- 文件越界、symlink、secret 泄漏等安全约束。 +- Go AST/import/call graph 等语言级解析规则。 +- CLI JSON envelope 的稳定字段。 + +不允许硬编码: + +- 默认数据库 provider。 +- 默认 ORM。 +- 默认日志库。 +- 默认注册发现组件。 +- 默认监控组件。 +- 默认目录结构。 +- 默认 repository/usecase/domain 分层。 +- 自动引入某个第三方 SDK。 +- 自动生成业务 repository、migration、Dockerfile、Makefile。 + +### 2. 任意 Go 项目可加入 + +Nucleus 的主路径应该是 adoption,而不是 initialization。 + +正确心智: + +```text +已有 Go 代码 + nucleus.yaml + graph/evidence = AI 可安全维护的服务 +``` + +错误心智: + +```text +nucleus init --template service = 创建一个符合 Nucleus 目录约束的新项目 +``` + +文档中应避免把用户项目描述成待改造或待迁移对象。Nucleus 不拥有用户项目结构,只为任意 Go 代码增加 AI 可读的协议索引。 + +### 3. 最小颗粒度是结构化锚点,不是项目模板 + +Nucleus 的最小操作单元应是: + +- 一个 contract。 +- 一个 capability。 +- 一个 interface。 +- 一个 symbol。 +- 一条 call edge。 +- 一个 route。 +- 一个 error code。 +- 一个 edit surface。 +- 一个 verification command。 +- 一份 evidence。 + +不是: + +- 一个完整 service template。 +- 一套 `internal/app`、`internal/domain`、`internal/usecase` 目录。 +- 一个固定 repository 模式。 +- 一个固定 infra provider。 + +### 4. AI 负责判断,框架负责约束和记录 + +AI 可以判断: + +- 当前项目适合用哪个库。 +- 是否新增依赖。 +- 某个 capability 应落在哪个已有 interface 或 package。 +- 哪些测试需要更新。 +- 某个 provider 决策的理由是什么。 + +Nucleus 必须要求 AI 输出: + +- 结构化 decision。 +- 影响面。 +- 涉及 symbols。 +- 涉及 files。 +- required verification。 +- risks。 +- evidence。 + +AI 不应该被 Nucleus 里的 Go 代码用 `providerHint()` 之类逻辑提前替它决定。 + +Capability kind 也不应该强枚举。Nucleus 可以提供建议词表帮助 AI 归类,但校验只能约束字段结构,不能因为未知 kind 失败。 + +### 5. Graph 是核心能力 + +Nucleus 应参考 GitNexus 类项目的知识图思想,但做成 Go 微服务语义图,而不是通用代码浏览器。 + +AI 改代码前需要知道: + +- 当前 route 进入哪个 handler。 +- handler 调用哪些 usecase/function。 +- usecase 依赖哪些 interface。 +- interface 有哪些实现。 +- 这个 symbol 被哪些地方调用。 +- 改一个 error code 会影响哪些 response mapper/test。 +- 改一个 capability 会影响哪些 config、wiring、handler、tests。 + +### 6. 不保留错误抽象 + +本项目尚未正式使用,因此不应该为错误方向付出兼容成本。 + +直接删除: + +- `init --template` 项目生成主流程。 +- `capability add --provider` provider scaffold 语义。 +- provider 默认值和 provider keyword hint。 +- `sql/postgres` 深度实现特例。 +- 自动写入第三方依赖的 capability 行为。 +- platform readiness、platform mapping、platform upload 相关心智。 +- manifest v1 字符串 capability 主模型。 +- manifest 内 provider 决策字段。 + +重写后只保留新协议需要的命令、schema 和检查。 + +## 非目标 + +Nucleus 不做: + +- 微服务控制台。 +- 发布平台。 +- 中间件一站式平台。 +- provider SDK 集合。 +- ORM 抽象大一统。 +- 项目脚手架主入口。 +- 强制目录规范。 +- 自动改造用户项目结构。 +- 自动选择第三方库。 +- 自动生成业务实现。 +- 为已判定错误的模板行为保留入口。 +- 为未发布功能设置历史包袱。 + +## 当前问题清单 + +### 1. `init --template` 过重 + +现状: + +- `cmd/nucleus/internal/initcmd/templates.go` 会生成 service、worker、library 模板。 +- service 模板包含 `cmd`、`internal/app`、`internal/config`、`internal/domain`、`internal/usecase`、`internal/adapter`、`internal/component`、`internal/server`、`deploy`、`Makefile` 等。 + +问题: + +- 对任意已有 Go 项目不友好。 +- 暗示 Nucleus 要规定项目结构。 +- AI 会被模板结构诱导,倾向于新增目录而不是理解现有代码。 + +方向: + +- 删除 `init --template` 代码路径和相关模板。 +- 删除以模板为主路径的 examples 文档。 +- 新增 `nucleus adopt` 作为主路径。 +- `adopt` 只生成最小 manifest 和可选 agent instruction,不生成业务代码。 + +### 2. capability catalog 硬编码过多 + +现状: + +- `cmd/nucleus/internal/capcatalog/catalog.go` 写死能力、provider、默认 provider。 +- `providerHint()` 根据自然语言关键词推断 provider。 +- `CapabilityModule()` 写死 capability 到 module import path 的映射。 + +问题: + +- 框架替 AI 和用户做技术选型。 +- 新 provider 必须改 Nucleus 源码。 +- 对私有库、公司内部库、已有封装不友好。 +- 误导 AI 认为 catalog 中的 provider 都是框架内置能力。 + +方向: + +- capability kind 只能作为外置建议词表,不应由 Go 代码强枚举。 +- provider 只能出现在 decision 中,不能出现在 manifest 中,也不应由 Go 代码枚举。 +- provider recipe 外置为数据文件,可本地扩展。 +- 未知 provider 必须允许存在,只要有 interface、verification 和 evidence。 +- 删除 `providerHint()` 这类替 AI 预设技术选型的逻辑。 + +### 3. `sql/postgres` 特例违反边界 + +现状: + +- `capability add sql --provider postgres` 会修改 `go.mod/go.sum`。 +- 自动引入 `github.com/lib/pq`。 +- 生成 `internal/component/sql/postgres.go`。 +- 生成 `internal/adapter/store/postgres/repository.go`。 +- 生成 `deploy/migrations/...sql`。 + +问题: + +- Nucleus 直接替项目选择数据库 driver。 +- 自动创建 repository 和 migration 属于业务/基础设施实现。 +- 这与“框架只使用抽象接口,具体实现由用户/AI 决定”冲突。 + +方向: + +- 删除所有 provider 特例。 +- 删除 `capability add --provider` 语义。 +- 新 capability 命令只声明能力和接口锚点。 +- 具体 provider implementation 由 AI 在项目上下文中实现,并输出 decision evidence。 + +### 4. platform readiness 心智容易跑偏 + +现状: + +- `report --platform`、`docs/platform-mapping.md`、`examples/deploy/docker` 等内容存在平台化表达。 + +问题: + +- 容易让项目变成平台或控制面。 +- 对任意项目加入不必要的发布平台概念。 + +方向: + +- 删除 `report --platform`。 +- 删除 platform mapping、platform upload、release dry-run 等平台化字段。 +- 如需报告,只保留本地结构质量、AI 决策质量和验证 evidence。 + +### 5. graph 还不够强 + +现状: + +- `contract/inspect` 有 flow graph、imports、source functions、source handlers。 +- 但 plan 输出主要还是 suggested paths、blocked paths、commands、risks。 + +问题: + +- AI 仍需要大量 grep/read 才能理解逻辑链。 +- 不能直接回答“改这个 symbol 影响谁”。 + +方向: + +- 增强 symbol graph、call graph、interface implementation graph、test relation graph。 +- `plan` 输出必须包含 affected symbols、callers、callees、contracts、tests、verification。 + +## 目标架构 + +```text ++-----------------------------+ +| AI Agent / Human Reviewer | ++--------------+--------------+ + | + v ++-----------------------------+ +| Nucleus CLI / MCP Interface | ++--------------+--------------+ + | + v ++-----------------------------+ +| Protocol Layer | +| - manifest schema | +| - evidence schema | +| - decision schema | +| - edit surface policy | ++--------------+--------------+ + | + v ++-----------------------------+ +| Inspection Layer | +| - Go AST | +| - import graph | +| - symbol graph | +| - call graph | +| - interface impl graph | +| - contract graph | +| - capability graph | ++--------------+--------------+ + | + v ++-----------------------------+ +| User Project | +| 任意 Go 目录结构 | +| 任意 provider/library | +| 任意业务分层 | ++-----------------------------+ +``` + +Nucleus 只读取、标记、约束、验证。业务实现和技术栈属于用户项目。 + +## Source of Truth + +Nucleus 必须明确事实源优先级,避免 CLI 输出、manifest 和 AI 判断互相污染。 + +事实源: + +- Contract 文件是外部行为事实源,如 OpenAPI、proto、errors。 +- Go 源码是 symbol、call graph、interface implementation 的事实源。 +- `nucleus.yaml` 是协议索引,只记录 service、contracts、capabilities、symbols、edit surfaces、verification commands。 +- `.nucleus/decisions/*.yaml` 是技术决策事实源,只记录 provider、library、driver、关键依赖选择及其理由。 +- `.nucleus/recipes/*.yaml` 和能力 kind 词表是参考知识,不是事实源。 +- `describe`、`trace`、`impact`、`adopt` 输出是可重建快照,不是长期事实源。 + +冲突处理: + +- manifest 与源码冲突时,`describe` 应输出 stale/unresolved 诊断,不应自动改写。 +- decision 与 manifest capability 冲突时,`decision validate` 失败。 +- recipe 与现有代码事实冲突时,以代码事实为准。 +- graph 推断不确定时必须标注 confidence,不得编造成确定事实。 + +## Schema 文件 + +协议必须实体化为 schema 文件,避免实现时各命令各写一套 JSON。 + +必须新增或重写: + +- `contract/schema/manifest.v2.schema.json` +- `contract/schema/decision.v1.schema.json` +- `contract/schema/recipe.v1.schema.json` +- `contract/schema/graph.v1.schema.json` +- `contract/schema/adopt-result.v1.schema.json` +- `contract/schema/mcp-result.v1.schema.json` +- `contract/schema/recipe-result.v1.schema.json` +- `contract/schema/decision-result.v1.schema.json` +- `contract/schema/trace-result.v1.schema.json` +- `contract/schema/impact-result.v1.schema.json` +- `contract/schema/serve-result.v1.schema.json` +- `contract/schema/report.v1.schema.json` +- `contract/schema/describe-result.v1.schema.json` +- `contract/schema/validate-result.v1.schema.json` +- `contract/schema/lint-result.v1.schema.json` +- `contract/schema/gen-result.v1.schema.json` +- `contract/schema/scenario-result.v1.schema.json` +- `contract/schema/plan-result.v1.schema.json` +- `contract/schema/plan-executable.v1.schema.json` +- `contract/schema/diagnostic.v1.schema.json` +- `contract/schema/evidence.v1.schema.json` + +所有 CLI JSON 输出必须带: + +- `result_kind` +- `schema_version` +- `schema_ref` +- `ok` +- `diagnostics` + +所有 MCP `structuredContent` 也必须带同样的 agent-facing envelope,除非它直接复用某个 CLI result schema。 + +## Diagnostic 规范 + +AI 依赖稳定错误结构。所有命令必须使用统一 diagnostic envelope。 + +示例: + +```json +{ + "severity": "error", + "code": "manifest.symbol_ambiguous", + "path": "nucleus.yaml", + "symbol_id": "go://example.com/order/internal/order#OrderStore", + "message": "symbol name OrderStore matched multiple packages", + "suggested_action": "rerun mark with a fully qualified symbol id" +} +``` + +字段: + +- `severity`: `error | warning | info` +- `code`: 稳定机器码,禁止把自然语言塞进 code。 +- `path`: 相关文件,可为空。 +- `span`: 可选起止行列。 +- `symbol_id`: 可选稳定 symbol ID。 +- `message`: 给人类看的简短说明。 +- `suggested_action`: 给 AI 的下一步动作建议。 + +诊断必须可机器处理,不能只依赖 stderr 文本。 + +## Manifest 设计 + +### 现有问题 + +当前 manifest 偏服务模板: + +- `capabilities` 是字符串列表。 +- `nucleus.providers` 容易表达成框架 provider registry。 +- edit surfaces 与模板目录强相关。 + +### 新 manifest 方向 + +manifest 应成为 AI 协议索引,而不是项目结构说明书。 + +示例: + +```yaml +schema_version: "2.0" + +service: + name: order-api + version: "0.1.0" + +contracts: + - id: http + kind: openapi + path: api/openapi.yaml + - id: errors + kind: errors + path: api/errors.yaml + +capabilities: + - id: order_store + kind: relational_store + intent: "persist and query orders" + symbols: + - OrderStore + +ai: + allowed_changes: + - "**/*.go" + - "api/**" + - "configs/**" + readonly: + - "**/*.gen.go" + - "vendor/**" + forbidden: + - "**/*secret*" + - "**/*.local.yaml" + +verify: + commands: + - "go test ./..." +``` + +### 字段解释 + +`contracts`: + +- 只标记契约文件。 +- 不推导项目结构。 + +`capabilities`: + +- `id` 是服务内稳定标识。 +- `kind` 是建议语义标签,如 `relational_store`、`cache`、`message_bus`、`logger`,但不是强枚举。 +- `symbols` 是代码锚点,可以是 interface、type、function、method 或 package 内符号。 +- manifest 不保存 provider、library、ORM、driver 等技术决策。 + +`ai`: + +- 只定义编辑边界。 +- 不暗示目录规范。 + +`verify`: + +- 用户项目自己定义验证命令。 +- Nucleus 可以建议,但不能替项目硬编码。 + +Manifest 应尽量薄。它不是配置中心,不保存 provider 细节,不保存 AI 的技术判断,只保存可长期稳定引用的协议索引。容易变化的判断放入 `.nucleus/decisions`,容易重建的事实放入 describe/adopt 输出。 + +## Capability 设计 + +### Capability 是语义锚点 + +Capability 不等于 provider,不等于 SDK,不等于目录。 + +Capability 应描述: + +- 这个服务需要什么能力。 +- AI 应通过哪个 interface 或 symbol 使用它。 +- 变更这个能力时要检查哪些调用和验证命令。 + +### Capability kind + +Nucleus 只能提供建议词表,不能把 kind 做成强枚举。原因是能力类型会随项目和时代变化,强枚举会把框架重新拉回硬编码。 + +建议词表应放在数据文件中,而不是写死在 Go 代码里。可选位置: + +- 内置只读 `vocab/capability-kinds.yaml`。 +- 用户项目 `.nucleus/vocab/capability-kinds.yaml`。 + +建议词表示例: + +- `relational_store` +- `document_store` +- `cache` +- `message_bus` +- `http_client` +- `scheduler` +- `logger` +- `tracer` +- `metrics` +- `auth` +- `config` +- `lock` +- `rate_limiter` +- `custom` + +这些 kind 只用于语义分类、搜索、计划提示和报表聚合,不绑定 Go module 或 provider。未知 kind 合法,lint 不得因为未知 kind 失败。用户项目词表可以增加、覆盖或隐藏内置建议。 + +### Provider 决策 + +Provider 决策不应写死在 Nucleus 代码里。 + +Provider 决策也不应写进 manifest。manifest 是稳定索引,decision 是 AI/用户对当前代码事实做出的可审查判断。AI 或用户可以生成 `.nucleus/decisions/*.yaml`: + +```yaml +schema_version: "decision.v1" +id: order-store-provider +capability: order_store +decision: + provider: gorm + library: gorm.io/gorm + status: proposed + locked: false +reason: + - "project already uses gorm.io/gorm in storage package" + - "existing transaction helper accepts *gorm.DB" +impact: + symbols: + - OrderStore + - NewOrderRepository + files: + - internal/order/store.go + - internal/storage/db.go +verification: + commands: + - "go test ./internal/order ./internal/storage" +alternatives: + - provider: database/sql + - provider: xorm +``` + +注意:这里不是框架替项目做预设,而是 AI 对当前代码事实做结构化判断。 + +用户确认后,decision 应进入 locked 状态: + +```yaml +decision: + provider: gorm + library: gorm.io/gorm + status: accepted + locked: true + accepted_by: human + accepted_at: "2026-07-03T00:00:00Z" +``` + +CLI 入口: + +```bash +nucleus decision accept .nucleus/decisions/order-store-provider.yaml \ + --by human \ + --accepted-at 2026-07-03T00:00:00Z \ + --json +``` + +后续 AI 不允许静默替换 locked decision。若确实需要更换 provider 或关键依赖,必须新增 supersede decision: + +```yaml +supersedes: order-store-provider +reason: + - "locked decision is being replaced because ..." +``` + +CLI 入口: + +```bash +nucleus decision supersede .nucleus/decisions/order-store-provider-v2.yaml --json +``` + +Decision hash 规范: + +- hash 使用 canonical JSON 计算。 +- canonical payload 不包含 `accepted_at`、本地绝对路径、格式化空白、diagnostics。 +- canonical payload 必须包含 capability、decision provider/library/driver、reason、impact、verification、alternatives。 +- locked decision 记录 `decision_hash`。 +- supersede decision 记录 `supersedes` 和 `supersedes_hash`。 + +### Capability add 新语义 + +删除的命令形态: + +```bash +nucleus capability add sql --provider postgres +``` + +新语义: + +```bash +nucleus mark capability order_store \ + --kind relational_store \ + --symbol OrderStore +``` + +或: + +```bash +nucleus capability declare order_store --kind relational_store --symbol OrderStore +``` + +CLI 只更新 manifest,不生成 provider implementation,不写 provider decision。 + +## Recipe 设计 + +### 为什么需要 recipe + +Nucleus 不应硬编码 provider,但 AI 需要参考资料。 + +解决方式是 recipe 外置: + +- 内置仓库可以提供少量通用 recipe。 +- 用户项目可以在 `.nucleus/recipes` 放私有 recipe。 +- AI 可以根据 recipe 生成 decision,但 Nucleus 不自动执行 recipe。 + +### Recipe 示例 + +```yaml +schema_version: "recipe.v1" +id: gorm-relational-store +kind: relational_store +provider: gorm +language: go +detect: + imports: + - gorm.io/gorm +suggest: + interfaces: + - "Repository interface should not expose *gorm.DB unless project already does" + verification: + - "go test ./..." +risks: + - "transaction boundary must be explicit" + - "avoid leaking ORM model into API contract" +``` + +Recipe 是知识,不是框架实现。 + +Recipe 安全边界: + +- recipe 不允许包含自动执行脚本。 +- recipe 不允许声明要写入的文件内容。 +- recipe 中的 commands 只能作为 suggested verification。 +- recipe 不得自动修改 manifest、decision、go.mod、源码或配置。 +- recipe 命中结果只能作为 plan candidates,不能直接变成 accepted decision。 + +## 多服务与 Monorepo + +Nucleus 必须支持 monorepo 和多 module,而不是假设一个 repo 只有一个服务。 + +规则: + +- 允许一个 repo 下存在多个 `nucleus.yaml`。 +- `--dir` 始终表示当前 Nucleus service root,不默认等于 git root。 +- `adopt --dir` 只在指定目录创建协议索引,不扫描并接管整个仓库。 +- `describe --dir` 默认只描述当前 service root,跨 service 关系必须通过 contracts/dependencies 显式声明。 +- `.nucleus/workspace.yaml` 暂缓进入 MVP。若未来引入,只能作为服务索引,不作为控制平面。 +- graph symbol ID 必须包含 module/package,避免跨 module 短名冲突。 + +## Graph 设计 + +### 输出目标 + +`nucleus describe --json` 应输出: + +- `contract_graph` +- `symbol_graph` +- `call_graph` +- `interface_graph` +- `capability_graph` +- `flow_graph` +- `impact_index` +- `test_graph` + +### Symbol Graph + +节点: + +- package +- file +- type +- interface +- method +- function +- variable/const + +边: + +- contains +- imports +- declares +- calls +- implements +- references +- returns +- accepts + +### Symbol ID + +所有 graph、mark、trace、impact 输出都必须使用稳定 symbol ID,不能只使用短名。 + +建议格式: + +```text +go:///# +go:///#. +``` + +示例: + +```text +go://example.com/order/internal/order#OrderStore +go://example.com/order/internal/order#Service.CreateOrder +``` + +每个 symbol 节点还应包含: + +- `name`:短名,如 `OrderStore`。 +- `package`:Go package import path。 +- `file`:相对文件路径。 +- `span`:起止行列。 +- `kind`:type、interface、function、method、const、var。 + +用户输入短名时,CLI 可以做候选解析;如果存在多个候选,必须返回 ambiguous diagnostics,不能猜。 + +### Graph Confidence + +每个 graph edge 都必须带来源和可信度: + +```json +{ + "from": "go://example.com/order/internal/http#Handler.CreateOrder", + "to": "go://example.com/order/internal/order#Service.CreateOrder", + "kind": "calls", + "source": "ast", + "confidence": "direct", + "stale": false +} +``` + +字段: + +- `source`: `ast | contract | manifest | mark | decision | recipe | heuristic`。 +- `confidence`: `direct | inferred | unknown`。 +- `stale`: 当前 edge 是否基于过期快照或未解析锚点。 + +`trace` 和 `impact` 不得把 `inferred` 或 `unknown` 边表达成确定事实。 + +### Flow Graph + +微服务语义链: + +```text +route -> handler -> function/method -> interface -> implementation -> external capability +``` + +示例 JSON: + +```json +{ + "route": "POST /orders", + "operation_id": "createOrder", + "chain": [ + {"kind": "handler", "symbol": "Handler.CreateOrder", "file": "internal/http/order.go:21"}, + {"kind": "usecase", "symbol": "CreateOrder", "file": "internal/order/usecase.go:14"}, + {"kind": "interface", "symbol": "OrderStore", "file": "internal/order/store.go:6"}, + {"kind": "implementation", "symbol": "GormOrderStore", "file": "internal/storage/order_store.go:18"}, + {"kind": "capability", "id": "order_store", "kind": "relational_store"} + ] +} +``` + +### Impact Graph + +输入: + +```bash +nucleus impact symbol OrderStore --json +``` + +输出: + +- direct callers。 +- transitive callers。 +- affected routes。 +- affected contracts。 +- affected tests。 +- affected capabilities。 +- suggested verification commands。 +- risk notes。 + +### Test Graph + +Nucleus 应建立轻量测试关联: + +- 同 package 测试。 +- `_test.go` 中引用的 symbol。 +- table-driven test 中出现的 operationId/error code。 +- HTTP scenario cases 与 OpenAPI route 的关系。 + +AI 修改前应知道最小验证集,而不是只会跑 `go test ./...`。 + +## CLI 设计 + +### 主命令分层 + +```text +nucleus adopt 在任意 Go 项目中生成最小协议索引 +nucleus describe 输出完整结构化事实 +nucleus plan 根据任务输出编辑计划和影响面 +nucleus apply 应用结构化 plan 中的文件编辑 +nucleus execute 执行 allowlisted verification commands +nucleus verify 执行项目声明的验证 +nucleus mark 标记 contract/capability/symbol/test 等锚点 +nucleus trace 查询 route/symbol/capability 调用链 +nucleus impact 查询变更影响面 +nucleus decision 校验 AI/用户的结构化技术决策 +nucleus report 输出本地质量报告 +nucleus mcp 通过 stdio MCP 暴露本地结构化事实 +``` + +### `nucleus adopt` + +目标: + +- 不生成业务代码。 +- 不规定目录。 +- 不选择 provider。 +- 不修改 go.mod。 + +行为: + +1. 检测 `go.mod`。 +2. 扫描常见 contract 文件,但只作为候选。 +3. 生成最小 `nucleus.yaml`。 +4. 生成项目事实快照。 +5. 生成可选 `.nucleus/README.md` 或 agent instruction。 +6. 输出 adoption evidence。 + +示例: + +```bash +nucleus adopt --dir . --service order-api --json +``` + +输出包含: + +- detected module。 +- package list summary。 +- detected contracts。 +- detected test commands。 +- created files。 +- generated file candidates。 +- symbol index summary。 +- skipped suggestions。 +- warnings。 + +`adopt` 的价值不是初始化目录,而是给 AI 第一次进入项目时提供地图。它应该尽量输出事实,不做决策。 + +### `nucleus mark` + +用于声明结构化锚点: + +```bash +nucleus mark contract http --kind openapi --path api/openapi.yaml +nucleus mark capability order_store --kind relational_store --symbol OrderStore +nucleus mark verify "go test ./..." +``` + +mark 只改 manifest,不生成实现。 + +`mark` 对 symbol 有两种状态: + +- `resolved`:symbol 当前存在,CLI 写入稳定 symbol ID。 +- `declared`:symbol 当前不存在,CLI 只记录 intent,后续 `plan` 必须提示需要创建或绑定 symbol。 + +如果用户输入短名且存在多个候选,`mark` 必须失败并返回候选列表。禁止自动选择第一个匹配项。 + +manifest 记录示例: + +```yaml +capabilities: + - id: order_store + kind: relational_store + symbols: + - id: go://example.com/order/internal/order#OrderStore + status: resolved + - name: FutureStore + status: declared +``` + +### `nucleus capability` + +删除旧 `capability add` 命令。 + +原因: + +- `add` 暗示脚手架生成。 +- `--provider` 暗示框架负责技术选型。 +- 旧命令会诱导 AI 生成 provider 实现,而不是声明语义锚点。 + +替代命令: + +```bash +nucleus mark capability order_store --kind relational_store --symbol OrderStore +nucleus decision validate artifacts/nucleus/decisions/order-store-provider.yaml +``` + +如果需要专门的 capability namespace,只允许声明式命令: + +```bash +nucleus capability declare order_store --kind relational_store --symbol OrderStore +``` + +该命令只写 manifest,不生成 provider、不修改依赖、不创建目录结构。 + +### `nucleus plan` + +输出应从路径计划升级为结构化作战图: + +```json +{ + "task": "add order status filter", + "task_type": "contract_and_logic_change", + "required_contract_edits": ["api/openapi.yaml"], + "suggested_edits": ["internal/order/query.go"], + "blocked_edits": [], + "affected_symbols": ["ListOrders", "OrderStore.List"], + "affected_routes": ["GET /orders"], + "affected_tests": ["internal/order/query_test.go"], + "capabilities": ["order_store"], + "commands": ["go test ./internal/order", "nucleus verify --dir . --json"], + "risks": ["query filter changes persistence semantics"] +} +``` + +### `nucleus trace` + +示例: + +```bash +nucleus trace route "POST /orders" --json +nucleus trace symbol CreateOrder --json +nucleus trace capability order_store --json +``` + +用途: + +- 给 AI 快速定位业务链。 +- 减少反复 grep/read。 +- 降低误改无关代码概率。 + +### `nucleus impact` + +示例: + +```bash +nucleus impact symbol OrderStore --json +nucleus impact file internal/order/store.go --json +nucleus impact contract api/openapi.yaml --json +``` + +用途: + +- 变更前理解 blast radius。 +- plan 中自动嵌入结果。 +- review 中解释为什么改这些文件。 + +### `nucleus decision` + +用于校验 AI 生成的技术决策: + +```bash +nucleus decision validate artifacts/nucleus/decisions/order-store-provider.yaml +``` + +校验: + +- capability 是否存在。 +- provider 是否有 evidence。 +- required changes 是否在 edit surfaces 内。 +- verification commands 是否存在。 +- 新增依赖是否有理由。 +- locked decision 是否被静默替换。 +- supersede decision 是否引用原 decision hash。 + +Locked decision 信任模型: + +- `decision validate` 可以校验 locked decision,但不能默认创建 locked decision。 +- 创建 locked decision 必须显式运行 `decision accept `,或由用户手动编辑。 +- locked decision 必须记录原始 decision hash。 +- supersede decision 必须引用 `supersedes` 和 `supersedes_hash`。 +- AI 后续计划若改变 locked provider/library/driver,必须先生成 supersede decision,否则 plan 应标记 blocked。 + +## MCP 设计 + +Nucleus 应提供 MCP 工具给 agent,而不是只靠 CLI 文本输出。 + +当前 stdio MCP 工具: + +- `get_service_description` +- `get_edit_surfaces` +- `get_contracts` +- `get_capabilities` +- `trace_route` +- `trace_symbol` +- `trace_capability` +- `impact_symbol` +- `impact_file` +- `impact_contract` +- `find_symbol` +- `list_callers` +- `list_callees` +- `validate_decision` +- `list_decisions` +- `get_report` +- `build_plan` +- `list_recipes` +- `get_recipe` + +MCP 返回结构化 JSON,不返回长篇自然语言。工具结果必须包含可被 agent 直接消费的结构化内容,而不是只把 CLI 文本塞进字符串。MCP `structuredContent` 必须包含 `result_kind`、`schema_version`、`schema_ref`、`ok`、`diagnostics`;复用 CLI 输出的工具直接返回对应 CLI result schema,其余 MCP 聚合工具使用 `mcp-result.v1`。 + +MCP 只读边界: + +- 不写文件。 +- 不执行验证命令。 +- 不访问网络。 +- 不选择 provider。 +- 不生成 scaffold。 +- 不把 recipe 变成 accepted 或 locked decision。 + +## 命令副作用矩阵 + +所有命令默认 local-only,不访问网络,不上传源码,不调用 control plane。 + +| Command | Read Files | Write Files | Execute Commands | Network | May Edit go.mod/go.sum | +| --- | --- | --- | --- | --- | --- | +| `adopt` | yes | only `nucleus.yaml` / `.nucleus/*` | no | no | no | +| `describe` | yes | no | no | no | no | +| `mark` | yes | only `nucleus.yaml` | no | no | no | +| `trace` | yes | no | no | no | no | +| `impact` | yes | no | no | no | no | +| `plan` | yes | no | no | no | no | +| `decision validate` | yes | no | no | no | no | +| `report` | yes | no | no | no | no | +| `mcp --stdio` | yes | no | no | no | no | +| `gen` | yes | generated contract artifacts only | no | no | no | +| `verify` | yes | evidence artifacts only | explicit manifest verify commands | no by default | no | +| `execute` | yes | evidence artifacts only | explicit allowlisted plan commands | no by default | no | +| `apply` | yes | plan-declared files inside edit surfaces | no | no | no | + +`verify` 和 `execute` 只有在用户声明的命令本身访问网络时才可能触发网络行为。Nucleus 不应额外添加网络调用,也不应扩展命令列表。 + +## 生成策略 + +### 什么可以生成 + +可以生成: + +- manifest。 +- contract-derived metadata。 +- route binder。 +- type-safe request/response shell。 +- decision draft。 +- evidence。 +- test scenario draft。 + +decision draft 只是候选文件,不能等同于 accepted 或 locked decision。任何会引入 provider/library/driver 的实现计划,都必须通过 `decision validate`,且 locked 状态必须由显式接受动作产生。 + +### 什么不应该生成 + +不应该生成: + +- provider SDK wiring。 +- ORM repository implementation。 +- 数据库 migration。 +- Dockerfile。 +- Makefile。 +- 固定 `internal` 目录结构。 +- 固定 config loader。 +- 固定 app container。 + +除非用户明确要求生成某个具体实现,并且 AI 已经给出 decision evidence。 + +`gen` 边界: + +- 只能生成 contract 派生物。 +- 不能生成 app wiring。 +- 不能生成 runtime bootstrap。 +- 不能生成 provider glue。 +- 不能自动引入 runtime import。 +- 不能修改 `go.mod/go.sum`。 +- 不能把 generated artifacts 当作业务代码编辑入口。 + +Generated artifacts 规则: + +- generated 文件必须带稳定 marker。 +- marker 必须包含 source hash、schema version、generator version。 +- generated 目标默认进入 readonly edit surface。 +- AI 不得直接编辑 generated 文件,应修改 source contract 后重新生成。 +- freshness marker 不得依赖本地绝对路径或时间戳。 + +## Report 设计 + +删除 platform 语义后,`report` 只输出本地质量报告。 + +允许字段: + +- graph coverage。 +- decision quality。 +- locked decision drift。 +- recipe candidate usage。 +- verification status。 +- edit surface violations。 +- generated freshness。 +- AI task evidence。 +- unresolved/stale symbols。 +- locked decision changes。 + +禁止字段: + +- platform upload。 +- release dry-run。 +- control plane。 +- production bridge readiness。 +- provider SDK readiness。 + +Graph coverage 必须是可解释计数,不是神秘分数。允许示例: + +- resolved symbols / declared symbols。 +- direct edges / inferred edges / unknown edges。 +- routes with resolved handler chain / total routes。 +- capabilities with resolved symbols / total capabilities。 +- tests linked to changed symbols / changed symbols. + +## Verify、Execute、Lint + +`verify`: + +- 执行 manifest 中声明的验证集合。 +- 校验 decision evidence、locked decision hash 和 supersedes hash。 +- 只产出 evidence。 +- 不擅自增加命令。 +- 不修改源码、manifest、decision 或依赖文件。 + +`execute`: + +- 执行 plan 中显式声明且 allowlisted 的命令。 +- 只产出 evidence。 +- 不读取未声明的命令建议。 +- 不把 recipe commands 当作可执行命令。 + +`lint`: + +- 只检查协议一致性、安全边界、decision evidence、generated freshness、graph stale/unresolved。 +- 不检查固定目录分层。 +- 不要求 `internal/app`、`internal/domain`、`internal/usecase` 等目录存在。 +- 不因为 unknown capability kind 或 unknown provider decision 失败。 + +## 无 Contract 项目 + +没有 OpenAPI、proto、errors 的 Go 项目也合法。 + +此时 Nucleus 运行在 graph-only 模式: + +- `adopt` 可以只生成 service、ai、verify、capabilities 空索引。 +- `describe` 输出 symbol/call/test graph。 +- `plan` 不能要求先创建 API contract,除非任务涉及外部行为。 +- 不得使用 “library template” 或类似脚手架术语描述该模式。 + +## 模块边界 + +当前 `cap`、`bridge`、`runtime/*` 模块需要重新评估。 + +### `bridge` + +建议删除。 + +原因: + +- Nucleus 不应提供 provider SDK adapter 集合。 +- provider 选择属于项目 decision,不属于框架内置能力。 +- 保留 bridge 模块会持续诱导贡献者添加 gorm、xorm、nacos、redis、kafka 等实现。 + +如果未来需要参考实现,应放在独立 examples 或 recipes,不进入核心模块边界。 + +工程动作: + +- 移除 `bridge` submodule。 +- 移除 README、implementation status、root docs 中的 bridge 核心模块描述。 +- 移除 lint/import mapping 中对 `github.com/nucleuskit/bridge/*` 的特殊识别。 +- 移除 capability/provider catalog 对 bridge candidates 的输出。 +- 若需要示例 adapter,以独立 recipe 或外部 example 表达,不进入核心仓库模块边界。 + +### `cap` + +建议收缩到协议类型,甚至先暂停扩展。 + +允许保留: + +- 极少量与 Nucleus 协议直接相关的通用类型。 +- no-op/testing helper,且不能表达某个具体 provider 偏好。 + +不应保留: + +- 一套覆盖 Redis、MQ、SQL、Mongo、Metric、Trace、Config 的接口全集。 +- provider-specific option。 +- 试图统一所有基础设施的抽象层。 + +### `runtime/*` + +运行时模块也要接受同一条边界: + +- 可以提供轻量协议适配,如 HTTP route binder 需要的最小接口。 +- 不应成为应用容器、配置系统、服务发现系统或启动框架。 +- 任何 runtime import 都必须是用户显式选择,而不是 `adopt` 或 capability 默认引入。 + +## 文档拆分 + +当前文件是总设计文档,用于统一方向。进入实施前应拆成三类文档,避免一份大文档同时承担决策、设计和任务跟踪。 + +建议拆分: + +- ADR:记录为什么删除 template、provider scaffold、platform 叙事、bridge 核心模块。 +- Design:记录 manifest v2、decision v1、graph schema、trace/impact 输出协议。 +- Implementation Plan:记录具体删除文件、修改包、测试用例和 PR 顺序。 + +总设计文档可以保留为索引,但具体执行以拆分后的文档为准。 + +## MVP 顺序 + +最小可用闭环应该优先解决 AI 看不清、定位慢、影响面不明的问题,而不是优先生成代码。 + +推荐顺序: + +```text +adopt -> describe graph -> trace -> impact -> plan -> decision -> verify +``` + +`gen` 可以保留 contract-derived 生成能力,但不应成为 MVP 主线。Nucleus 的差异化不是“生成更多代码”,而是“让 AI 更准确地理解和修改现有代码”。 + +## 测试策略 + +除功能正向测试外,必须加入反能力测试,确保 Nucleus 不会做不该做的事。反能力测试应成为 CI gate,而不是普通建议。 + +必须测试: + +- `adopt` 不修改 `go.mod/go.sum`。 +- `adopt` 不生成业务目录。 +- capability 命令不生成 provider SDK wiring。 +- capability 命令不创建 repository、migration、Dockerfile、Makefile。 +- unknown capability kind 合法。 +- unknown provider decision 合法,只要 decision schema 和 evidence 完整。 +- manifest 不允许 provider/library/driver 字段。 +- lint 不要求 `internal/app`、`internal/domain`、`internal/usecase` 等目录。 +- plan 不输出 provider 默认选择。 +- report 不输出 platform upload、release dry-run、control plane 字段。 +- trace/impact 在 graph 不完整时输出 confidence/unknown,而不是编造确定链路。 +- generated 文件默认 readonly,且 freshness marker 不含时间戳/绝对路径。 +- no-contract graph-only 项目合法。 + +只要出现以下行为,CI 必须失败: + +- `adopt` 或 capability 命令修改 `go.mod/go.sum`。 +- 生成 provider SDK wiring。 +- 要求固定目录存在。 +- unknown kind/provider 导致失败。 +- manifest 接受 provider/library/driver 字段。 +- report 输出 platform/control plane 字段。 +- lint 要求固定分层目录。 +- recipe 触发写文件或执行命令。 + +## 代码改造计划 + +### 阶段 1:删除错误方向并收紧定位 + +目标: + +- 删除 template/platform 主路径。 +- 文档从 template/platform 改为 protocol/adoption。 +- 明确 Nucleus 不做 provider selection。 + +任务: + +- 修改 `README.md` 中 Project Shape、Roadmap、Capability Protocol 表述。 +- 修改 `docs/adr/0001-project-scope.md`,移除 `template` 作为核心 area。 +- 新增 ADR:Nucleus is an agent-native protocol layer, not a scaffold. +- 新增 manifest/decision/recipe/graph/report/diagnostic/adopt-result schema 文件。 +- 删除 `docs/platform-mapping.md` 或重写为纯本地 quality report 文档。 +- 删除 `examples/deploy/docker` 平台化示例。 +- 删除以 service/worker/library template 为主的 example 文档。 +- 删除 `cmd/nucleus/internal/initcmd` 或重写为 `adopt`。 + +验收: + +- README 第一屏不再强调 template。 +- 主流程展示 `adopt -> describe -> plan -> impact -> verify`。 +- 文档明确 provider 不由框架决定。 +- 仓库中不存在平台化发布/上传/readiness 叙事。 + +### 阶段 2:新增 `adopt` 和项目事实快照 + +目标: + +- 任意 Go 项目可加入 Nucleus。 +- AI 第一次进入项目时拿到结构化地图。 + +任务: + +- 新增 `cmd/nucleus/internal/adopt`。 +- 扫描 `go.mod`、contract candidates、test command candidates。 +- 生成最小 `nucleus.yaml`。 +- 输出 package list summary、generated file candidates、symbol index summary。 +- 输出 `nucleus.adopt_result`。 +- 加测试覆盖空项目、已有 openapi、无 contract 的 library、monorepo 子目录。 + +验收: + +- `nucleus adopt --dir . --json` 不生成业务代码。 +- 不修改 `go.mod/go.sum`。 +- 不要求特定目录。 +- 无 contract 项目合法。 +- 可对当前仓库自身运行。 + +### 阶段 3:增强 describe graph + +目标: + +- 让 AI 用一次查询理解项目结构、逻辑链和影响面基础数据。 + +任务: + +- 在 `contract/inspect` 增加 symbol graph。 +- 定义稳定 symbol ID。 +- 解析 package、file、type、interface、method、function。 +- 增加 calls/references/implements edges。 +- 增加 route 到 handler 到 symbol chain。 +- 增加 interface implementation detection。 +- 增加 test relation detection。 +- 为 graph edge 输出 source、confidence、stale。 +- 输出 graph schema。 + +验收: + +- `describe --json` 包含 symbol/call/interface/test graph。 +- 能查询 symbol callers/callees。 +- 能从 route 找到 handler chain。 +- 能从 interface 找到 implementations。 +- 短名冲突时返回 ambiguous diagnostics。 + +### 阶段 4:新增 trace/impact + +目标: + +- 让 AI 快速定位修改影响。 + +任务: + +- 新增 `cmd/nucleus/internal/trace`。 +- 新增 `cmd/nucleus/internal/impact`。 +- `plan` 自动嵌入 impact summary。 +- 输出稳定 JSON。 + +验收: + +- `nucleus trace route` 可返回 route execution chain。 +- `nucleus impact symbol` 可返回 affected symbols/files/routes/tests。 +- `plan` 中不再只有路径,还包含 affected graph。 + +### 阶段 5:重构 capability + +目标: + +- capability 从 provider scaffold 改为 semantic anchor。 + +任务: + +- 将 `capcatalog.Provider` 从核心流程移除。 +- 删除 provider 强枚举和 provider 默认值。 +- 删除 capability kind 强枚举校验,只保留建议词表。 +- 将 capability kind 建议词表外置为 vocab 数据。 +- 删除 `postgresOperations` 特例。 +- 删除自动写 `go.mod/go.sum` 行为。 +- 新增 capability object schema。 +- 增加 `nucleus mark capability`。 +- 删除 `cmd/nucleus/internal/capability` 旧 add/scaffold 实现,或重写为纯 declaration。 + +验收: + +- 添加 mysql/gorm/xorm/vector_store/payment_gateway/custom kind 不需要修改 Nucleus 源码。 +- capability 命令不引入任何 provider SDK。 +- `lint` 不因未知 kind 或未知 provider 失败,只要求 capability 有锚点或 decision。 + +### 阶段 6:decision/evidence 闭环 + +目标: + +- AI 技术选型可审查、可锁定、可回放。 + +任务: + +- 定义 `decision.v1` schema。 +- 新增 `nucleus decision validate`。 +- 支持 locked decision 和 supersede decision。 +- 支持 decision hash、supersedes_hash。 +- hash 使用 canonical payload,排除 accepted_at、绝对路径、diagnostics。 +- `plan` 对新增依赖/provider 要求 decision。 +- `verify/report` 汇总 decisions。 + +当前实现状态: + +- `decision validate`、`decision accept`、`decision supersede` 已实现。 +- `plan` 已通过 `blocked_decisions` 阻止未带 supersede evidence 的 locked provider/library/driver 替换。 +- `verify` 已新增 `decision` evidence step,并把 decision diagnostics 合并到顶层 diagnostics。 +- `report` 已输出 `ai_quality.decision_quality`,包含 files、valid、errors、warnings、accepted_locked、supersedes、drift。 + +验收: + +- 新 provider、新依赖、新外部服务都有 decision evidence。 +- locked decision 不能被后续 AI 静默替换。 +- supersede decision 必须引用原 decision hash。 +- decision 中 required changes 必须落在 edit surfaces。 +- report 能展示 AI 决策质量。 + +### 阶段 7:外置 recipe + +目标: + +- provider 知识外置,框架不硬编码。 + +任务: + +- 定义 `recipe.v1` schema。 +- 支持 `.nucleus/recipes/*.yaml`。 +- 支持内置只读 recipes 目录。 +- `plan` 可以读取 recipe 作为候选建议。 +- 输出 candidate,不自动选择。 + +当前实现状态: + +- MCP 已支持读取项目本地 `.nucleus/recipes/*.yaml`、`.yml`、`.json` 和内置只读 recipe。 +- 已支持内置只读 recipes,当前内置项以 provider-neutral 的 `sql-port-boundary` 为主,不编码 ORM、driver、依赖、DSN、命令或文件写入默认值。 +- 项目本地 recipe 与内置 recipe 同 id 时,本地 recipe 覆盖内置 recipe;候选结果通过 `source: project|builtin` 标记来源。 +- recipe 使用 strict schema,未知字段会失败,避免把脚本或写文件意图混入知识文件。 +- `plan` 已输出 `context.recipe_candidates`、`context.recipe_diagnostics` 和 `context.recipe_policy`。 +- `report` 已从 AI task evidence 中汇总 `recipe_candidate_usage`,统计 candidate task、candidate count、decision-required count 和 unique candidate ids。 +- recipe candidates 只作为 `candidate_only` 参考,不会改变 plan commands、generated outputs、provider decision 或 locked decision 状态。 +- 无效 recipe 会被排除在 candidates 之外,并作为诊断和风险提示暴露给 agent。 + +验收: + +- 新增 provider recipe 不需要改 Go 代码。 +- 未命中 recipe 时仍可使用 custom provider。 +- AI 看到的是候选项和理由,不是默认实现。 +- recipe 不允许写文件、执行脚本或直接生成 accepted decision。 + +### 阶段 8:MCP 化 + +目标: + +- 让 Codex、Claude Code、Cursor 等 agent 直接查询结构化上下文。 + +任务: + +- 暴露 MCP server。 +- 工具覆盖 describe、trace、impact、plan、decision validation。 +- 输出 schema 化 JSON。 + +当前实现状态: + +- 已提供 `nucleus mcp --stdio`。 +- 已覆盖 contracts、edit surfaces、capabilities、symbols、trace、impact、decision validation、report、plan 和 recipes。 +- MCP 工具是 local-only/read-only,不执行命令、不写文件、不做 provider 选择。 + +验收: + +- agent 不需要 shell grep/read 多轮查找即可获取调用链。 +- MCP 工具结果可直接进入 plan。 + +## 删除策略 + +项目未正式使用,不保留旧协议。 + +直接删除: + +- `nucleus init --template service|worker|library`。 +- `cmd/nucleus/internal/initcmd` 中的模板生成实现。 +- `nucleus capability add --provider`。 +- `cmd/nucleus/internal/capability` 中 provider scaffold、go.mod/go.sum 写入、postgres 特例。 +- `capcatalog` 中 provider 默认值、provider 枚举、planning keyword provider hint。 +- `report --platform`。 +- platform upload、release dry-run、platform mapping、docker platform local 示例。 +- manifest v1 的 `capabilities: []string` 主模型。 +- manifest 中 provider/library/driver 决策字段。 +- capability kind 强枚举校验。 +- `bridge` 模块作为核心模块。 + +直接重写: + +- manifest schema 到 v2 object model。 +- capability graph 读取 v2 capability objects。 +- lint 中依赖硬编码 capability module/provider/kind 的规则。 +- plan 中 capability/provider 的自然语言猜测逻辑。 +- `cap` 模块边界,收缩为协议类型或暂缓扩展。 + +如果旧测试覆盖这些行为,应删除或改写测试,不做兼容断言。 + +## 风险和约束 + +### 风险 1:过度抽象 + +如果 capability kind 太抽象,AI 可能不知道如何落地。 + +应对: + +- 用 recipe 提供建议。 +- 用 graph 展示现有项目事实。 +- 用 decision 记录 AI 判断。 + +### 风险 2:graph 不准确 + +Go 动态调用、interface、多态、反射会让静态分析不完整。 + +应对: + +- 输出 confidence。 +- 区分 `direct`、`inferred`、`unknown`。 +- 允许用户 mark symbol/capability 补充事实。 + +### 风险 3:AI 决策质量不稳定 + +AI 可能选择不适合的库。 + +应对: + +- 强制 decision evidence。 +- 强制 alternatives。 +- 强制 verification。 +- report 汇总失败决策。 + +## 成功标准 + +### 产品层 + +- 用户可以在任意 Go 项目执行 `nucleus adopt`。 +- Nucleus 不要求项目目录符合某个模板。 +- AI 可以通过 Nucleus 获取结构化上下文,而不是靠大量 grep。 +- 新增 mysql/gorm/xorm/vector_store/payment_gateway/custom kind 或 provider 不需要改 Nucleus 源码。 +- 用户可以拒绝任何 provider 默认选择,因为框架不做默认选择。 + +### 技术层 + +- `describe` 输出可解释的 graph。 +- `plan` 输出 affected symbols/routes/tests。 +- `impact` 能定位 blast radius。 +- `capability` 不再自动引入 provider SDK。 +- `capability` 不强校验 kind 枚举。 +- manifest 不包含 provider/library/driver。 +- `decision` 能校验 AI 技术选型。 +- locked decision 需要 supersede decision 才能替换。 +- `verify` 能基于项目自己的命令产出 evidence。 + +### 心智层 + +Nucleus 从: + +```text +一个 AI-first Go 微服务脚手架/平台 +``` + +变成: + +```text +任意 Go 微服务都可加入的 AI 结构化协议层 +``` + +## 推荐第一批 PR + +### PR 1:删除错误方向并收敛文档 + +- 改 README。 +- 新增 ADR。 +- 拆分 ADR/Design/Implementation Plan。 +- 新增 schema 文件清单。 +- 定义 diagnostic envelope。 +- 定义命令副作用矩阵。 +- 删除 template/platform 叙事。 +- 删除 platform mapping 或重写为本地质量报告。 +- 删除 `bridge` 核心模块定位。 +- 明确 Source of Truth 优先级。 + +### PR 2:删除 template init,新增 `adopt` + +- 新增命令。 +- 删除 `cmd/nucleus/internal/initcmd` 模板生成路径。 +- 输出检测结果和创建的最小 manifest。 +- 输出项目事实快照。 +- 支持 monorepo 子目录。 +- 支持 no-contract graph-only 项目。 +- 不写业务代码。 + +### PR 3:describe graph MVP + +- 定义 symbol ID。 +- 输出 package/file/type/interface/function/method。 +- 输出 calls。 +- 输出 implements。 +- 输出 test relation summary。 +- edge 输出 source/confidence/stale。 + +### PR 4:trace/impact MVP + +- `trace symbol` 支持 callers/callees。 +- `trace route` 支持 route 到 handler 的链路。 +- `impact symbol` 支持 affected files/routes/tests。 +- graph 不完整时输出 confidence/unknown。 + +### PR 5:capability object schema + +- 删除 `postgresOperations`。 +- 删除 `capability add --provider`。 +- capability 命令不再改 `go.mod/go.sum`。 +- manifest 直接切到 capability object。 +- manifest 不允许 provider/library/driver 字段。 +- 删除 v1 string list 主模型和相关测试。 +- unknown kind 合法。 +- capability kind 建议词表外置。 + +### PR 6:decision v1 和 locked decision + +- 定义 decision schema。 +- provider/library/driver 只允许出现在 decision。 +- 支持 locked 和 supersede。 +- 支持 decision hash 和 supersedes_hash。 +- `decision validate` 校验 edit surfaces 和 verification。 +- canonical hash 排除 accepted_at、绝对路径、diagnostics。 + +### PR 7:反能力测试 + +- 覆盖不改 `go.mod/go.sum`。 +- 覆盖不生成 provider SDK、repository、migration。 +- 覆盖不要求固定目录。 +- 覆盖不输出 platform 字段。 +- 覆盖 unknown kind/provider 不失败。 +- 将反能力测试纳入 CI gate。 +- 覆盖 recipe 不写文件、不执行命令。 +- 覆盖 generated marker 不含时间戳/绝对路径。 + +### PR 8:report 本地质量模型 + +- 删除 platform/control plane 字段。 +- 输出 graph coverage、decision quality、verification status。 +- 输出 unresolved/stale symbols。 +- 输出 locked decision changes。 +- 输出 recipe candidate usage。 +- graph coverage 使用可解释计数。 + +### PR 9:verify/execute/lint 职责收敛 + +- `verify` 只执行协议验证、decision evidence 校验和 manifest 声明验证集合。 +- `execute` 只执行 plan allowlisted commands。 +- `lint` 只检查协议一致性、安全边界、decision evidence、generated freshness、graph stale。 +- `lint` 不检查固定目录分层。 + +### PR 10:删除 bridge 工程动作 + +- 移除 `bridge` submodule。 +- 移除文档和 implementation status 中 bridge 核心模块描述。 +- 移除 lint/import mapping 中 bridge 特殊识别。 +- 移除 capability/provider catalog 的 bridge candidates。 + +## 最终判断 + +Nucleus 最有价值的方向不是“帮人生成一个标准微服务项目”,而是“让 AI 理解并安全修改任何 Go 微服务项目”。 + +因此,所有硬编码都应接受一条判断: + +```text +如果它是协议、安全、证据或语言事实,可以固化。 +如果它是技术栈、provider、目录结构或业务实现,必须外置给 AI/用户判断,并留下结构化 evidence。 +``` + +只要守住这条线,Nucleus 就会从又一个框架,变成 AI 时代真正有差异化的 Go 微服务协议内核。 diff --git a/docs/platform-mapping.md b/docs/platform-mapping.md deleted file mode 100644 index 9559f38..0000000 --- a/docs/platform-mapping.md +++ /dev/null @@ -1,37 +0,0 @@ -# Platform Mapping - -This document records how `nucleus report --platform` maps local service facts to -release-readiness metadata. The report is intentionally local-only: it does not -upload artifacts, call a control plane, or require provider SDK credentials. - -## Local Artifacts - -`platform_upload_payload` describes the service metadata that a future platform -upload step may consume: - -- service identity from `nucleus.yaml` -- declared capabilities -- HTTP endpoints and gRPC services from contract inspection -- generated freshness state -- verification commands - -`release_dry_run` describes the local release matrix and verification commands -that should be checked before publishing a build. - -## Readiness Gates - -The report emits readiness gates for: - -- local platform upload payload metadata -- local release dry-run metadata -- generated freshness -- declared verification commands - -Failed gates do not prevent report generation. Release automation should inspect -the gates and decide whether to continue. - -## Provider Strategy - -Provider strategy is derived from the capability graph. Providers remain -optional bridge or application wiring choices; the report never imports provider -SDKs or turns detected providers into defaults. diff --git a/docs/usage.md b/docs/usage.md new file mode 100644 index 0000000..07982fa --- /dev/null +++ b/docs/usage.md @@ -0,0 +1,461 @@ +# Nucleus 使用指南 + +这份文档说明当前改造后的 Nucleus 应该如何使用。 + +Nucleus 现在不是脚手架、平台、provider SDK 集合,也不是项目模板生成器。它是给 AI agent、人类 reviewer、CI 共用的一层本地协议工具:把项目事实、可编辑边界、调用链、影响面、技术决策和验证证据变成结构化数据。 + +## 核心原则 + +- 用 `adopt` 接入已有 Go 项目,不用 `init` 创建项目。 +- 用 `mark` 声明契约、能力锚点和验证命令,不生成业务代码。 +- 用 `.nucleus/decisions/*.yaml` 记录 provider/library/driver/ORM 等技术选择。 +- 用 `describe/trace/impact/plan` 给 AI 提供定位链、影响链和编辑边界。 +- 用 `verify` 产出最终机器可读证据。 +- 不用 Nucleus 选择 MySQL ORM、Redis client、Kafka SDK、目录结构或应用 wiring。 + +## 命令入口 + +如果已经安装 CLI: + +```bash +nucleus --help +``` + +在本仓库源码内临时运行: + +```bash +go run ./cmd/nucleus --help +``` + +后文统一写成 `nucleus`。如果还没安装,可以把命令替换为: + +```bash +go run ./cmd/nucleus +``` + +## 推荐主流程 + +```text +adopt -> mark -> describe -> trace/impact -> plan -> decision -> change code -> verify -> report +``` + +对应命令: + +```bash +nucleus adopt --dir . --agent codex --json --pretty +nucleus mark contract http --kind openapi --path api/openapi.yaml --json +nucleus mark capability order_store --kind relational_store --symbol OrderStore --json +nucleus mark verify "go test ./..." --json +nucleus describe --dir . --json --pretty --flow +nucleus trace symbol OrderStore --json --pretty +nucleus impact symbol OrderStore --json --pretty +nucleus plan --dir . --task "add mysql capability" --json --pretty --executable +nucleus decision validate .nucleus/decisions/order-store-mysql.yaml --json --pretty +nucleus verify --dir . --json --pretty +nucleus report --dir . --json --pretty +``` + +## 1. 接入已有项目 + +在已有 Go 项目根目录执行: + +```bash +nucleus adopt --dir . --agent codex --json --pretty +``` + +`adopt` 只写: + +- `nucleus.yaml` +- `.nucleus/*` + +它不会: + +- 创建 service/worker/library 模板 +- 生成业务目录 +- 修改 `go.mod` 或 `go.sum` +- 选择 provider、ORM、driver 或 SDK + +如果已有 `nucleus.yaml`,需要显式覆盖: + +```bash +nucleus adopt --dir . --force --json --pretty +``` + +## 2. 声明契约 + +HTTP OpenAPI: + +```bash +nucleus mark contract http --kind openapi --path api/openapi.yaml --json --pretty +``` + +gRPC proto: + +```bash +nucleus mark contract grpc --kind proto --path api/proto/order.proto --json --pretty +``` + +错误码契约: + +```bash +nucleus mark contract errors --kind errors --path api/errors.yaml --json --pretty +``` + +契约只是声明项目已有或将要维护的协议文件。外部行为变更应先改契约,再改实现。 + +## 3. 声明能力锚点 + +能力是语义锚点,不是 provider 实现。 + +例如项目里已经有 `OrderStore` 接口: + +```bash +nucleus mark capability order_store \ + --kind relational_store \ + --symbol OrderStore \ + --intent "order persistence boundary" \ + --json --pretty +``` + +如果短 symbol 名不唯一,命令会返回候选项;应改用稳定 symbol ID。 + +能力声明可以表达: + +- 这个项目有一个 `order_store` 能力 +- 它是 `relational_store` 这类语义能力 +- 它锚定到项目自己的接口或类型 + +能力声明不能表达: + +- 使用 GORM 还是 Xorm +- 使用 MySQL 还是 PostgreSQL +- DSN 怎么配置 +- 事务实现怎么写 +- 要不要引入某个 SDK + +这些属于技术决策,放到 `.nucleus/decisions`。 + +## 4. 声明验证命令 + +`verify` 只运行 `nucleus.yaml` 里声明的项目命令,不隐式运行 `go test ./...`。 + +添加验证命令: + +```bash +nucleus mark verify "go test ./..." --json --pretty +nucleus mark verify "go test ./internal/order/..." --json --pretty +``` + +这会写入 `nucleus.yaml` 的 `verify.commands`。 + +## 5. 让 AI 读取项目事实 + +修改前先跑: + +```bash +nucleus describe --dir . --json --pretty --flow +``` + +重点读取: + +- `result_kind/schema_version/schema_ref/ok/diagnostics` +- `service` +- `endpoints` +- `grpc_services` +- `error_codes` +- `capabilities` +- `edit_surfaces` +- `symbol_graph` +- `flow_graph` +- `verification` +- `generated_freshness` + +AI 修改代码前必须看 `edit_surfaces`: + +- `allowed`: 可以改 +- `readonly`: 只能读 +- `forbidden`: 不能改 + +如果目标文件不在 allowed 范围内,不要绕过去写业务代码,应先调整协议边界或让用户确认。 + +## 6. 查询调用链和影响面 + +查 symbol 调用链: + +```bash +nucleus trace symbol OrderStore --json --pretty +``` + +查能力锚点: + +```bash +nucleus trace capability order_store --json --pretty +``` + +查路由链路: + +```bash +nucleus trace route "POST /orders" --json --pretty +``` + +查 symbol 影响面: + +```bash +nucleus impact symbol OrderStore --json --pretty +``` + +查文件影响面: + +```bash +nucleus impact file internal/order/store.go --json --pretty +``` + +查契约影响面: + +```bash +nucleus impact contract api/openapi.yaml --json --pretty +``` + +这些命令用于帮助 AI 快速定位: + +- 谁调用了这个 symbol +- 这个改动影响哪些文件 +- 哪些测试可能相关 +- 哪些路由或契约可能受影响 +- 哪些边是确定事实,哪些只是启发式推断 + +短 symbol 名有歧义时,不要猜,使用输出里的稳定 ID。 + +## 7. 生成计划 + +```bash +nucleus plan --dir . --task "add mysql capability" --json --pretty --executable +``` + +重点读取: + +- `ok` +- `diagnostics` +- `suggested_edits` +- `blocked_edits` +- `blocked_decisions` +- `impact_summary` +- `commands` +- `risks` +- `context.recipe_candidates` + +规则: + +- `blocked_edits` 不为空时,停止修改。 +- `blocked_decisions` 不为空时,先补或 supersede decision。 +- `recipe_candidates` 只是参考知识,不是默认技术选型。 +- `commands` 是建议链路,不代表 Nucleus 会自动执行所有命令。 + +## 8. 记录技术决策 + +当引入或替换 provider/library/driver/ORM/SDK 时,写 decision 文件。 + +示例:`.nucleus/decisions/order-store-mysql.yaml` + +```yaml +schema_version: "decision.v1" +id: order-store-mysql +capability: order_store +decision: + provider: mysql + library: gorm.io/gorm + driver: github.com/go-sql-driver/mysql + status: proposed + locked: false +reason: + - project team selected MySQL for order persistence + - existing service conventions use GORM repositories +impact: + symbols: + - go://example.com/order/internal/order#OrderStore + files: + - internal/order/store.go + - internal/order/mysql_store.go +verification: + commands: + - go test ./internal/order/... +alternatives: + - provider: mysql + library: xorm.io/xorm + driver: github.com/go-sql-driver/mysql + reason: rejected because project already uses GORM transaction helpers +``` + +校验: + +```bash +nucleus decision validate .nucleus/decisions/order-store-mysql.yaml --json --pretty +``` + +接受并锁定: + +```bash +nucleus decision accept .nucleus/decisions/order-store-mysql.yaml --by human --json --pretty +``` + +锁定后,后续 AI 不能静默替换 provider/library/driver。 + +如果确实要替换,创建新的 supersede decision,然后执行: + +```bash +nucleus decision supersede .nucleus/decisions/order-store-mysql-v2.yaml --json --pretty +nucleus decision validate .nucleus/decisions/order-store-mysql-v2.yaml --json --pretty +``` + +## 9. 生成契约产物 + +契约变更后运行: + +```bash +nucleus gen --dir . --json --pretty +``` + +`gen` 只生成契约派生产物。它不生成 provider glue、业务 wiring、目录模板或依赖变更。 + +## 10. 验证 + +快速检查: + +```bash +nucleus validate --dir . --json --pretty +nucleus lint --dir . --strict --json --pretty +``` + +最终证据: + +```bash +nucleus verify --dir . --json --pretty +``` + +`verify` 会输出 `evidence.v1`,重点看: + +- `ok` +- `summary` +- `steps` +- `diagnostics` +- 每个 step 的 `kind/status/ok/exit_code` + +如果根目录没有 `nucleus.yaml`,`verify` 会失败并给出 `manifest.read_failed`。这是正确行为;先运行 `adopt`。 + +## 11. MCP 给 agent 使用 + +启动本地 stdio MCP: + +```bash +nucleus --dir . mcp --stdio +``` + +MCP 是只读结构化工具层。它不会写文件、不会执行验证命令、不会访问网络、不会选择 provider。 + +常用工具: + +- `get_service_description` +- `get_edit_surfaces` +- `get_contracts` +- `get_capabilities` +- `trace_symbol` +- `trace_route` +- `trace_capability` +- `impact_symbol` +- `impact_file` +- `impact_contract` +- `find_symbol` +- `list_callers` +- `list_callees` +- `validate_decision` +- `list_decisions` +- `get_report` +- `build_plan` +- `list_recipes` +- `get_recipe` + +所有 MCP `structuredContent` 都应带: + +- `result_kind` +- `schema_version` +- `schema_ref` +- `ok` +- `diagnostics` + +agent 不要解析自然语言文本来判断成功失败,应以这些字段为准。 + +## 12. 常见场景 + +### 接入一个已有服务 + +```bash +nucleus adopt --dir . --agent codex --json --pretty +nucleus mark verify "go test ./..." --json --pretty +nucleus describe --dir . --json --pretty +``` + +### 给已有项目增加 MySQL 能力 + +```bash +nucleus mark capability order_store --kind relational_store --symbol OrderStore --json --pretty +nucleus plan --dir . --task "add mysql capability for order_store" --json --pretty --executable +``` + +然后由用户或 AI 写 `.nucleus/decisions/order-store-mysql.yaml`,明确 MySQL、ORM、driver、理由、影响面和验证命令。 + +Nucleus 不会替你决定用 GORM 还是 Xorm。 + +### 修改一个 API + +```bash +nucleus describe --dir . --json --pretty --flow +nucleus impact contract api/openapi.yaml --json --pretty +nucleus plan --dir . --task "change GET /orders response" --json --pretty --executable +``` + +先改 `api/openapi.yaml`,再运行: + +```bash +nucleus gen --dir . --json --pretty +nucleus verify --dir . --json --pretty +``` + +### 替换已锁定 provider + +```bash +nucleus plan --dir . --task "switch order_store provider to xorm" --json --pretty --executable +``` + +如果出现 `blocked_decisions`,创建 supersede decision: + +```bash +nucleus decision supersede .nucleus/decisions/order-store-xorm.yaml --json --pretty +nucleus decision validate .nucleus/decisions/order-store-xorm.yaml --json --pretty +``` + +再重新 plan。 + +## 13. 不要使用的旧路径 + +这些已经不是当前方向: + +- `nucleus init` +- `nucleus capability add` +- `nucleus capability add --provider ...` +- `nucleus migrate` +- `report --platform` +- `bridge` provider adapter 集合 +- 自动修改 `go.mod/go.sum` 的能力接入 +- 把 provider/library/driver 写进 `nucleus.yaml` + +如果一个需求必须选择技术栈,把选择写成 decision evidence,而不是写成框架默认值。 + +## 14. 推荐给 AI 的最小工作守则 + +1. 先跑 `describe`,读取 edit surfaces、symbol graph、verification。 +2. 对目标 symbol/route/file 跑 `trace` 或 `impact`。 +3. 跑 `plan --executable`,遇到 blocked 就停止。 +4. provider/library/driver 变化必须写 decision。 +5. 只改 allowed edit surfaces 内的文件。 +6. 变更后跑 `verify --json`。 +7. 最终交付时引用 evidence,而不是说“我觉得可以”。 diff --git a/examples/agent/codex/SKILL.md b/examples/agent/codex/SKILL.md index 655d464..496bca5 100644 --- a/examples/agent/codex/SKILL.md +++ b/examples/agent/codex/SKILL.md @@ -1,21 +1,21 @@ --- name: nucleus -description: Use when creating, inspecting, modifying, verifying, or repairing Go services built with Nucleus, the Contract/Manifest-first AI-native Go service kernel. Triggers include Nucleus business services, nucleus.yaml, api/openapi.yaml, api/errors.yaml, api/proto, capability declarations, edit surfaces, generated freshness, executable plans, and Nucleus CLI/MCP workflows. +description: Use when adopting, inspecting, modifying, verifying, or repairing Go services that use Nucleus as an agent-native protocol layer. Triggers include nucleus.yaml, .nucleus/decisions, api/openapi.yaml, api/errors.yaml, api/proto, capability declarations, edit surfaces, generated freshness, executable plans, and Nucleus CLI/MCP workflows. --- # Nucleus ## Start Here -Treat Nucleus as an installed service-kernel tool. Do not derive business code by copying a local Nucleus source checkout, examples, or testdata. +Treat Nucleus as an installed protocol tool. Do not derive business code by copying a local Nucleus source checkout, examples, or testdata. -For an empty directory, bootstrap through the CLI: +For an existing Go project that has not adopted Nucleus yet, add only the local protocol index: ```bash -nucleus init --name --module --template service --agent codex --dir . +nucleus adopt --dir . --agent codex ``` -Use `--template worker` for worker services and `--template library` for library packages. +If the repository is empty or has no Go module, ask the user how they want to initialize their own project first. Nucleus does not create service, worker, or library templates. For an existing service, inspect machine-readable facts before editing: @@ -34,28 +34,21 @@ Read `edit_surfaces`, `generated_freshness`, `capability_graph`, `verification.c - HTTP starts in `api/openapi.yaml`. - gRPC starts in `api/proto/*.proto`. - Error behavior starts in `api/errors.yaml`. -4. Apply Manifest-First edits for service identity, dependencies, capabilities, providers, and AI edit boundaries in `nucleus.yaml`. +4. Apply Manifest-First edits for service identity, dependencies, capability indexes, and AI edit boundaries in `nucleus.yaml`. 5. Write only paths allowed by `describe.edit_surfaces.allowed`. -6. For an existing capability scaffold, prefer the CLI entrypoint: - - ```bash - nucleus capability add --provider --dir . - ``` - - When MCP is available, call `get_capability_recipe` first and follow its `scaffold_command` / `bridge_candidates[].scaffold_command`. The CLI supports every registered Nucleus capability. Some providers generate deep wiring, while others create a manifest/provider placeholder that must be replaced with real bridge construction in `internal/app`. For PostgreSQL persistence, use `nucleus capability add sql --provider postgres --dir .`. `sql` is the capability; `postgres` is a provider/bridge choice. MongoDB is not SQL; use `mongo` capability flows when available. +6. For a capability or provider decision, keep provider, library, ORM, driver, SDK, DSN, and wiring choices out of `nucleus.yaml`. Record the technical choice as structured decision evidence under `.nucleus/decisions`, then implement the user-project interface in paths allowed by the project. 7. Regenerate instead of manually editing generated files: ```bash nucleus gen --dir . ``` -8. Verify with the service's declared commands, plus the Nucleus defaults when applicable: +8. Verify with the service's declared commands: ```bash nucleus validate --dir . nucleus lint --dir . --strict nucleus verify --dir . --json - go test ./... ``` 9. Repair only from evidence and only inside allowed edit surfaces: @@ -70,10 +63,10 @@ Read `edit_surfaces`, `generated_freshness`, `capability_graph`, `verification.c Stop and report the blocker instead of guessing when: -- `nucleus.yaml` is missing and required bootstrap inputs are unavailable. +- `nucleus.yaml` is missing and `nucleus adopt` cannot be run safely. - A requested edit falls outside `describe.edit_surfaces.allowed`. - The plan includes `blocked_edits[]` that would be required for the task. -- A Manifest-First capability/provider change requires `nucleus.yaml` but it is blocked; do not add provider imports or app wiring as a workaround. +- A Manifest-First capability change requires `nucleus.yaml` but it is blocked; do not add provider imports or app wiring as a workaround. - HTTP, gRPC, or error behavior is requested without contract edits. - Capability code is added without a matching `nucleus.yaml` declaration. - A generated file would need manual editing. diff --git a/examples/agent/codex/references/boundaries.md b/examples/agent/codex/references/boundaries.md index fef9e74..9b00f3a 100644 --- a/examples/agent/codex/references/boundaries.md +++ b/examples/agent/codex/references/boundaries.md @@ -2,18 +2,17 @@ ## Product Boundary -Nucleus is an AI-native Go service kernel: Contract/Manifest SSOT, thin service kernel, capability protocol, runtime adapters, and safe AI editing workflow. +Nucleus is an agent-native Go microservice protocol layer: contract facts, manifest indexes, decision evidence, graph inspection, edit surfaces, and safe AI editing workflow. -It is not a general application scaffold, UI wizard, business domain model, middleware bundle, or compatibility layer for unrelated frameworks. +It is not a general application scaffold, UI wizard, business domain model, middleware bundle, provider SDK collection, or compatibility layer for unrelated frameworks. ## Layer Rules -- `core/*`: standard library only. -- `cap/*`: capability interfaces, options, and noop implementations. -- `bridge/*`: optional provider implementations of capabilities. -- `runtime/*`: transport assembly over `core` and `cap`; do not import `bridge`. -- business `domain/*`: depend on domain ports and core concepts only; do not import transport frameworks or provider bridges. -- business `internal/app`: assemble runtime, capabilities, providers, and bridges. +- Project structure belongs to the user repository, not to Nucleus. +- Use the repository's own `AGENTS.md`, existing packages, and local conventions before creating paths. +- Keep domain code independent of provider SDKs and transport frameworks unless the project already owns that boundary. +- Keep provider/library/driver choices in project code and `.nucleus/decisions`, not in `nucleus.yaml`. +- Treat Nucleus runtime modules as optional project dependencies, not as dependencies introduced by Nucleus adoption. ## Contract-First @@ -27,13 +26,13 @@ Run generation after contract edits. Treat generated files as readonly unless a ## Manifest-First -Service identity, capabilities, dependencies, providers, and AI edit boundaries start in `nucleus.yaml`. +Service identity, contracts, capabilities, dependencies, and AI edit boundaries start in `nucleus.yaml`. -Capability imports and app wiring must match manifest declarations. Adding provider code without manifest declaration is drift. +Capability code should match manifest declarations. Adding provider code without manifest declaration or decision evidence is drift. -For business services, adding an existing capability means updating `nucleus.yaml` first, adding safe placeholder config, wiring providers only in `internal/app`, and verifying L004 with `nucleus lint --dir . --strict`. Use `nucleus capability add --provider --dir .` when the CLI has a scaffold. +For business services, adding a capability means updating `nucleus.yaml` with a v2 capability object, recording provider/library/driver choices as `.nucleus/decisions` evidence, and implementing the user-project interface inside allowed edit surfaces. -`sql` is the relational database capability. PostgreSQL, generic `database/sql`, and GORM are provider/bridge choices for `cap/sql`; MongoDB is a separate document-store capability and should use `cap/mongo`. +Relational storage, document storage, cache, message bus, metrics, tracing, and logging are semantic capability kinds. PostgreSQL, MySQL, GORM, Xorm, Redis, Kafka, Zap, OpenTelemetry, and similar choices are user/project decisions, not Nucleus manifest fields. ## Edit Surfaces diff --git a/examples/agent/codex/references/commands.md b/examples/agent/codex/references/commands.md index 554d7c2..c201814 100644 --- a/examples/agent/codex/references/commands.md +++ b/examples/agent/codex/references/commands.md @@ -7,6 +7,27 @@ nucleus describe --dir . --json --pretty ``` Use this before editing. Read edit surfaces, generated freshness, capability graph, endpoints, errors, dependencies, and verification commands. +Also read `symbol_graph` when available; its node IDs are the stable IDs used by trace, impact, and mark workflows. + +## Trace + +```bash +nucleus trace symbol --json +nucleus trace capability order_store --json +nucleus trace route "POST /orders" --json +``` + +Use this to inspect callers, callees, capability anchors, or route flow before editing. If a short symbol name is ambiguous, rerun with one of the returned stable symbol IDs. + +## Impact + +```bash +nucleus impact symbol --json +nucleus impact file internal/order/store.go --json +nucleus impact contract api/openapi.yaml --json +``` + +Use this before planning or reviewing a change. Read affected symbols, files, tests, routes, and edge confidence; do not treat inferred edges as certain facts. ## Plan @@ -14,7 +35,25 @@ Use this before editing. Read edit surfaces, generated freshness, capability gra nucleus plan --dir . --task "" --json --executable ``` -Use this to identify candidate edits, blocked edits, generated outputs, risk, and command order. +Use this to identify candidate edits, blocked edits, blocked locked decisions, generated outputs, risk, command order, and `impact_summary`. Read `blocked_decisions` before editing provider/library/driver code; if present, create and validate a supersede decision first. Read `impact_summary` before editing; empty or warning-only summaries mean you should run `trace` or `impact` explicitly. Treat `recipe_candidates` as source-labeled hints only; built-in candidates are read-only knowledge, not default provider choices. + +## Adopt + +```bash +nucleus adopt --dir . --agent codex +``` + +Use this to add a minimal protocol index to an existing Go project. It writes only `nucleus.yaml` and `.nucleus/*`. + +## Mark + +```bash +nucleus mark contract http --kind openapi --path api/openapi.yaml --json +nucleus mark capability order_store --kind relational_store --symbol OrderStore --json +nucleus mark verify "go test ./..." --json +``` + +Use this to declare manifest anchors. `mark` writes only `nucleus.yaml`; it does not generate implementation code, choose providers, or modify `go.mod/go.sum`. ## Generate @@ -24,13 +63,28 @@ nucleus gen --dir . Run after contract changes. Do not manually edit generated files that should be produced by this command. -## Capability Scaffold +## Capability Decisions ```bash -nucleus capability add --provider --dir . +nucleus mark capability order_store --kind relational_store --symbol OrderStore --json +nucleus decision validate .nucleus/decisions/.yaml --json ``` -Use this before manual provider wiring when adding a declared Nucleus capability. If MCP is available, first call `get_capability_recipe` and use its `scaffold_command` or provider-specific `bridge_candidates[].scaffold_command`. +Use `mark capability` for semantic anchors and `.nucleus/decisions` for provider/library/driver choices. Nucleus does not scaffold provider wiring or modify `go.mod/go.sum` for capabilities. + +## Decision Validate + +```bash +nucleus decision validate .nucleus/decisions/.yaml --json +nucleus decision accept .nucleus/decisions/.yaml --by human --json +nucleus decision supersede .nucleus/decisions/.yaml --json +``` + +Use `validate` after creating or changing decision evidence. It checks manifest capability references, decision hashes, supersedes hashes, impact edit surfaces, and verification commands. + +Use `accept` only after explicit human approval. It writes `status: accepted`, `locked: true`, `accepted_by`, `accepted_at`, and `decision_hash` into that one decision file. + +Use `supersede` before replacing a locked provider/library/driver choice. It fills `supersedes_hash` from the referenced prior decision; it does not accept the new decision. ## Validate And Lint @@ -47,7 +101,8 @@ Use `validate` for YAML/OpenAPI shape and `lint` for Nucleus rules such as contr nucleus verify --dir . --json ``` -Use this as the main machine-readable verification evidence. Also run any stricter commands listed under `describe.verification.commands`. +Use this as the main machine-readable verification evidence. It runs protocol checks and the commands declared under `nucleus.yaml` `verify.commands`; add missing project-owned checks with `nucleus mark verify ""`. +Read the `decision` step before changing provider/library/driver code; failed decision diagnostics mean you need to fix or supersede decision evidence first. ## Execute @@ -68,7 +123,8 @@ Use only from verification evidence. Do not repair through forbidden or readonly ## Report ```bash -nucleus report --dir . --platform --json +nucleus report --dir . --json ``` -Use when the task needs platform readiness metadata, quality metrics, or a structured handoff summary. +Use when the task needs local AI task quality metrics or a structured handoff summary. Do not treat it as platform readiness evidence. +Read `ai_quality.decision_quality` for locked decision drift and `ai_quality.recipe_candidate_usage` to see whether prior plans surfaced recipe candidates. diff --git a/examples/agent/codex/references/mcp.md b/examples/agent/codex/references/mcp.md index 632ed20..b0635aa 100644 --- a/examples/agent/codex/references/mcp.md +++ b/examples/agent/codex/references/mcp.md @@ -1,37 +1,86 @@ # Nucleus MCP Reference -Nucleus MCP is a tool layer over CLI/schema facts. Use it for quick access to service facts, not as a separate source of truth. +Nucleus MCP is a local stdio tool layer over CLI/schema facts. Use it for quick structured access to service facts, not as a separate source of truth. + +Start it from a service root: + +```bash +nucleus --dir . mcp --stdio +``` + +The server uses Content-Length framed JSON-RPC and currently supports `initialize`, `ping`, `tools/list`, and `tools/call`. ## Preferred Order -1. Use CLI/schema commands to understand the contract: +1. Use CLI/schema commands for durable evidence: ```bash nucleus describe --dir . --json --pretty nucleus plan --dir . --task "" --json --executable + nucleus verify --dir . --json ``` -2. Use MCP tools for targeted facts: - - describe service - - endpoints - - error catalog - - capability recipes and scaffold commands - - flow graph - - executable plan - - verification or repair wrappers +2. Use MCP tools for targeted local facts: + - `get_service_description` + - `get_edit_surfaces` + - `get_contracts` + - `get_capabilities` + - `trace_route` + - `trace_symbol` + - `trace_capability` + - `impact_symbol` + - `impact_file` + - `impact_contract` + - `find_symbol` + - `list_callers` + - `list_callees` + - `validate_decision` + - `list_decisions` + - `get_report` + - `build_plan` + - `list_recipes` + - `get_recipe` + +3. Return to CLI commands for generation, lint, verification, repair, execution, and report artifacts. + +## Result Envelope + +Every MCP `structuredContent` payload must be self-describing: + +- `result_kind` +- `schema_version` +- `schema_ref` +- `ok` +- `diagnostics` + +Tools that mirror a CLI command use that CLI result schema. MCP-only aggregate tools use `contract/schema/mcp-result.v1.schema.json`; recipe views use `contract/schema/recipe-result.v1.schema.json`. + +## Capability Decisions + +Use MCP capability or decision tools only as structured views over local facts. Capability tools can expose semantic kinds and existing symbols, but must not choose providers or scaffold implementation code. + +Read these fields when available: + +- declared capability `id`, `kind`, `intent`, and symbols +- decision evidence path and validation status +- affected graph nodes and edges +- allowed edit surfaces and blocked edits +- recipe candidates as reference knowledge only -3. Return to CLI commands for generation, lint, verification, repair, and report evidence. +## Recipe Boundary -## Capability Recipes +Recipes are read-only knowledge from project `.nucleus/recipes/*.yaml`, `.yml`, `.json`, or built-in data-only recipes. Project recipes override built-ins with the same id. -Use `get_capability_recipe` before adding or changing a capability. Read these fields: +Recipe tools must not: -- `scaffold_supported`: whether `nucleus capability add` knows this capability. -- `scaffold_command`: default CLI command for the capability. -- `bridge_candidates[].scaffold_command`: provider-specific CLI commands. -- `wiring`: layer boundary guidance for app-level provider injection. +- execute commands +- write files +- modify `nucleus.yaml` +- modify `.nucleus/decisions` +- modify `go.mod` or `go.sum` +- produce accepted or locked decisions -Run the scaffold command before manual provider wiring unless the command is unavailable or the manifest/edit surface blocks it. +Unknown recipe fields such as executable `commands` should fail validation. Suggested verification belongs under `suggest.verification`. ## Consistency Rule @@ -41,8 +90,8 @@ If MCP and CLI/schema disagree, trust CLI/schema and record the mismatch in the MCP tools must not bypass: -- Contract-First edits -- Manifest-First edits +- contract-first edits +- manifest-first edits - allowed edit surfaces - generated freshness - verification commands diff --git a/examples/agent/codex/references/workflow.md b/examples/agent/codex/references/workflow.md index d824ad5..3f5c78a 100644 --- a/examples/agent/codex/references/workflow.md +++ b/examples/agent/codex/references/workflow.md @@ -1,20 +1,18 @@ # Nucleus Workflow Reference -## Empty Repository +## Empty Or Existing Repository -Use CLI bootstrap only: +Use Nucleus adoption only after the user project exists: ```bash -nucleus init --name --module --template service --agent codex --dir . +nucleus adopt --dir . --agent codex ``` -Ask for only missing bootstrap inputs: +Ask for only missing adoption inputs: - service name -- Go module path -- template type: `service`, `worker`, or `library` -Do not inspect or copy local Nucleus examples, testdata, templates, or source checkouts to derive business code. +If the directory has no Go module or business code yet, ask the user how they want to initialize their own project. Do not use Nucleus examples, testdata, templates, or source checkouts to derive business code. ## Existing Service @@ -36,7 +34,7 @@ Use `describe` to determine current facts: - generated freshness - verification commands -Use `plan --executable` to determine the candidate write set. Treat `blocked_edits[]` as a hard stop unless the user explicitly changes the service edit boundaries. Do not continue by adding provider imports or wiring outside the manifest path. +Use `plan --executable` to determine the candidate write set. Treat `blocked_edits[]` as a hard stop unless the user explicitly changes the service edit boundaries. Do not continue by adding provider imports or wiring outside the allowed edit surface. ## HTTP Or gRPC Behavior Change @@ -55,14 +53,13 @@ Do not hand-write routes that drift from OpenAPI. Do not invent error codes outs Apply manifest edits before code: -1. Update `nucleus.yaml` capability declarations, dependencies, providers, or edit boundaries. -2. If MCP is available, call `get_capability_recipe` and use its `scaffold_command` or provider-specific `bridge_candidates[].scaffold_command`. -3. Prefer a scaffold command when available, for example `nucleus capability add sql --provider postgres --dir .`. -4. Add or adjust app wiring in the service assembly layer. -5. Keep `domain` independent of provider SDKs and transport frameworks. -6. Verify manifest/import consistency with `nucleus lint --dir . --strict`. +1. Update `nucleus.yaml` capability declarations, dependencies, or edit boundaries. +2. Record provider, library, ORM, driver, SDK, and wiring choices as structured decision evidence under `.nucleus/decisions`. +3. Add or adjust user-project interfaces and implementation wiring inside allowed edit surfaces. +4. Keep domain code independent of provider SDKs and transport frameworks unless the project already owns that boundary. +5. Verify manifest, contract, and import consistency with `nucleus lint --dir . --strict`. -SQL providers such as PostgreSQL and MySQL belong to `cap/sql`. MongoDB is a separate document-store capability and must not be modeled as SQL. +Do not write provider, library, driver, or ORM decisions into `nucleus.yaml`. Those are decisions, not protocol index fields. ## Fix Or Repair diff --git a/examples/deploy/docker/README.md b/examples/deploy/docker/README.md deleted file mode 100644 index 86e3937..0000000 --- a/examples/deploy/docker/README.md +++ /dev/null @@ -1,35 +0,0 @@ -# Nucleus 本地平台栈 - -该目录提供本地开发用 compose 示例,用于验证 Nucleus 能力接入协议和平台字段映射,不是生产部署模板。 - -## 启动 - -```bash -docker compose -f deploy/docker/docker-compose.yml up -d -``` - -或使用仓库 Make 目标: - -```bash -make docker-up -make docker-down -``` - -## 服务 - -| 服务 | 地址 | 用途 | -|------|------|------| -| Nacos | http://localhost:8848/nacos | 配置和注册示例 | -| Redis | localhost:6379 | 缓存、锁能力示例 | -| Kafka | localhost:9092 | MQ 能力示例 | -| OTel Collector | localhost:4317 / localhost:4318 | OTLP gRPC/HTTP | -| Jaeger | http://localhost:16686 | 本地 trace 查询 | -| Prometheus | http://localhost:9090 | 指标查询 | - -## 配置约束 - -- 不包含生产密钥,Nacos auth 在本地示例中关闭。 -- Redis 未设置密码,仅绑定本机开发端口。 -- Kafka 使用单节点 KRaft 模式,仅适合本地验证。 -- 生产环境应由平台注入 Secret、采样策略、持久化和网络策略。 - diff --git a/examples/deploy/docker/docker-compose.yml b/examples/deploy/docker/docker-compose.yml deleted file mode 100644 index d4d4a5f..0000000 --- a/examples/deploy/docker/docker-compose.yml +++ /dev/null @@ -1,96 +0,0 @@ -name: nucleus-platform-local - -services: - nacos: - image: nacos/nacos-server:v2.4.3 - container_name: nucleus-nacos - environment: - MODE: standalone - PREFER_HOST_MODE: hostname - NACOS_AUTH_ENABLE: "false" - ports: - - "8848:8848" - - "9848:9848" - networks: - - nucleus - - redis: - image: redis:7.2-alpine - container_name: nucleus-redis - command: ["redis-server", "--appendonly", "yes"] - ports: - - "6379:6379" - volumes: - - redis-data:/data - networks: - - nucleus - - kafka: - image: bitnami/kafka:3.7 - container_name: nucleus-kafka - environment: - ALLOW_PLAINTEXT_LISTENER: "yes" - KAFKA_CFG_NODE_ID: "1" - KAFKA_CFG_PROCESS_ROLES: controller,broker - KAFKA_CFG_CONTROLLER_QUORUM_VOTERS: 1@kafka:9093 - KAFKA_CFG_LISTENERS: PLAINTEXT://:9092,CONTROLLER://:9093 - KAFKA_CFG_ADVERTISED_LISTENERS: PLAINTEXT://localhost:9092 - KAFKA_CFG_LISTENER_SECURITY_PROTOCOL_MAP: CONTROLLER:PLAINTEXT,PLAINTEXT:PLAINTEXT - KAFKA_CFG_CONTROLLER_LISTENER_NAMES: CONTROLLER - KAFKA_CFG_INTER_BROKER_LISTENER_NAME: PLAINTEXT - ports: - - "9092:9092" - volumes: - - kafka-data:/bitnami/kafka - networks: - - nucleus - - jaeger: - image: jaegertracing/all-in-one:1.57 - container_name: nucleus-jaeger - environment: - COLLECTOR_OTLP_ENABLED: "true" - ports: - - "16686:16686" - networks: - - nucleus - - otel-collector: - image: otel/opentelemetry-collector-contrib:0.105.0 - container_name: nucleus-otel-collector - command: ["--config=/etc/otelcol/config.yaml"] - volumes: - - ./otel-collector-config.yaml:/etc/otelcol/config.yaml:ro - ports: - - "4317:4317" - - "4318:4318" - - "8889:8889" - depends_on: - - jaeger - networks: - - nucleus - - prometheus: - image: prom/prometheus:v2.53.0 - container_name: nucleus-prometheus - command: - - "--config.file=/etc/prometheus/prometheus.yml" - - "--storage.tsdb.retention.time=24h" - volumes: - - ./prometheus.yml:/etc/prometheus/prometheus.yml:ro - - prometheus-data:/prometheus - ports: - - "9090:9090" - depends_on: - - otel-collector - networks: - - nucleus - -networks: - nucleus: - name: nucleus-platform-local - -volumes: - redis-data: - kafka-data: - prometheus-data: diff --git a/examples/deploy/docker/otel-collector-config.yaml b/examples/deploy/docker/otel-collector-config.yaml deleted file mode 100644 index 1634dfa..0000000 --- a/examples/deploy/docker/otel-collector-config.yaml +++ /dev/null @@ -1,32 +0,0 @@ -receivers: - otlp: - protocols: - grpc: - endpoint: 0.0.0.0:4317 - http: - endpoint: 0.0.0.0:4318 - -processors: - batch: {} - -exporters: - debug: - verbosity: basic - otlp/jaeger: - endpoint: jaeger:4317 - tls: - insecure: true - prometheus: - endpoint: 0.0.0.0:8889 - -service: - pipelines: - traces: - receivers: [otlp] - processors: [batch] - exporters: [debug, otlp/jaeger] - metrics: - receivers: [otlp] - processors: [batch] - exporters: [debug, prometheus] - diff --git a/examples/deploy/docker/prometheus.yml b/examples/deploy/docker/prometheus.yml deleted file mode 100644 index ff4744c..0000000 --- a/examples/deploy/docker/prometheus.yml +++ /dev/null @@ -1,9 +0,0 @@ -global: - scrape_interval: 15s - evaluation_interval: 15s - -scrape_configs: - - job_name: otel-collector - static_configs: - - targets: ["otel-collector:8889"] - diff --git a/examples/library/README.md b/examples/library/README.md deleted file mode 100644 index c134c93..0000000 --- a/examples/library/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Nucleus Library Template - -Generated by: - -```bash -nucleus init --template library --name demo-lib --module example.com/demo-lib --dir ./demo-lib -``` - -The generated library is intentionally runtime-free and can pass `go test ./...` without Nucleus runtime imports. diff --git a/examples/service/README.md b/examples/service/README.md deleted file mode 100644 index a05359f..0000000 --- a/examples/service/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# Nucleus Service Template - -This directory documents the generated service template. It is not itself a -checked-in runnable generated service. - -Generate a runnable service with: - -```bash -nucleus init --template service --name demo-api --module example.com/demo-api --dir ./demo-api -``` - -The generated service includes: - -- `api/openapi.yaml` and `api/errors.yaml` as contract SSOT. -- `contract/gen` for contract facts and freshness markers. -- `internal/adapter/http/gen` for generated HTTP route binders and handler interfaces. -- `cmd//main.go` as process entry only. -- `internal/app`, `internal/config`, `internal/domain`, `internal/usecase`, `internal/adapter`, `internal/component`, and `internal/server` as handwritten service surfaces. -- `configs/config.example.yaml`, `docs/`, `deploy/`, and `test/integration/` scaffolding. -- `AGENTS.md` with business-service edit rules and top-level directory registration. - -From the generated service directory, run: - -```bash -nucleus validate --dir . -nucleus lint --dir . --strict -nucleus verify --dir . --json -go test ./... -``` - -See [Implementation Status](../../docs/implementation-status.md) for the current -module and adapter status. diff --git a/examples/worker/README.md b/examples/worker/README.md deleted file mode 100644 index f58c525..0000000 --- a/examples/worker/README.md +++ /dev/null @@ -1,9 +0,0 @@ -# Nucleus Worker Template - -Generated by: - -```bash -nucleus init --template worker --name demo-worker --module example.com/demo-worker --dir ./demo-worker -``` - -The generated worker includes `nucleus.yaml`, minimal contracts, and a worker entrypoint. From 69e06c640b7acdd9aa477aaa073393d271d534a2 Mon Sep 17 00:00:00 2001 From: xusihong Date: Fri, 3 Jul 2026 19:29:12 +0800 Subject: [PATCH 2/4] =?UTF-8?q?config(submodules):=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E5=AD=90=E6=A8=A1=E5=9D=97=E4=BB=93=E5=BA=93=E5=9C=B0=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将 cap 子模块 URL 从 github.com 更改为 anniext.cn - 将 contract 子模块 URL 从 github.com 更改为 anniext.cn - 将 core 子模块 URL 从 github.com 更改为 anniext.cn - 将 grpc 子模块 URL 从 github.com 更改为 anniext.cn - 将 http 子模块 URL 从 github.com 更改为 anniext.cn - 将 worker 子模块 URL 从 github.com 更改为 anniext.cn --- .gitmodules | 12 ++++++------ contract | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.gitmodules b/.gitmodules index cdde34d..eee2625 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,24 +1,24 @@ [submodule "cap"] path = cap - url = https://github.com/nucleuskit/cap.git + url = https://anniext.cn/nucleuskit/cap.git branch = main [submodule "contract"] path = contract - url = https://github.com/nucleuskit/contract.git + url = https://anniext.cn/nucleuskit/contract.git branch = main [submodule "core"] path = core - url = https://github.com/nucleuskit/core.git + url = https://anniext.cn/nucleuskit/core.git branch = main [submodule "runtime/grpc"] path = runtime/grpc - url = https://github.com/nucleuskit/grpc.git + url = https://anniext.cn/nucleuskit/grpc.git branch = main [submodule "runtime/http"] path = runtime/http - url = https://github.com/nucleuskit/http.git + url = https://anniext.cn/nucleuskit/http.git branch = main [submodule "runtime/worker"] path = runtime/worker - url = https://github.com/nucleuskit/worker.git + url = https://anniext.cn/nucleuskit/worker.git branch = main diff --git a/contract b/contract index 3eb5338..a8a1cd7 160000 --- a/contract +++ b/contract @@ -1 +1 @@ -Subproject commit 3eb5338fd875043d8cfdb7e6085cb19f3552246d +Subproject commit a8a1cd786fd0850720cb11f150d6cd1365159567 From 13ba9ddbb8e53cd42ec693ab9bd542257fa874df Mon Sep 17 00:00:00 2001 From: xusihong Date: Mon, 6 Jul 2026 16:08:56 +0800 Subject: [PATCH 3/4] refactor: narrow nucleus to agent-native protocol boundary --- .gitmodules | 20 ------------- README.md | 12 +++++--- cap | 1 - cmd/nucleus/internal/repair/repair_test.go | 29 +------------------ contract | 2 +- core | 1 - docs/implementation-status.md | 23 ++++++++------- docs/plans/2026-06-14-complete-gen-command.md | 2 +- runtime/grpc | 1 - runtime/http | 1 - runtime/worker | 1 - 11 files changed, 23 insertions(+), 70 deletions(-) delete mode 160000 cap delete mode 160000 core delete mode 160000 runtime/grpc delete mode 160000 runtime/http delete mode 160000 runtime/worker diff --git a/.gitmodules b/.gitmodules index eee2625..c5f5c08 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,24 +1,4 @@ -[submodule "cap"] - path = cap - url = https://anniext.cn/nucleuskit/cap.git - branch = main [submodule "contract"] path = contract url = https://anniext.cn/nucleuskit/contract.git branch = main -[submodule "core"] - path = core - url = https://anniext.cn/nucleuskit/core.git - branch = main -[submodule "runtime/grpc"] - path = runtime/grpc - url = https://anniext.cn/nucleuskit/grpc.git - branch = main -[submodule "runtime/http"] - path = runtime/http - url = https://anniext.cn/nucleuskit/http.git - branch = main -[submodule "runtime/worker"] - path = runtime/worker - url = https://anniext.cn/nucleuskit/worker.git - branch = main diff --git a/README.md b/README.md index a12181d..256a6c9 100644 --- a/README.md +++ b/README.md @@ -142,12 +142,16 @@ contract/inspect/ Source and contract inspection docs/ Concepts, ADRs, and plans ``` -The contract and narrowly scoped runtime packages are published as separate Go modules: +The root repository keeps only the protocol boundary in-tree. Runtime, +provider, ORM, transport, and storage implementations are project decisions, +not framework defaults: - `github.com/nucleuskit/contract` -- `github.com/nucleuskit/http` -- `github.com/nucleuskit/grpc` -- `github.com/nucleuskit/worker` + +Projects can still depend on any router, RPC library, worker runtime, database +driver, ORM, or provider SDK they choose. Nucleus only records the contract, +capability anchors, decision evidence, and generated protocol glue needed for +agents to understand and change the code safely. ## Roadmap diff --git a/cap b/cap deleted file mode 160000 index eb1efac..0000000 --- a/cap +++ /dev/null @@ -1 +0,0 @@ -Subproject commit eb1efac82a1cb7d16e182f1fe38286720b65f3b5 diff --git a/cmd/nucleus/internal/repair/repair_test.go b/cmd/nucleus/internal/repair/repair_test.go index b614f6b..b9ff98a 100644 --- a/cmd/nucleus/internal/repair/repair_test.go +++ b/cmd/nucleus/internal/repair/repair_test.go @@ -21,7 +21,7 @@ capabilities: - id: http kind: http `) - writeFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n\nrequire github.com/nucleuskit/http v0.0.0\n\nrequire (\n\tgithub.com/nucleuskit/cap v0.1.0-alpha.2 // indirect\n\tgithub.com/nucleuskit/core v0.1.0-alpha.2 // indirect\n)\n\n"+localRuntimeReplace(t)) + writeFile(t, dir, "go.mod", "module example.com/demo\n\ngo 1.26.3\n") writeFile(t, dir, "demo.go", "package demo\n") writeFile(t, dir, "internal/app/routes.go", `package app @@ -370,30 +370,3 @@ func sha256Hex(value string) string { sum := sha256.Sum256([]byte(value)) return hex.EncodeToString(sum[:]) } - -func runtimeHTTPReplace(t *testing.T) string { - t.Helper() - path, err := filepath.Abs("../../../../runtime/http") - if err != nil { - t.Fatal(err) - } - return filepath.ToSlash(path) -} - -func localRuntimeReplace(t *testing.T) string { - t.Helper() - return strings.Join([]string{ - "replace github.com/nucleuskit/http => " + runtimeHTTPReplace(t), - "replace github.com/nucleuskit/cap => " + localModulePath(t, "cap"), - "replace github.com/nucleuskit/core => " + localModulePath(t, "core"), - }, "\n\n") + "\n" -} - -func localModulePath(t *testing.T, rel string) string { - t.Helper() - path, err := filepath.Abs(filepath.Join("../../../../", rel)) - if err != nil { - t.Fatal(err) - } - return filepath.ToSlash(path) -} diff --git a/contract b/contract index a8a1cd7..6b98911 160000 --- a/contract +++ b/contract @@ -1 +1 @@ -Subproject commit a8a1cd786fd0850720cb11f150d6cd1365159567 +Subproject commit 6b98911bf39390aff336d5d74660ef37818b384a diff --git a/core b/core deleted file mode 160000 index 3c37082..0000000 --- a/core +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 3c37082c20c002d0fef7a533a335be8e9518e43f diff --git a/docs/implementation-status.md b/docs/implementation-status.md index 244120d..6877e1b 100644 --- a/docs/implementation-status.md +++ b/docs/implementation-status.md @@ -87,17 +87,18 @@ or become an accepted decision. `plan` exposes safe matches as policy. Recipe suggestions do not modify plan commands, generated outputs, provider decisions, dependencies, or locked decision state. -## Runtime Modules - -The runtime modules remain separate Go modules. They should be treated as -optional user/project dependencies, not as dependencies introduced by `adopt` or -capability commands. - -| Module | Current status | Notes | -| --- | --- | --- | -| `github.com/nucleuskit/http` | Existing runtime module | HTTP server, route registration, response envelopes, middleware, clients, CORS, SSE, static assets, and well-known metadata exist outside the protocol CLI. | -| `github.com/nucleuskit/grpc` | Existing runtime module | gRPC server/client wrappers, discovery resolver support, health/reflection options, and interceptor chains exist outside the protocol CLI. | -| `github.com/nucleuskit/worker` | Existing runtime module | Worker primitives exist outside the protocol CLI. | +## Runtime Boundary + +The root repository now keeps only the protocol and code-intelligence boundary. +Runtime modules are not part of the core project shape. HTTP routers, gRPC +stacks, worker runtimes, ORMs, drivers, and provider SDKs must be selected and +owned by the user project, then connected to Nucleus through contracts, +capability anchors, generated protocol glue, and decision evidence. + +`adopt`, `mark capability`, `plan`, `verify`, and `mcp` must not add runtime +dependencies, pick providers, rewrite `go.mod`, or assume a fixed project +layout. They should expose enough structured facts for an agent or user to make +the implementation decision explicitly. ## Adoption Guidance diff --git a/docs/plans/2026-06-14-complete-gen-command.md b/docs/plans/2026-06-14-complete-gen-command.md index 77fc6ff..147257a 100644 --- a/docs/plans/2026-06-14-complete-gen-command.md +++ b/docs/plans/2026-06-14-complete-gen-command.md @@ -40,7 +40,7 @@ - Test: `contract/gen/export_test.go` - [ ] Fix all module paths to `github.com/nucleuskit/*`. -- [ ] Ensure generated HTTP binder imports `github.com/nucleuskit/http`. +- [x] Obsolete: generated HTTP binders must not import a Nucleus runtime package; they expose project-owned registrar interfaces instead. - [ ] Keep reusable rendering/export logic in `contract/gen`; do not duplicate it in CLI root. - [ ] Add Go doc for exported APIs. - [ ] Ensure client export and optional generated directories can receive freshness markers from the CLI. diff --git a/runtime/grpc b/runtime/grpc deleted file mode 160000 index b824fcd..0000000 --- a/runtime/grpc +++ /dev/null @@ -1 +0,0 @@ -Subproject commit b824fcd86357688b9653c29a9b5f0c570325d860 diff --git a/runtime/http b/runtime/http deleted file mode 160000 index c8f1e59..0000000 --- a/runtime/http +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c8f1e5922b299b21f2261666a1921f7a8c1576c3 diff --git a/runtime/worker b/runtime/worker deleted file mode 160000 index d85a5d2..0000000 --- a/runtime/worker +++ /dev/null @@ -1 +0,0 @@ -Subproject commit d85a5d2c816c5a34a8f10888ae133320e92c8750 From 64ee22eb524cc1d1047db5b79f9a39d9157226cb Mon Sep 17 00:00:00 2001 From: xusihong Date: Mon, 6 Jul 2026 16:36:57 +0800 Subject: [PATCH 4/4] =?UTF-8?q?fix(config):=20=E6=9B=B4=E6=96=B0=E5=90=88?= =?UTF-8?q?=E7=BA=A6=E5=AD=90=E6=A8=A1=E5=9D=97=E7=9A=84URL=E5=9C=B0?= =?UTF-8?q?=E5=9D=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 将合约子模块的URL从 anniext.cn 更改为 github.com - 保持路径和分支配置不变 --- .gitmodules | 2 +- contract | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index c5f5c08..cbccf73 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,4 +1,4 @@ [submodule "contract"] path = contract - url = https://anniext.cn/nucleuskit/contract.git + url = https://github.com/nucleuskit/contract.git branch = main diff --git a/contract b/contract index 6b98911..85cbd4e 160000 --- a/contract +++ b/contract @@ -1 +1 @@ -Subproject commit 6b98911bf39390aff336d5d74660ef37818b384a +Subproject commit 85cbd4ed206c161e6672f76869f320715439f729