From 6cc4dbad1d129a3cc8c240adee65d1110a8c7672 Mon Sep 17 00:00:00 2001 From: Andrew Tereshko Date: Tue, 11 Aug 2026 10:41:28 +0300 Subject: [PATCH] Support ttl parameter when adding example with management API --- .agents/skills/gitnexus/gitnexus-cli/SKILL.md | 86 ++++ .../gitnexus/gitnexus-debugging/SKILL.md | 101 +++++ .../gitnexus/gitnexus-exploring/SKILL.md | 78 ++++ .../skills/gitnexus/gitnexus-guide/SKILL.md | 138 ++++++ .../gitnexus-impact-analysis/SKILL.md | 97 +++++ .../gitnexus/gitnexus-refactoring/SKILL.md | 121 ++++++ AGENTS.md | 45 ++ api/openapi.yaml | 4 + internal/server/server.go | 28 +- internal/server/server_example.go | 78 +++- internal/server/server_management.go | 10 +- internal/server/server_test.go | 74 ++++ internal/server/server_ttl_test.go | 408 ++++++++++++++++++ .../2026-08-11-add-example-ttl/.openspec.yaml | 2 + .../2026-08-11-add-example-ttl/design.md | 71 +++ .../2026-08-11-add-example-ttl/proposal.md | 28 ++ .../specs/management-api/spec.md | 41 ++ .../specs/mock-server-core/spec.md | 57 +++ .../2026-08-11-add-example-ttl/tasks.md | 51 +++ openspec/specs/management-api/spec.md | 15 + openspec/specs/mock-server-core/spec.md | 56 +++ scripts/analyze_scenario_coverage.py | 174 +++++--- test/management-api/management_api_test.go | 144 +++++++ 23 files changed, 1826 insertions(+), 81 deletions(-) create mode 100644 .agents/skills/gitnexus/gitnexus-cli/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-debugging/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-exploring/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-guide/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-impact-analysis/SKILL.md create mode 100644 .agents/skills/gitnexus/gitnexus-refactoring/SKILL.md create mode 100644 internal/server/server_ttl_test.go create mode 100644 openspec/changes/archive/2026-08-11-add-example-ttl/.openspec.yaml create mode 100644 openspec/changes/archive/2026-08-11-add-example-ttl/design.md create mode 100644 openspec/changes/archive/2026-08-11-add-example-ttl/proposal.md create mode 100644 openspec/changes/archive/2026-08-11-add-example-ttl/specs/management-api/spec.md create mode 100644 openspec/changes/archive/2026-08-11-add-example-ttl/specs/mock-server-core/spec.md create mode 100644 openspec/changes/archive/2026-08-11-add-example-ttl/tasks.md diff --git a/.agents/skills/gitnexus/gitnexus-cli/SKILL.md b/.agents/skills/gitnexus/gitnexus-cli/SKILL.md new file mode 100644 index 0000000..b73ea7e --- /dev/null +++ b/.agents/skills/gitnexus/gitnexus-cli/SKILL.md @@ -0,0 +1,86 @@ +--- +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 + +Commands below use `node .gitnexus/run.cjs ` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required. + +> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939). + +## Commands + +### analyze — Build or refresh the index + +```bash +node .gitnexus/run.cjs 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. | +| `--pdg` | Build the program-dependence layers used by `explain` and `pdg_query` (taint, CDG, and REACHING_DEF). | + +**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 +node .gitnexus/run.cjs 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 +node .gitnexus/run.cjs 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 +node .gitnexus/run.cjs 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 +node .gitnexus/run.cjs 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/.agents/skills/gitnexus/gitnexus-debugging/SKILL.md b/.agents/skills/gitnexus/gitnexus-debugging/SKILL.md new file mode 100644 index 0000000..4a33e58 --- /dev/null +++ b/.agents/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -0,0 +1,101 @@ +--- +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. query({search_query: ""}) → Find related execution flows +2. context({name: ""}) → See callers/callees/processes +3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow +4. cypher({statement: "MATCH path..."}) → Custom traces if needed +``` + +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. + +## Checklist + +``` +- [ ] Understand the symptom (error message, unexpected behavior) +- [ ] query for error text or related code +- [ ] Identify the suspect function from returned processes +- [ ] context to see callers and callees +- [ ] Trace execution flow via process resource if applicable +- [ ] cypher for custom call chain traces if needed +- [ ] Read source files to confirm root cause +``` + +## Debugging Patterns + +| Symptom | GitNexus Approach | +| -------------------- | ---------------------------------------------------------- | +| Error message | `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 | +| "How does A reach B?" | `trace` between the two symbols — shortest call chain in one call | + +## Tools + +**query** — find code related to error: + +``` +query({search_query: "payment validation error"}) +→ Processes: CheckoutFlow, ErrorHandling +→ Symbols: validatePayment, handlePaymentError, PaymentException +``` + +**context** — full context for a suspect: + +``` +context({name: "validatePayment"}) +→ Incoming calls: processCheckout, webhookHandler +→ Outgoing calls: verifyCard, fetchRates (external API!) +→ Processes: CheckoutFlow (step 3/7) +``` + +**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 +``` + +**trace** — shortest call chain between two symbols ("how does A reach B?"), one call instead of chaining `context` hops: + +``` +trace({ from: "processCheckout", to: "fetchRates" }) +→ status: ok, hopCount: 3 +→ hops: processCheckout → validatePayment → verifyCard → fetchRates +→ edges: CALLS (1.0), CALLS (0.95), CALLS (1.0) +``` + +When no path exists, `trace` reports the furthest reachable node — exactly where the chain breaks (dynamic dispatch, reflection, or an external boundary). + +## Example: "Payment endpoint returns 500 intermittently" + +``` +1. query({search_query: "payment error handling"}) + → Processes: CheckoutFlow, ErrorHandling + → Symbols: validatePayment, handlePaymentError + +2. 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/.agents/skills/gitnexus/gitnexus-exploring/SKILL.md b/.agents/skills/gitnexus/gitnexus-exploring/SKILL.md new file mode 100644 index 0000000..f483c2f --- /dev/null +++ b/.agents/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. query({search_query: ""}) → Find related execution flows +4. 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 `node .gitnexus/run.cjs analyze` in terminal. + +## Checklist + +``` +- [ ] READ gitnexus://repo/{name}/context +- [ ] query for the concept you want to understand +- [ ] Review returned processes (execution flows) +- [ ] 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 + +**query** — find execution flows related to a concept: + +``` +query({search_query: "payment processing"}) +→ Processes: CheckoutFlow, RefundFlow, WebhookHandler +→ Symbols grouped by flow with file locations +``` + +**context** — 360-degree view of a symbol: + +``` +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. query({search_query: "payment processing"}) + → CheckoutFlow: processPayment → validateCard → chargeStripe + → RefundFlow: initiateRefund → calculateRefund → processRefund +3. context({name: "processPayment"}) + → Incoming: checkoutHandler, webhookHandler + → Outgoing: validateCard, chargeStripe, saveTransaction +4. Read src/payments/processor.ts for implementation details +``` diff --git a/.agents/skills/gitnexus/gitnexus-guide/SKILL.md b/.agents/skills/gitnexus/gitnexus-guide/SKILL.md new file mode 100644 index 0000000..c966161 --- /dev/null +++ b/.agents/skills/gitnexus/gitnexus-guide/SKILL.md @@ -0,0 +1,138 @@ +--- +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 `node .gitnexus/run.cjs 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 | +| `trace` | Shortest path between two symbols — "how does A reach B?" in one call | +| `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) | +| `explain` | Persisted taint findings — source→sink data flows (needs `analyze --pdg`) | +| `pdg_query` | Control/data dependence — what gates X (CDG) / where Y flows (REACHING_DEF); needs `analyze --pdg` | +| `check` | Check graph invariants such as circular imports | +| `route_map` | API route map — which components/hooks fetch which endpoints, and the handler files that serve them | +| `shape_check` | Response-shape drift — keys each route returns vs keys its consumers access (flags MISMATCH) | +| `api_impact` | Pre-change report for an API route — consumers, middleware, shape mismatches, risk level | +| `tool_map` | MCP/RPC tool definitions and the files that handle them | +| `group_list` | List configured multi-repo groups, or one group's config | +| `group_sync` | Rebuild a group's Contract Registry (cross-repo HTTP contract links); run after `group.yaml` changes or member re-index | +| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) | + +### Paginating `list_repos` + +`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns: + +```jsonc +{ + "repositories": [ + { "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } } + ], + "pagination": { + "total": 437, + "limit": 50, + "offset": 0, + "returned": 50, + "hasMore": true, + "nextOffset": 50 + } +} +``` + +To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`: + +```text +list_repos {} → repos 1–50, nextOffset 50, hasMore true +list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true +… +list_repos { offset: 400 } → repos 401–437, hasMore false (done) +``` + +Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged. + +### Taint findings (`explain`) + +`explain` returns taint findings recorded by `gitnexus analyze --pdg` — intra-procedural `TAINTED` edges plus cross-function `TAINT_PATH` hops where the interprocedural taint phase found a function-level source→sink chain. Each finding includes a sink category (command-injection, code-injection, path-traversal, sql-injection, xss), source/sink lines, and the ordered hop path with the variable carried on each hop. + +- `explain {}` — enumerate all findings for the repo (bounded by `limit`, deterministic order) +- `explain { target: "src/vuln.ts" }` — findings in a file (suffix path match accepted) +- `explain { target: "runUserCommand" }` — findings in a function (resolved like `context`; ambiguous names return ranked candidates) + +A repo indexed without `--pdg` returns a clear "no taint layer" note. Caveats: closure/callback, property/field, and implicit flows are not modeled, and interprocedural findings are function-level `TAINT_PATH` hops rather than statement-level path proof, so the absence of a finding is **not** proof of safety. `SANITIZES` (sanitizer-kill) edges are queryable via `cypher`. + +### Control & data dependence (`pdg_query`) + +`pdg_query` reads the control/data-dependence layers `gitnexus analyze --pdg` records (CDG + REACHING_DEF, basic-block granular) — the control/data analog of `explain`. It is **always anchored** (a `target` file path or symbol, resolved like `context`) and has two modes: + +- `pdg_query { mode: "controls", target: "..." }` — CDG: "under what condition does X run?". Each edge is a controlling predicate block → dependent block with the branch sense (`'T'`/`'F'`) in `reason`; an edge into an early `return`/`throw` is flagged `guard: true` (guard-clause discovery — the sense depends on the predicate, so don't filter guards by a fixed label). +- `pdg_query { mode: "flows", target: "...", variable?: "..." }` — REACHING_DEF def→use edges within the function; pass `variable` to trace one binding. + +A repo indexed without `--pdg` returns a "no PDG layer" note (or "status unknown" when the layer can't be confirmed). Intra-procedural only — cross-function flow is taint's domain (`explain`). The raw CDG/REACHING_DEF edges are also queryable via `cypher`. See the `gitnexus-pdg-query` skill for the full query surface. + +### Shortest path between two symbols (`trace`) + +`trace` answers "how does A reach B?" in one call — the shortest directed path over `CALLS` (plus `HAS_METHOD`, so a class-rooted trace descends into its methods) instead of chaining 3–8 `context`/`impact` hops by hand. + +- `trace { from: "validateUser", to: "executeQuery" }` — shortest path between two symbols. +- Disambiguate common names with `from_uid`/`to_uid` (zero-ambiguity) or `from_file`/`to_file`; an ambiguous name returns ranked candidates. +- `maxDepth` (default 10, max 30) bounds the search; `includeTests` (default false) lets the traversal pass through test-file symbols. + +Returns ordered `hops` (each `{ name, filePath, startLine }`) and an aligned `edges[]` of `{ relType, confidence }`, so call hops and containment (`HAS_METHOD`) hops stay distinguishable. When no path exists it reports the **furthest** reachable node (where the chain breaks) and sets `truncated: true` if a traversal cap was hit first. Every result carries a `status`: `ok` / `no_path` / `ambiguous` / `not_found` / `error`. + +Cross-repo (experimental): pass `repo: "@groupName"` to trace across a group's member repos — the path may cross **one** `ContractLink` boundary (reported as a `CONTRACT_LINK` hop with the bridged contract in `crossings[]`). Omit `to` entirely to follow `from`'s outgoing HTTP call to whatever provider endpoint it lands on. Groups are configured via `group_list` / `group_sync`. + +## 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, Folder, Function, Class, Interface, Method, CodeElement, Community, Process, Route, Tool, plus language-specific types (Struct, Enum, Trait, Impl, Namespace, Module, …) and BasicBlock (`--pdg` indexes only). The full node list lives in `gitnexus://repo/{name}/schema`. +**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, CONTAINS, MEMBER_OF, HAS_METHOD, HAS_PROPERTY, ACCESSES, METHOD_OVERRIDES, METHOD_IMPLEMENTS, STEP_IN_PROCESS, HANDLES_ROUTE, FETCHES, HANDLES_TOOL, ENTRY_POINT_OF, WRAPS, QUERIES, INJECTS, plus `--pdg`-only types (CFG, REACHING_DEF, TAINTED, SANITIZES, TAINT_PATH, CDG — zero rows on a default index). + +Read `gitnexus://repo/{name}/schema` before writing Cypher — it is the authoritative schema for the indexed repo. + +```cypher +MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"}) +RETURN caller.name, caller.filePath +``` diff --git a/.agents/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.agents/skills/gitnexus/gitnexus-impact-analysis/SKILL.md new file mode 100644 index 0000000..45eb7ce --- /dev/null +++ b/.agents/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. impact({target: "X", direction: "upstream"}) → What depends on this +2. READ gitnexus://repo/{name}/processes → Check affected execution flows +3. detect_changes() → Map current git changes to affected flows +4. Assess risk and report to user +``` + +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. + +## Checklist + +``` +- [ ] 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 +- [ ] 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 + +**impact** — the primary tool for symbol blast radius: + +``` +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%] +``` + +**detect_changes** — git-diff based impact analysis: + +``` +detect_changes({scope: "staged"}) + +→ Changed: 5 symbols in 3 files +→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline +→ Risk: MEDIUM +``` + +## Example: "What breaks if I change validateUser?" + +``` +1. 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/.agents/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.agents/skills/gitnexus/gitnexus-refactoring/SKILL.md new file mode 100644 index 0000000..2dbb71c --- /dev/null +++ b/.agents/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. impact({target: "X", direction: "upstream"}) → Map all dependents +2. query({search_query: "X"}) → Find execution flows involving X +3. context({name: "X"}) → See all incoming/outgoing refs +4. Plan update order: interfaces → implementations → callers → tests +``` + +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. + +## Checklists + +### Rename Symbol + +``` +- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] Review graph edits (high confidence) and text_search edits (review carefully) +- [ ] If satisfied: rename({..., dry_run: false}) — apply edits +- [ ] detect_changes() — verify only expected files changed +- [ ] Run tests for affected processes +``` + +### Extract Module + +``` +- [ ] context({name: target}) — see all incoming/outgoing refs +- [ ] impact({target, direction: "upstream"}) — find all external callers +- [ ] Define new module interface +- [ ] Extract code, update imports +- [ ] detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +### Split Function/Service + +``` +- [ ] context({name: target}) — understand all callees +- [ ] Group callees by responsibility +- [ ] impact({target, direction: "upstream"}) — map callers to update +- [ ] Create new functions/services +- [ ] Update callers +- [ ] detect_changes() — verify affected scope +- [ ] Run tests for affected processes +``` + +## Tools + +**rename** — automated multi-file rename: + +``` +rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +→ 12 edits across 8 files +→ 10 graph edits (high confidence), 2 text_search edits (review) +→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] +``` + +**impact** — map all dependents first: + +``` +impact({target: "validateUser", direction: "upstream"}) +→ d=1: loginHandler, apiMiddleware, testUtils +→ Affected Processes: LoginFlow, TokenRefresh +``` + +**detect_changes** — verify your changes after refactoring: + +``` +detect_changes({scope: "all"}) +→ Changed: 8 files, 12 symbols +→ Affected processes: LoginFlow, TokenRefresh +→ Risk: MEDIUM +``` + +**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 rename for automated updates | +| Cross-area refs | Use detect_changes after to verify scope | +| String/dynamic refs | query to find them | +| External/public API | Version and deprecate properly | + +## Example: Rename `validateUser` to `authenticateUser` + +``` +1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) + → 12 edits: 10 graph (safe), 2 text_search (review) + → Files: validator.ts, login.ts, middleware.ts, config.json... + +2. Review text_search edits (config.json: dynamic reference!) + +3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) + → Applied 12 edits across 8 files + +4. detect_changes({scope: "all"}) + → Affected: LoginFlow, TokenRefresh + → Risk: MEDIUM — run tests for these flows +``` diff --git a/AGENTS.md b/AGENTS.md index 2e68eed..72e439f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,3 +44,48 @@ ``` - Use parameterized tests when the all test’s steps (AAA) are identical across all cases, and only the input and expected output differ. Otherwise, write separate tests. - Unit tests and integration tests placement and conventions are defined in [project standards](docs/project.md#testing-standarts) + + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **oasmock** (2049 symbols, 4322 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `query({search_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 `context({name: "symbolName"})`. +- For security review, `explain({target: "fileOrSymbol"})` lists taint findings (source→sink flows; needs `analyze --pdg`). + +## Never Do + +- NEVER edit a function, class, or method without first running `impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. +- NEVER commit changes without running `detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/oasmock/context` | Codebase overview, check index freshness | +| `gitnexus://repo/oasmock/clusters` | All functional areas | +| `gitnexus://repo/oasmock/processes` | All execution flows | +| `gitnexus://repo/oasmock/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` | + + diff --git a/api/openapi.yaml b/api/openapi.yaml index 94c8ae7..eae33a4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -101,6 +101,10 @@ components: type: boolean default: false description: If true, the example will be returned only once (when conditions are met) + ttl: + type: integer + minimum: 0 + description: Time-to-live in seconds. After this duration the example becomes unavailable and is removed from memory. 0 or omitted means no expiration. validate: type: boolean default: true diff --git a/internal/server/server.go b/internal/server/server.go index ca09283..b9b984a 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -83,6 +83,9 @@ type Server struct { config Config router *chi.Mux httpServer *http.Server + httpMu sync.Mutex + shutdownOnce sync.Once + shutdownResult error mappings []RouteMapping stateStore StateStore historyStore HistoryStore @@ -91,6 +94,8 @@ type Server struct { onceMu sync.RWMutex dynamicExamples map[string][]dynamicExample dyMu sync.RWMutex + sweepCtx context.Context + sweepCancel context.CancelFunc deps Dependencies rpcHandler *RpcHandler rpcMappings []*loader.RpcRouteMapping @@ -205,6 +210,9 @@ func NewWithDependencies(config Config, schemas []SchemaInfo, deps Dependencies, } s.setupRouter() + + s.sweepCtx, s.sweepCancel = context.WithCancel(context.Background()) + s.startTTLSweep() return s, nil } @@ -577,19 +585,31 @@ func (s *Server) extractPathParams(r *http.Request, mapping *RouteMapping) map[s func (s *Server) Start() error { addr := fmt.Sprintf(":%d", s.config.Port) slog.Info("Starting mock server", "address", addr) + s.httpMu.Lock() s.httpServer = &http.Server{ Addr: addr, Handler: s.router, } + s.httpMu.Unlock() return s.httpServer.ListenAndServe() } -// Shutdown gracefully shuts down the server. +// Shutdown gracefully shuts down the server. It is idempotent: subsequent +// calls are no-ops that return the result of the first shutdown. func (s *Server) Shutdown(ctx context.Context) error { - if s.httpServer != nil { - return s.httpServer.Shutdown(ctx) + if s.sweepCancel != nil { + s.sweepCancel() + } + s.httpMu.Lock() + hs := s.httpServer + s.httpMu.Unlock() + if hs == nil { + return nil } - return nil + s.shutdownOnce.Do(func() { + s.shutdownResult = hs.Shutdown(ctx) + }) + return s.shutdownResult } func applyPrefixRpc(prefix, path string) string { diff --git a/internal/server/server_example.go b/internal/server/server_example.go index 7338036..d15dd89 100644 --- a/internal/server/server_example.go +++ b/internal/server/server_example.go @@ -9,6 +9,7 @@ import ( "slices" "strconv" "strings" + "time" "github.com/getkin/kin-openapi/openapi3" "github.com/mamonth/oasmock/internal/extensions" @@ -17,6 +18,9 @@ import ( ) type dynamicExample struct { + onceID string + addedAt time.Time + ttl int once bool conditions map[string]any response struct { @@ -26,6 +30,62 @@ type dynamicExample struct { } } +// isExpired reports whether the example's TTL has elapsed. +// Examples without a TTL (ttl <= 0) never expire. +func isExpired(ex dynamicExample) bool { + if ex.ttl <= 0 { + return false + } + return !ex.addedAt.Add(time.Duration(ex.ttl) * time.Second).After(time.Now()) +} + +const ttlSweepInterval = time.Second + +// startTTLSweep launches the background goroutine that periodically removes +// expired dynamic examples from memory. +func (s *Server) startTTLSweep() { + go func() { + ticker := time.NewTicker(ttlSweepInterval) + defer ticker.Stop() + for { + select { + case <-s.sweepCtx.Done(): + return + case <-ticker.C: + s.sweepExpiredExamples() + } + } + }() +} + +// sweepExpiredExamples removes expired dynamic examples from storage and +// cleans up their onceExamples entries. +func (s *Server) sweepExpiredExamples() { + s.dyMu.Lock() + defer s.dyMu.Unlock() + + for key, examples := range s.dynamicExamples { + kept := make([]dynamicExample, 0, len(examples)) + for idx, ex := range examples { + if !isExpired(ex) { + kept = append(kept, ex) + continue + } + s.onceMu.Lock() + delete(s.onceExamples, ex.onceID) + s.onceMu.Unlock() + if s.config.Verbose { + slog.Debug("Removed expired dynamic example", "key", key, "idx", idx, "ttl", ex.ttl) + } + } + if len(kept) == 0 { + delete(s.dynamicExamples, key) + } else { + s.dynamicExamples[key] = kept + } + } +} + func (s *Server) selectResponse(mapping *RouteMapping, eval runtime.Evaluator) (string, *openapi3.Response) { if mapping.Responses == nil { return "", nil @@ -201,14 +261,23 @@ func (s *Server) selectDynamicExample(mapping *RouteMapping, eval runtime.Evalua } // Check once flag if ex.once { - onceID := fmt.Sprintf("dynamic:%s:%d", key, idx) - if s.isOnceUsed(onceID) { + if s.isOnceUsed(ex.onceID) { if s.config.Verbose { - slog.Debug("selectDynamicExample: example already used", "onceID", onceID) + slog.Debug("selectDynamicExample: example already used", "onceID", ex.onceID) } continue } } + // Check TTL expiry + if isExpired(ex) { + if s.config.Verbose { + slog.Debug("selectDynamicExample: example expired", + "idx", idx, + "ttl", ex.ttl, + "addedAt", ex.addedAt) + } + continue + } // Evaluate conditions if len(ex.conditions) > 0 { // Convert to ParamsMatch @@ -226,8 +295,7 @@ func (s *Server) selectDynamicExample(mapping *RouteMapping, eval runtime.Evalua } // Matched if ex.once { - onceID := fmt.Sprintf("dynamic:%s:%d", key, idx) - s.markOnceUsed(onceID) + s.markOnceUsed(ex.onceID) } if s.config.Verbose { slog.Debug("selectDynamicExample: returning matched example", "idx", idx) diff --git a/internal/server/server_management.go b/internal/server/server_management.go index ec537a2..68bfc52 100644 --- a/internal/server/server_management.go +++ b/internal/server/server_management.go @@ -26,6 +26,7 @@ var addExampleRequestSchema = gojsonschema.NewGoLoader(map[string]any{ }, "once": map[string]any{"type": "boolean"}, "validate": map[string]any{"type": "boolean"}, + "ttl": map[string]any{"type": "integer", "minimum": 0}, "conditions": map[string]any{ "type": "object", "additionalProperties": true, @@ -195,6 +196,7 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { Method string `json:"method"` Once bool `json:"once"` Validate bool `json:"validate"` + TTL int `json:"ttl"` Conditions map[string]any `json:"conditions"` Response struct { Code int `json:"code"` @@ -227,15 +229,19 @@ func (s *Server) handleAddExample(w http.ResponseWriter, r *http.Request) { // TODO: validate response body against OpenAPI schema if req.Validate is true // (skipped for now) // Create dynamic example + id := fmt.Sprintf("dynex-%d", time.Now().UnixNano()) example := dynamicExample{ + onceID: id, once: req.Once, conditions: req.Conditions, + ttl: req.TTL, + } + if req.TTL > 0 { + example.addedAt = time.Now() } example.response.code = req.Response.Code example.response.headers = req.Response.Headers example.response.body = req.Response.Body - // Generate a simple ID - id := fmt.Sprintf("dynex-%d", time.Now().UnixNano()) // Store under mapping key key := routeKey(targetMapping.Method, targetMapping.ChiPattern) if s.config.Verbose { diff --git a/internal/server/server_test.go b/internal/server/server_test.go index de6296c..bbd8eb7 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -63,6 +63,10 @@ func newMockedServerWithGeneratedMocks(t *testing.T, config Config) (*Server, *M server, err := NewWithDependencies(config, schemas, deps, nil, nil) require.NoError(t, err, "NewWithDependencies should not error") + t.Cleanup(func() { + _ = server.Shutdown(context.Background()) + }) + return server, routeProvider, stateStore, historyStore, expressionEvaluator, requestSourceFactory, stateSourceFactory, envSourceFactory, extensionProcessor } @@ -2034,6 +2038,76 @@ func TestStartAndShutdown(t *testing.T) { } } +/* +Scenario: Concurrent Start and Shutdown must not race on the HTTP server field +Given a server +When Start runs in a goroutine while Shutdown is called repeatedly +Then the server stops gracefully and Start returns http.ErrServerClosed +And no data race is reported by the race detector + +This is a race-regression test: it MUST be run with the race detector +(`go test -race`). Before the fix, Start assigned s.httpServer without +synchronization while Shutdown read it, which the race detector flagged +intermittently. The httpMu mutex in Start/Shutdown removes the race. + +Related spec scenarios: RS.MSC.1 +*/ +func TestConcurrentStartAndShutdownNoDataRace(t *testing.T) { + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + + startErrCh := make(chan error, 1) + go func() { + startErrCh <- server.Start() + }() + + // Hammer Shutdown so reads of s.httpServer overlap with Start's assignment. + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + _ = server.Shutdown(context.Background()) + } + + select { + case err := <-startErrCh: + assert.ErrorIs(t, err, http.ErrServerClosed, "Start should return after shutdown") + case <-time.After(5 * time.Second): + t.Fatal("Start did not return after shutdown") + } +} + +/* +Scenario: Shutdown is idempotent +Given a started server +When Shutdown is called twice +Then both calls return nil and only one graceful shutdown occurs + +Related spec scenarios: RS.MSC.1 +*/ +func TestShutdownIsIdempotent(t *testing.T) { + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + + startErrCh := make(chan error, 1) + go func() { + startErrCh <- server.Start() + }() + + // Wait for Start to have assigned httpServer before shutting down. + require.Eventually(t, func() bool { + server.httpMu.Lock() + defer server.httpMu.Unlock() + return server.httpServer != nil + }, 2*time.Second, 5*time.Millisecond) + + require.NoError(t, server.Shutdown(context.Background()), "first Shutdown should succeed") + require.NoError(t, server.Shutdown(context.Background()), "second Shutdown should be a no-op") + + select { + case err := <-startErrCh: + assert.ErrorIs(t, err, http.ErrServerClosed, "Start should return after shutdown") + case <-time.After(2 * time.Second): + t.Fatal("Start did not return after shutdown") + } +} + /* Scenario: Shutdown handles nil httpServer gracefully Given a server that hasn't been started (httpServer is nil) diff --git a/internal/server/server_ttl_test.go b/internal/server/server_ttl_test.go new file mode 100644 index 0000000..2f4fac4 --- /dev/null +++ b/internal/server/server_ttl_test.go @@ -0,0 +1,408 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/mamonth/oasmock/internal/runtime" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dynExample builds a dynamicExample for tests with the given ttl, addedAt and response body. +func dynExample(ttl int, addedAt time.Time, body any) dynamicExample { + return dynamicExample{ + addedAt: addedAt, + ttl: ttl, + response: struct { + code int + headers map[string]string + body any + }{ + code: 200, + body: body, + }, + } +} + +/* +Scenario: Selecting dynamic examples with TTL +Given a server with expired and non-expired dynamic examples +When selectDynamicExample is called +Then expired examples are skipped and the first non-expired example is returned + +Related spec scenarios: RS.MSC.40, RS.MSC.41 +*/ +func TestSelectDynamicExampleExpiry(t *testing.T) { + t.Parallel() + + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + key := "GET /test" + server.dynamicExamples = map[string][]dynamicExample{ + key: { + dynExample(1, time.Now().Add(-2*time.Second), "expired"), + dynExample(3600, time.Now(), "alive"), + }, + } + + mapping := &RouteMapping{Method: "GET", ChiPattern: "/test"} + ex, _ := server.selectDynamicExample(mapping, runtime.NewEvaluator()) + + require.NotNil(t, ex, "a non-expired example should be selected") + assert.Equal(t, "alive", ex.response.body, "expired example should be skipped") +} + +/* +Scenario: TTL=0 means no expiration +Given a server with a dynamic example that has ttl=0 and old addedAt +When selectDynamicExample is called +Then the example is still selected + +Related spec scenarios: RS.MSC.42 +*/ +func TestSelectDynamicExampleZeroTTLNeverExpires(t *testing.T) { + t.Parallel() + + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + key := "GET /test" + server.dynamicExamples = map[string][]dynamicExample{ + key: { + dynExample(0, time.Now().Add(-10*time.Hour), "no-ttl"), + }, + } + + mapping := &RouteMapping{Method: "GET", ChiPattern: "/test"} + ex, _ := server.selectDynamicExample(mapping, runtime.NewEvaluator()) + + require.NotNil(t, ex, "example without TTL should never expire") + assert.Equal(t, "no-ttl", ex.response.body) +} + +/* +Scenario: TTL and once combined +Given a server with a one-time dynamic example that has a TTL +When selectDynamicExample is called twice +Then the example is consumed on first match and not returned again + +Related spec scenarios: RS.MSC.43 +*/ +func TestSelectDynamicExampleOnceWithTTL(t *testing.T) { + t.Parallel() + + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + key := "GET /test" + ex := dynExample(3600, time.Now(), "once-ttl") + ex.once = true + ex.onceID = "once-ttl" + server.dynamicExamples = map[string][]dynamicExample{key: {ex}} + + mapping := &RouteMapping{Method: "GET", ChiPattern: "/test"} + + first, _ := server.selectDynamicExample(mapping, runtime.NewEvaluator()) + require.NotNil(t, first, "first match should be returned") + assert.Equal(t, "once-ttl", first.response.body) + + second, _ := server.selectDynamicExample(mapping, runtime.NewEvaluator()) + assert.Nil(t, second, "once example should not be returned again even with valid TTL") +} + +/* +Scenario: Sweeping expired examples from storage +Given a server with expired, non-expired, and no-TTL dynamic examples +When sweepExpiredExamples is called +Then only expired examples are removed, non-expired and no-TTL examples remain + +Related spec scenarios: RS.MSC.44, RS.MSC.46 +*/ +func TestSweepExpiredExamples(t *testing.T) { + t.Parallel() + + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server.dynamicExamples = map[string][]dynamicExample{ + "GET /a": { + dynExample(1, time.Now().Add(-2*time.Second), "expired"), + dynExample(3600, time.Now(), "alive"), + dynExample(0, time.Time{}, "no-ttl"), + }, + "GET /b": { + dynExample(0, time.Time{}, "persistent"), + }, + } + + server.sweepExpiredExamples() + + server.dyMu.RLock() + defer server.dyMu.RUnlock() + got := server.dynamicExamples + + require.Len(t, got["GET /a"], 2, "only the expired example should be removed") + assert.Equal(t, "alive", got["GET /a"][0].response.body) + assert.Equal(t, "no-ttl", got["GET /a"][1].response.body) + require.Len(t, got["GET /b"], 1, "no-TTL examples should be preserved") +} + +/* +Scenario: Cleaning onceExamples on sweep +Given a consumed one-time example that has expired +When sweepExpiredExamples is called +Then its onceExamples entry is removed + +Related spec scenarios: RS.MSC.45 +*/ +func TestSweepExpiredExamplesCleansOnceExamples(t *testing.T) { + t.Parallel() + + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + key := "GET /test" + ex := dynExample(1, time.Now().Add(-2*time.Second), "once-expired") + ex.once = true + ex.onceID = "once-expired" + server.dynamicExamples = map[string][]dynamicExample{key: {ex}} + server.onceExamples = map[string]bool{ex.onceID: true} + + server.sweepExpiredExamples() + + server.onceMu.RLock() + _, ok := server.onceExamples[ex.onceID] + server.onceMu.RUnlock() + assert.False(t, ok, "onceExamples entry should be removed for swept example") +} + +/* +Scenario: Consumed once example is not served again after a preceding example is swept +Given two one-time TTL examples on the same route, both already consumed +When the earlier example expires and is swept, shifting the later example's index +Then the later consumed example is still skipped by the once flag + +Related spec scenarios: RS.MSC.43 +*/ +func TestSweepDoesNotReuseConsumedOnceExample(t *testing.T) { + t.Parallel() + + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + key := "GET /test" + + expired := dynExample(1, time.Now().Add(-2*time.Second), "expired-once") + expired.once = true + expired.onceID = "once-A" + + alive := dynExample(3600, time.Now(), "alive-once") + alive.once = true + alive.onceID = "once-B" + + server.dynamicExamples = map[string][]dynamicExample{key: {expired, alive}} + + // Both examples are consumed before the expired one is swept. + server.markOnceUsed(expired.onceID) + server.markOnceUsed(alive.onceID) + + // The expired example is swept, so alive shifts from index 1 to index 0. + server.sweepExpiredExamples() + + mapping := &RouteMapping{Method: "GET", ChiPattern: "/test"} + ex, _ := server.selectDynamicExample(mapping, runtime.NewEvaluator()) + assert.Nil(t, ex, "consumed once example must not be served again after compaction") +} + +/* +Scenario: Adding an example with TTL +Given a server with a matching route mapping +When handleAddExample is called with ttl values (positive, zero, omitted) +Then the example is stored with the given ttl and addedAt set only for positive ttl + +Related spec scenarios: RS.MAPI.16, RS.MAPI.18, RS.MSC.39 +*/ +func TestHandleAddExampleWithTTL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + reqBody string + wantTTL int + wantAddedAtSet bool + }{ + { + name: "positive ttl", + reqBody: `{"path":"/test","response":{"code":200},"ttl":60}`, + wantTTL: 60, + wantAddedAtSet: true, + }, + { + name: "zero ttl", + reqBody: `{"path":"/test","response":{"code":200},"ttl":0}`, + wantTTL: 0, + }, + { + name: "omitted ttl", + reqBody: `{"path":"/test","response":{"code":200}}`, + wantTTL: 0, + }, + } + + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server.mappings = []RouteMapping{{ + Method: "GET", + Path: "/test", + Pattern: "/test", + ChiPattern: "/test", + }} + server.dynamicExamples = make(map[string][]dynamicExample) + + req := httptest.NewRequest("POST", "/_mock/examples", strings.NewReader(tt.reqBody)) + w := httptest.NewRecorder() + + server.handleAddExample(w, req) + + assert.Equal(t, http.StatusOK, w.Code, "expected success response") + + key := "GET /test" + server.dyMu.RLock() + defer server.dyMu.RUnlock() + require.Len(t, server.dynamicExamples[key], 1, "example should be stored") + stored := server.dynamicExamples[key][0] + assert.Equal(t, tt.wantTTL, stored.ttl, "ttl should be stored on example") + if tt.wantAddedAtSet { + assert.False(t, stored.addedAt.IsZero(), "addedAt should be set for ttl > 0") + } else { + assert.True(t, stored.addedAt.IsZero(), "addedAt should be zero for ttl <= 0") + } + }) + } +} + +/* +Scenario: TTL field validation — negative value +Given a server with a matching route mapping +When handleAddExample is called with a negative ttl +Then the server responds with HTTP 400 + +Related spec scenarios: RS.MAPI.17 +*/ +func TestHandleAddExampleRejectsNegativeTTL(t *testing.T) { + t.Parallel() + + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + server.mappings = []RouteMapping{{ + Method: "GET", + Path: "/test", + Pattern: "/test", + ChiPattern: "/test", + }} + server.dynamicExamples = make(map[string][]dynamicExample) + + req := httptest.NewRequest("POST", "/_mock/examples", strings.NewReader(`{"path":"/test","response":{"code":200},"ttl":-1}`)) + w := httptest.NewRecorder() + + server.handleAddExample(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code, "negative ttl should be rejected") +} + +/* +Scenario: Sweep starts on server startup and stops on server shutdown +Given a newly created server +When the background sweep runs +Then it removes expired examples from storage +And when the server shuts down the sweep is stopped + +Related spec scenarios: RS.MSC.48, RS.MSC.49 +*/ +func TestTTLSweepStartsAndStops(t *testing.T) { + t.Parallel() + + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + require.NotNil(t, server.sweepCancel, "sweep should be initialized on server creation") + + // Verify the background sweep goroutine runs: add an expired example and + // expect it to be removed by the background sweep. + key := "GET /test" + server.dynamicExamples = map[string][]dynamicExample{ + key: {dynExample(1, time.Now().Add(-2*time.Second), "expired")}, + } + + require.Eventually(t, func() bool { + server.dyMu.RLock() + defer server.dyMu.RUnlock() + _, ok := server.dynamicExamples[key] + return !ok + }, 3*time.Second, 50*time.Millisecond, "sweep goroutine should remove the expired example") + + // Shutdown cancels the sweep. + require.NoError(t, server.Shutdown(context.Background())) + assert.Equal(t, context.Canceled, server.sweepCtx.Err(), "sweep context should be cancelled on shutdown") +} + +/* +Scenario: Concurrent selection and TTL sweep must not race +Given a route populated with expired and non-expired dynamic examples +When selectDynamicExample and sweepExpiredExamples run concurrently +Then the sweep must not mutate the slice being iterated by selection +And no data race is reported by the race detector + +This is a race-regression test: it MUST be run with the race detector +(`go test -race`). Before the fix, sweepExpiredExamples compacted the example +slice in place (reusing the backing array via `examples[:0]`), racing with +selectDynamicExample, which iterates the slice after copying its header under +RLock. The fresh-slice allocation in sweepExpiredExamples removes the race. + +Related spec scenarios: RS.MSC.41, RS.MSC.44 +*/ +func TestConcurrentSelectAndSweepNoDataRace(t *testing.T) { + server, _, _, _, _, _, _, _, _ := newMockedServerWithGeneratedMocks(t, Config{}) + + key := "GET /test" + examples := make([]dynamicExample, 0, 64) + for i := range 64 { + if i%2 == 0 { + examples = append(examples, dynExample(1, time.Now().Add(-2*time.Second), i)) + } else { + examples = append(examples, dynExample(3600, time.Now(), i)) + } + } + server.dynamicExamples = map[string][]dynamicExample{key: examples} + + mapping := &RouteMapping{Method: "GET", ChiPattern: "/test"} + + stop := make(chan struct{}) + var wg sync.WaitGroup + for range 8 { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + server.selectDynamicExample(mapping, runtime.NewEvaluator()) + } + } + }() + } + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-stop: + return + default: + server.sweepExpiredExamples() + } + } + }() + + time.Sleep(2 * time.Second) + close(stop) + wg.Wait() +} diff --git a/openspec/changes/archive/2026-08-11-add-example-ttl/.openspec.yaml b/openspec/changes/archive/2026-08-11-add-example-ttl/.openspec.yaml new file mode 100644 index 0000000..d7bc011 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-add-example-ttl/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-10 diff --git a/openspec/changes/archive/2026-08-11-add-example-ttl/design.md b/openspec/changes/archive/2026-08-11-add-example-ttl/design.md new file mode 100644 index 0000000..8cf8844 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-add-example-ttl/design.md @@ -0,0 +1,71 @@ +## Context + +The dynamic example system (`internal/server/server_example.go`) stores examples in `map[string][]dynamicExample` keyed by route. Examples are matched against incoming requests in `selectDynamicExample` with optional `once` flag tracking via `onceExamples map[string]bool`. Currently, there is no time-based expiration — examples live until consumed (once) or indefinitely. + +The user requires a CPU-friendly TTL mechanism with zero per-request overhead beyond a timestamp comparison, and memory usage may spike temporarily but must eventually be reclaimed. + +## Goals / Non-Goals + +**Goals:** +- Allow users to specify `ttl` (seconds) when adding a dynamic example via `POST /_mock/examples` +- Expired examples SHALL be skipped during request matching (lazy check) +- A background goroutine SHALL periodically sweep and remove expired examples from storage +- Each removed example SHALL be logged at debug level + +**Non-Goals:** +- TTL for static (OpenAPI spec-defined) examples +- Per-example configurable sweep intervals +- Persistent storage of TTL values +- Configurable sweep tick via CLI +- A `DELETE` endpoint for manual example removal + +## Decisions + +### 1. Two-phase expiry: lazy check + periodic sweep + +**Choice**: Check `addedAt + ttl > now` in `selectDynamicExample` (lazy), AND run a background goroutine with a 1-second `time.Ticker` to sweep expired entries. + +**Rationale**: Lazy check prevents an expired example from ever being returned to a client (correctness). The sweep ensures memory is reclaimed for expired+unused examples (memory hygiene). A 1-second tick is low-frequency enough to avoid CPU overhead while keeping stale data lifetime bounded. + +**Alternatives considered**: +- Timer-per-example (`time.AfterFunc`): O(n) goroutines for n examples, high memory and scheduler pressure. +- Sweep-only (no lazy check): A just-expired example could be returned between sweeps — violates correctness. +- Lazy-only (no sweep): `dynamicExamples` slices and `onceExamples` entries never cleaned up — unbounded memory growth. + +### 2. Single background goroutine with coarse-grained iteration + +**Choice**: One goroutine per `Server` instance. Each tick: acquire `dyMu.Lock()`, iterate route keys, filter expired entries, compact slices, hold `onceMu.Lock()` briefly to clean `onceExamples`, then release. + +**Rationale**: Simplifies lifecycle management (start/stop via context cancellation). Coarse-grained locking means the sweep holds the write lock for a few milliseconds while iterating — negligible impact on request-serving goroutines that use `RLock()` for both `dyMu` and `onceMu`. + +**Alternatives considered**: +- Per-key goroutine: Overkill for typical usage (tens to hundreds of examples). +- `sync.Map` with atomic deletion: No ordering guarantee for slices — iteration order matters for example precedence. + +### 3. Timestamp stored per example, not per slice + +**Choice**: `dynamicExample` gains `addedAt time.Time` field set at creation time. + +**Rationale**: Each example has its own TTL; storing per-example is the natural granularity. No shared expiration timestamp needed. + +### 4. `onceExamples` cleanup on TTL sweep + +**Choice**: When an expired dynamic example is swept, also delete its corresponding `onceExamples` entry. + +**Rationale**: Prevents `onceExamples` from accumulating entries for examples that no longer exist, keeping the map bounded. + +### 5. Debug logging + +**Choice**: Use `slog.Debug` (existing `log/slog` package) to log each removed example with route key, index, and original TTL. + +**Rationale**: Consistent with existing verbose logging pattern in `selectDynamicExample` and `handleAddExample`. No new logging dependency. + +## Risks / Trade-offs + +- **[Risk]**: Sweep holds `dyMu.Lock()` while iterating routes — could block write operations for the duration of the sweep. + - **Mitigation**: Sweep iterates only populated keys (missed keys in `dynamicExamples` map are skipped). Under typical loads (hundreds of entries), sweep completes in microseconds. If `dynamicExamples` grows to thousands, the lazy check still prevents expired examples from being served. + +- **[Risk]**: TTL grain is 1 second (tick interval); an example added with ttl=1 could live for up to 2 seconds before sweep removes it. + - **Mitigation**: Lazy check handles correctness (never returns expired). Sweep handles memory cleanup (bounded lateness is acceptable). + +- **[Trade-off]**: Memory usage from expired-but-not-yet-swept examples is temporary but bounded to max 1 tick interval worth of additions per route. diff --git a/openspec/changes/archive/2026-08-11-add-example-ttl/proposal.md b/openspec/changes/archive/2026-08-11-add-example-ttl/proposal.md new file mode 100644 index 0000000..c22722b --- /dev/null +++ b/openspec/changes/archive/2026-08-11-add-example-ttl/proposal.md @@ -0,0 +1,28 @@ +## Why + +Dynamic examples added via the management API currently live indefinitely or until matched once (`once: true`). There is no time-based expiration, which limits testing scenarios where mock data should only be valid for a specific window — e.g., simulating short-lived tokens, time-limited resources, or eventual consistency patterns. + +## What Changes + +- Add optional `ttl` (time-to-live) field to `AddExampleRequest` — a non-negative integer in seconds. Zero or omitted means no expiration. +- Store creation timestamp alongside dynamic examples on addition. +- Expired examples are skipped during request matching (excluded from selection, same as `once`-consumed examples). +- A background goroutine periodically sweeps expired examples from the `dynamicExamples` map and cleans up their `onceExamples` entries, logging each removal at debug level. +- The sweep interval is fixed (e.g., 1 second) with low-resource design: a single timer tick, coarse-grained locking per route key, no per-request overhead. + +## Capabilities + +### New Capabilities +None + +### Modified Capabilities +- `management-api`: `AddExampleRequest` gains optional `ttl` field (non-negative integer seconds, 0 = no expiration) +- `mock-server-core`: Dynamic example TTL expiration (lazy skip during selection) and background cleanup goroutine that removes expired entries + +## Impact + +- **API**: `POST /_mock/examples` accepts new optional `ttl` field; `AddExampleRequest` schema updated +- **Data structures**: `dynamicExample` struct gains `addedAt` timestamp; `Server` struct gains cleanup goroutine control +- **Concurrency**: Existing `dyMu` (RWMutex) and `onceMu` (RWMutex) protect cleanup; no new mutex needed +- **Performance**: Per-request path unaffected (expiry check is a single `time.Now().After()` comparison, same cost as existing `once` check); background sweep is lightweight with configurable tick +- **Memory**: Cleanup ensures `dynamicExamples` slices and `onceExamples` map don't grow unbounded for TTL examples diff --git a/openspec/changes/archive/2026-08-11-add-example-ttl/specs/management-api/spec.md b/openspec/changes/archive/2026-08-11-add-example-ttl/specs/management-api/spec.md new file mode 100644 index 0000000..851d533 --- /dev/null +++ b/openspec/changes/archive/2026-08-11-add-example-ttl/specs/management-api/spec.md @@ -0,0 +1,41 @@ +## MODIFIED Requirements + +### Requirement: Add example endpoint +The mock server SHALL provide `POST /_mock/examples` to add a custom mock example. + +#### Scenario RS.MAPI.2: Adding a simple example +- **WHEN** a POST request is sent to `/_mock/examples` with a valid `AddExampleRequest` JSON body +- **THEN** the server stores the example and responds with `AddExampleResponse` containing success and an example ID + +#### Scenario RS.MAPI.3: Adding a conditional example +- **WHEN** the request includes `conditions` object with runtime expressions +- **THEN** the server stores the example and will match it only when conditions are satisfied + +#### Scenario RS.MAPI.4: Adding a one-time example +- **WHEN** the request includes `once: true` +- **THEN** the server stores the example as one-time (disposed after first match) + +#### Scenario RS.MAPI.5: Adding an example with validation disabled +- **WHEN** the request includes `validate: false` +- **THEN** the server does not validate the example data against the OpenAPI schema + +#### Scenario RS.MAPI.6: Invalid request body +- **WHEN** the request body is missing required fields or malformed +- **THEN** the server responds with HTTP 400 + +## ADDED Requirements + +### Requirement: Add example with TTL +The `AddExampleRequest` SHALL accept an optional `ttl` field (integer seconds). + +#### Scenario RS.MAPI.16: Adding an example with TTL +- **WHEN** a POST request is sent to `/_mock/examples` with `ttl: 1` +- **THEN** the server accepts the request and stores the TTL value alongside the example + +#### Scenario RS.MAPI.17: TTL field validation — negative value +- **WHEN** a POST request is sent to `/_mock/examples` with `ttl: -1` +- **THEN** the server responds with HTTP 400 + +#### Scenario RS.MAPI.18: TTL field is optional (omitted) +- **WHEN** a POST request is sent to `/_mock/examples` without a `ttl` field +- **THEN** the server accepts the request and the example has no expiration diff --git a/openspec/changes/archive/2026-08-11-add-example-ttl/specs/mock-server-core/spec.md b/openspec/changes/archive/2026-08-11-add-example-ttl/specs/mock-server-core/spec.md new file mode 100644 index 0000000..29cb98b --- /dev/null +++ b/openspec/changes/archive/2026-08-11-add-example-ttl/specs/mock-server-core/spec.md @@ -0,0 +1,57 @@ +## ADDED Requirements + +### Requirement: Dynamic example TTL expiration +Dynamic examples added with a TTL SHALL be skipped during selection once the TTL has elapsed. + +#### Scenario RS.MSC.39: Storing an example with TTL +- **WHEN** a dynamic example is added with a `ttl` in seconds +- **THEN** the server stores the example with its creation timestamp and the specified TTL + +#### Scenario RS.MSC.40: Selecting a non-expired TTL example +- **WHEN** a dynamic example has a TTL that has not yet elapsed +- **AND** a request matches that example's route +- **THEN** the example is selected and returned normally + +#### Scenario RS.MSC.41: Skipping an expired TTL example +- **WHEN** a dynamic example's TTL has elapsed +- **AND** a request matches that example's route +- **THEN** the expired example is skipped during selection and not returned + +#### Scenario RS.MSC.42: TTL=0 means no expiration +- **WHEN** a dynamic example is added with `ttl: 0` or no TTL +- **THEN** the example never expires and behaves as before this change + +#### Scenario RS.MSC.43: TTL and once combined +- **WHEN** a dynamic example has both a TTL and `once: true` +- **AND** the example is matched by a request before the TTL elapses +- **THEN** the example is consumed via the once flag and not returned again + +### Requirement: Background TTL cleanup +The server SHALL periodically remove expired dynamic examples from memory via a background goroutine. + +#### Scenario RS.MSC.44: Sweeping expired examples from storage +- **WHEN** a dynamic example with a TTL has expired +- **AND** the background sweep runs +- **THEN** the expired example is removed from the dynamic examples storage + +#### Scenario RS.MSC.45: Cleaning onceExamples on sweep +- **WHEN** an expired TTL example is removed by the background sweep +- **THEN** its corresponding onceExamples entry (if any) is also removed + +#### Scenario RS.MSC.46: Preserving non-expired examples +- **WHEN** the background sweep runs +- **AND** examples have not yet expired or have no TTL +- **THEN** those examples remain in storage + +#### Scenario RS.MSC.47: Debug logging on removal +- **WHEN** an expired example is removed by the background sweep +- **AND** verbose mode is enabled +- **THEN** a debug-level log entry is emitted containing the route key and example details + +#### Scenario RS.MSC.48: Sweep starts on server creation +- **WHEN** a server instance is created +- **THEN** the background goroutine for TTL sweeping is launched + +#### Scenario RS.MSC.49: Sweep stops on server shutdown +- **WHEN** the server shuts down +- **THEN** the background goroutine for TTL sweeping is stopped diff --git a/openspec/changes/archive/2026-08-11-add-example-ttl/tasks.md b/openspec/changes/archive/2026-08-11-add-example-ttl/tasks.md new file mode 100644 index 0000000..cbceada --- /dev/null +++ b/openspec/changes/archive/2026-08-11-add-example-ttl/tasks.md @@ -0,0 +1,51 @@ +## 1. Data Model + +- [x] 1.1 Add `addedAt time.Time` field to `dynamicExample` struct in `internal/server/server_example.go` +- [x] 1.2 Add `sweepCtx context.Context` and `sweepCancel context.CancelFunc` fields to `Server` struct in `internal/server/server.go` for goroutine lifecycle control + +## 2. API Schema + +- [x] 2.1 Add `ttl` field (integer, non-negative, optional, description) to `AddExampleRequest` schema in `api/openapi.yaml` +- [x] 2.2 Update `addExampleRequestSchema` JSON Schema in `internal/server/server_management.go` to include optional `ttl` property + +## 3. Add Example Handler + +- [x] 3.1 Parse `ttl` field from request body in `handleAddExample` (`internal/server/server_management.go`) +- [x] 3.2 Validate `ttl >= 0`; reject negative values with HTTP 400 +- [x] 3.3 Set `example.addedAt = time.Now()` when TTL is specified (> 0); use zero value when TTL is 0 or omitted + +## 4. Lazy Expiry Check + +- [x] 4.1 Implement `isExpired(dynamicExample) bool` helper in `internal/server/server_example.go` +- [x] 4.2 Add expired check in `selectDynamicExample` loop — skip expired examples same as `once`-consumed ones +- [x] 4.3 Log skipped expired examples at debug level (consistent with existing verbose logging pattern) + +## 5. Background Sweep + +- [x] 5.1 Implement `sweepExpiredExamples()` method on `Server` — iterates `dynamicExamples` under `dyMu.Lock()`, removes expired entries, cleans `onceExamples` under `onceMu.Lock()` +- [x] 5.2 Implement `startTTLSweep()` — launches a goroutine with `time.Ticker` (1s interval), selects on ticker and `sweepCtx.Done()` +- [x] 5.3 Log each removed example at debug level with route key, index, and TTL +- [x] 5.4 Compact remaining slice after removing expired entries (fresh-slice allocation, not in-place — in-place compaction races with `selectDynamicExample`'s slice iteration; covered by `TestConcurrentSelectAndSweepNoDataRace`) + +## 6. Server Lifecycle + +- [x] 6.1 Initialize sweep context/cancel in `NewWithDependencies` (`internal/server/server.go`) +- [x] 6.2 Call `startTTLSweep()` at the end of `NewWithDependencies` after `setupRouter` +- [x] 6.3 Call `sweepCancel()` in `Server.Shutdown()` before HTTP server shutdown +- [x] 6.4 Make `Server.Shutdown()` idempotent (`shutdownOnce`) and race-safe with `Start()` (guard `httpServer` with `httpMu`); add race-regression tests (`TestConcurrentStartAndShutdownNoDataRace`, `TestShutdownIsIdempotent`) and call `Shutdown` via `t.Cleanup` in `newMockedServerWithGeneratedMocks` + +## 7. Unit Tests + +- [x] 7.1 Test `selectDynamicExample` skips expired examples and returns non-expired ones (scenarios RS.MSC.40, RS.MSC.41, RS.MSC.42) +- [x] 7.2 Test TTL and `once` flag interaction — consumed example skipped regardless of TTL (scenario RS.MSC.43) +- [x] 7.3 Test `sweepExpiredExamples` removes only expired entries, preserves non-expired and no-TTL examples (scenarios RS.MSC.44, RS.MSC.46) +- [x] 7.4 Test `sweepExpiredExamples` cleans `onceExamples` entries for swept examples (scenario RS.MSC.45) +- [x] 7.5 Test `handleAddExample` rejects negative `ttl` (scenario RS.MAPI.17) +- [x] 7.6 Test `handleAddExample` accepts positive `ttl`, zero `ttl`, and omitted `ttl` (scenarios RS.MAPI.16, RS.MAPI.18) +- [x] 7.7 Test sweep goroutine starts on server creation and stops on shutdown (scenarios RS.MSC.48, RS.MSC.49) + +## 8. Integration Tests + +- [x] 8.1 Test full flow: add example with TTL via API, verify it's returned before expiry, verify it's not returned after expiry +- [x] 8.2 Test that debug logs contain TTL expiry messages when verbose mode is enabled (scenario RS.MSC.47) +- [x] 8.3 Update existing integration tests if `AddExampleRequest` validation logic changes diff --git a/openspec/specs/management-api/spec.md b/openspec/specs/management-api/spec.md index 6e0d9e5..a109e7a 100644 --- a/openspec/specs/management-api/spec.md +++ b/openspec/specs/management-api/spec.md @@ -34,6 +34,21 @@ The mock server SHALL provide `POST /_mock/examples` to add a custom mock exampl - **WHEN** the request body is missing required fields or malformed - **THEN** the server responds with HTTP 400 +### Requirement: Add example with TTL +The `AddExampleRequest` SHALL accept an optional `ttl` field (integer seconds). + +#### Scenario RS.MAPI.16: Adding an example with TTL +- **WHEN** a POST request is sent to `/_mock/examples` with `ttl: 1` +- **THEN** the server accepts the request and stores the TTL value alongside the example + +#### Scenario RS.MAPI.17: TTL field validation — negative value +- **WHEN** a POST request is sent to `/_mock/examples` with `ttl: -1` +- **THEN** the server responds with HTTP 400 + +#### Scenario RS.MAPI.18: TTL field is optional (omitted) +- **WHEN** a POST request is sent to `/_mock/examples` without a `ttl` field +- **THEN** the server accepts the request and the example has no expiration + ### Requirement: Request history endpoint The mock server SHALL provide `GET /_mock/requests` to retrieve request history. diff --git a/openspec/specs/mock-server-core/spec.md b/openspec/specs/mock-server-core/spec.md index bba619b..d52e35a 100644 --- a/openspec/specs/mock-server-core/spec.md +++ b/openspec/specs/mock-server-core/spec.md @@ -193,3 +193,59 @@ The mock server SHALL log detailed request/response information when verbose mod #### Scenario RS.MSC.38: Verbose mode enabled via environment - **WHEN** OASMOCK_VERBOSE=true - **THEN** the server enables verbose logging + +### Requirement: Dynamic example TTL expiration +Dynamic examples added with a TTL SHALL be skipped during selection once the TTL has elapsed. + +#### Scenario RS.MSC.39: Storing an example with TTL +- **WHEN** a dynamic example is added with a `ttl` in seconds +- **THEN** the server stores the example with its creation timestamp and the specified TTL + +#### Scenario RS.MSC.40: Selecting a non-expired TTL example +- **WHEN** a dynamic example has a TTL that has not yet elapsed +- **AND** a request matches that example's route +- **THEN** the example is selected and returned normally + +#### Scenario RS.MSC.41: Skipping an expired TTL example +- **WHEN** a dynamic example's TTL has elapsed +- **AND** a request matches that example's route +- **THEN** the expired example is skipped during selection and not returned + +#### Scenario RS.MSC.42: TTL=0 means no expiration +- **WHEN** a dynamic example is added with `ttl: 0` or no TTL +- **THEN** the example never expires and behaves as before this change + +#### Scenario RS.MSC.43: TTL and once combined +- **WHEN** a dynamic example has both a TTL and `once: true` +- **AND** the example is matched by a request before the TTL elapses +- **THEN** the example is consumed via the once flag and not returned again + +### Requirement: Background TTL cleanup +The server SHALL periodically remove expired dynamic examples from memory via a background goroutine. + +#### Scenario RS.MSC.44: Sweeping expired examples from storage +- **WHEN** a dynamic example with a TTL has expired +- **AND** the background sweep runs +- **THEN** the expired example is removed from the dynamic examples storage + +#### Scenario RS.MSC.45: Cleaning onceExamples on sweep +- **WHEN** an expired TTL example is removed by the background sweep +- **THEN** its corresponding onceExamples entry (if any) is also removed + +#### Scenario RS.MSC.46: Preserving non-expired examples +- **WHEN** the background sweep runs +- **AND** examples have not yet expired or have no TTL +- **THEN** those examples remain in storage + +#### Scenario RS.MSC.47: Debug logging on removal +- **WHEN** an expired example is removed by the background sweep +- **AND** verbose mode is enabled +- **THEN** a debug-level log entry is emitted containing the route key and example details + +#### Scenario RS.MSC.48: Sweep starts on server creation +- **WHEN** a server instance is created +- **THEN** the background goroutine for TTL sweeping is launched + +#### Scenario RS.MSC.49: Sweep stops on server shutdown +- **WHEN** the server shuts down +- **THEN** the background goroutine for TTL sweeping is stopped diff --git a/scripts/analyze_scenario_coverage.py b/scripts/analyze_scenario_coverage.py index 1f79d4c..0d1678f 100644 --- a/scripts/analyze_scenario_coverage.py +++ b/scripts/analyze_scenario_coverage.py @@ -3,7 +3,7 @@ Analyze requirement scenario coverage by tests. This script: -1. Extracts all requirement scenarios from spec files +1. Extracts all requirement scenarios from spec files (baseline and active change specs) 2. Maps test files to the scenarios they cover 3. Calculates coverage percentage 4. Optionally generates a detailed traceability matrix @@ -22,113 +22,134 @@ class ScenarioCoverageAnalyzer: def __init__(self, root_dir: str): self.root_dir = Path(root_dir) self.spec_dir = self.root_dir / "openspec" / "specs" - + self.changes_dir = self.root_dir / "openspec" / "changes" + # Data structures self.all_scenarios: Set[str] = set() + self.scenario_source: Dict[str, str] = {} self.scenario_to_tests: Dict[str, Dict[str, List[str]]] = {} self.test_to_scenarios: Dict[str, Set[str]] = {} - + def extract_scenarios_from_specs(self) -> None: - """Extract all RS.* scenario codes from spec files.""" - pattern = re.compile(r'#### Scenario\s+(RS\.[A-Z]+\.[0-9]+)') - - for spec_file in self.spec_dir.rglob("spec.md"): + """Extract all RS.* scenario codes from spec files. + + Baseline specs (openspec/specs) take priority over active change + specs (openspec/changes/*/specs); archived specs are excluded. + """ + self._extract_from_dir(self.spec_dir, "spec") + self._extract_from_dir(self.changes_dir, "change") + + def _extract_from_dir(self, search_dir: Path, source: str) -> None: + """Extract RS.* scenario codes from spec.md files under a directory.""" + pattern = re.compile(r"#### Scenario\s+(RS\.[A-Z]+\.[0-9]+)") + + for spec_file in search_dir.rglob("spec.md"): # Exclude archived specs if "archive" in str(spec_file): continue - + content = spec_file.read_text() matches = pattern.findall(content) - + for scenario in matches: - self.all_scenarios.add(scenario) - if scenario not in self.scenario_to_tests: + if scenario not in self.all_scenarios: + self.all_scenarios.add(scenario) + self.scenario_source[scenario] = source self.scenario_to_tests[scenario] = {"unit": [], "integration": []} - + def classify_test_file(self, test_file: Path) -> str: """Classify a test file as unit or integration based on its path.""" test_file_str = str(test_file) - + # Integration tests are in test/ directory if "test/" in test_file_str: return "integration" - + # Unit tests are in internal/ or cmd/ directories if "internal/" in test_file_str or "cmd/" in test_file_str: return "unit" - + # Default to unit return "unit" - + def extract_scenarios_from_test_file(self, test_file: Path) -> Set[str]: """Extract RS.* scenario codes from a test file.""" content = test_file.read_text() - + # Look for "Related spec scenarios:" lines - pattern = re.compile(r'Related spec scenarios:\s*(.+)') + pattern = re.compile(r"Related spec scenarios:\s*(.+)") scenarios = set() - - for line in content.split('\n'): + + for line in content.split("\n"): match = pattern.search(line) if match: # Parse comma-separated scenario codes scenario_text = match.group(1).strip() # Split by commas, handle spaces - for scenario in re.split(r',\s*', scenario_text): + for scenario in re.split(r",\s*", scenario_text): if scenario.startswith("RS."): scenarios.add(scenario) - + return scenarios - + def analyze_test_files(self) -> None: """Find all test files and map them to scenarios.""" # Find all Go test files test_files = list(self.root_dir.rglob("*_test.go")) - + for test_file in test_files: # Skip files that don't contain actual test functions content = test_file.read_text() if "func Test" not in content and "func Benchmark" not in content: continue - + test_type = self.classify_test_file(test_file) test_rel_path = test_file.relative_to(self.root_dir) - + # Extract scenarios covered by this test scenarios = self.extract_scenarios_from_test_file(test_file) - + # Store test -> scenarios mapping if scenarios: self.test_to_scenarios[str(test_rel_path)] = scenarios - + # Store scenario -> test mapping for scenario in scenarios: if scenario in self.scenario_to_tests: - self.scenario_to_tests[scenario][test_type].append(str(test_rel_path)) + self.scenario_to_tests[scenario][test_type].append( + str(test_rel_path) + ) else: - # Scenario mentioned in test but not found in specs - print(f"Warning: Scenario {scenario} in {test_rel_path} not found in specs") - self.scenario_to_tests[scenario] = {"unit": [], "integration": []} - self.scenario_to_tests[scenario][test_type].append(str(test_rel_path)) - + # Scenario mentioned in test but not found in any spec + print( + f"Warning: Scenario {scenario} in {test_rel_path} not found in baseline or active change specs" + ) + self.scenario_to_tests[scenario] = { + "unit": [], + "integration": [], + } + self.scenario_to_tests[scenario][test_type].append( + str(test_rel_path) + ) + def calculate_coverage(self) -> Tuple[int, int, float]: """Calculate coverage statistics.""" covered_scenarios = set() - + for scenario, tests in self.scenario_to_tests.items(): if tests["unit"] or tests["integration"]: covered_scenarios.add(scenario) - + total = len(self.all_scenarios) covered = len(covered_scenarios) coverage_pct = (covered / total * 100) if total > 0 else 0.0 - + return total, covered, coverage_pct - + def generate_report(self, detailed: bool = False) -> str: """Generate coverage report.""" total, covered, coverage_pct = self.calculate_coverage() - + report = [] report.append("# Requirement Scenario Coverage Report") report.append("") @@ -136,40 +157,53 @@ def generate_report(self, detailed: bool = False) -> str: report.append(f"**Covered scenarios:** {covered}") report.append(f"**Coverage:** {coverage_pct:.1f}%") report.append("") - + if detailed: report.append("## Detailed Traceability Matrix") report.append("") - report.append("| Scenario | Unit Tests | Integration Tests |") - report.append("|----------|------------|-------------------|") - - # Sort scenarios for consistent output - sorted_scenarios = sorted(self.scenario_to_tests.keys()) - + report.append("| Scenario | Source | Unit Tests | Integration Tests |") + report.append("|----------|--------|------------|-------------------|") + + # Sort scenarios naturally ascending (by area, then numeric) + def scenario_sort_key(scenario: str) -> Tuple[str, int]: + match = re.match(r"(RS\.[A-Z]+)\.(\d+)", scenario) + if match: + return match.group(1), int(match.group(2)) + return scenario, 0 + + sorted_scenarios = sorted( + self.scenario_to_tests.keys(), key=scenario_sort_key + ) + for scenario in sorted_scenarios: tests = self.scenario_to_tests[scenario] - + # Format test lists unit_tests = tests["unit"] integration_tests = tests["integration"] - + unit_str = "
".join(sorted(unit_tests)) if unit_tests else "—" - integration_str = "
".join(sorted(integration_tests)) if integration_tests else "—" - - report.append(f"| {scenario} | {unit_str} | {integration_str} |") - + integration_str = ( + "
".join(sorted(integration_tests)) if integration_tests else "—" + ) + source = self.scenario_source.get(scenario, "unknown") + + report.append( + f"| {scenario} | {source} | {unit_str} | {integration_str} |" + ) + return "\n".join(report) - + def run_analysis(self) -> None: """Run complete analysis.""" print("Extracting scenarios from spec files...", file=sys.stderr) self.extract_scenarios_from_specs() - + print(f"Found {len(self.all_scenarios)} requirement scenarios", file=sys.stderr) - + print("Analyzing test files...", file=sys.stderr) self.analyze_test_files() - + print(f"Analyzed {len(self.test_to_scenarios)} test files", file=sys.stderr) @@ -178,32 +212,32 @@ def main(): description="Analyze requirement scenario coverage by tests" ) parser.add_argument( - "--detailed", "-d", + "--detailed", + "-d", action="store_true", - help="Generate detailed traceability matrix" + help="Generate detailed traceability matrix", ) parser.add_argument( - "--coverage-only", "-c", + "--coverage-only", + "-c", action="store_true", - help="Output only coverage percentage as decimal (e.g., 0.639)" + help="Output only coverage percentage as decimal (e.g., 0.639)", ) parser.add_argument( - "--output", "-o", - type=str, - help="Output file (default: stdout)" + "--output", "-o", type=str, help="Output file (default: stdout)" ) parser.add_argument( "--root", type=str, default=".", - help="Project root directory (default: current directory)" + help="Project root directory (default: current directory)", ) - + args = parser.parse_args() - + analyzer = ScenarioCoverageAnalyzer(args.root) analyzer.run_analysis() - + if args.coverage_only: total, covered, coverage_pct = analyzer.calculate_coverage() coverage_decimal = coverage_pct / 100.0 @@ -211,9 +245,9 @@ def main(): else: report = analyzer.generate_report(detailed=args.detailed) output = report - + if args.output: - with open(args.output, 'w') as f: + with open(args.output, "w") as f: f.write(output) if not args.coverage_only: print(f"Report written to {args.output}", file=sys.stderr) @@ -222,4 +256,4 @@ def main(): if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/test/management-api/management_api_test.go b/test/management-api/management_api_test.go index a143480..5d2efab 100644 --- a/test/management-api/management_api_test.go +++ b/test/management-api/management_api_test.go @@ -1,13 +1,19 @@ package managementapi_test import ( + "bytes" "encoding/json" "fmt" + "net" "net/http" + "os/exec" "strings" + "sync" + "syscall" "testing" "time" + "github.com/mamonth/oasmock/test/_shared/binhelper" "github.com/mamonth/oasmock/test/_shared/clihelper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -343,3 +349,141 @@ func TestManagementAPIAccessible(t *testing.T) { // No error yet, process still running } } + +/* +Scenario: Adding an example with TTL and verifying expiry +Given a running server +When a POST request to /_mock/examples includes ttl: 1 +Then the example is returned before expiry and becomes unavailable after expiry + +Related spec scenarios: RS.MSC.40, RS.MSC.41 +*/ +func TestManagementAPITTLExpiration(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + t.Parallel() + + cmd, errCh, port := clihelper.Cmd(t).SetSchema("../_shared/resources/test.yaml", "").Run() + defer clihelper.StopServer(t, cmd) + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + // Add an example with a 1-second TTL + exampleJSON := `{ + "path": "/conditional", + "method": "GET", + "ttl": 1, + "response": { + "code": 200, + "headers": { "X-TTL": "short-lived" }, + "body": { "message": "expires soon" } + } + }` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/examples", port), "application/json", strings.NewReader(exampleJSON)) + require.NoError(t, err, "failed to POST example with TTL") + resp.Body.Close() //nolint:errcheck + assert.Equal(t, 200, resp.StatusCode, "expected status 200") + + // Before expiry the example should be served + req1, err := http.Get(fmt.Sprintf("http://localhost:%d/conditional", port)) + require.NoError(t, err, "failed to make request before expiry") + defer req1.Body.Close() //nolint:errcheck + assert.Equal(t, 200, req1.StatusCode, "expected status 200 before expiry") + assert.Equal(t, "short-lived", req1.Header.Get("X-TTL"), "expected TTL example to be served before expiry") + + // After expiry the example should no longer be available (route exists, no example) + require.Eventually(t, func() bool { + req, err := http.Get(fmt.Sprintf("http://localhost:%d/conditional", port)) + if err != nil { + return false + } + defer req.Body.Close() //nolint:errcheck + return req.StatusCode == 501 + }, 4*time.Second, 100*time.Millisecond, "expected status 501 after TTL expiry") + + // Check for any errors from the server process + select { + case err := <-errCh: + if err != nil && err.Error() != "signal: terminated" { + t.Logf("server process exited with error: %v", err) + } + default: + // No error yet, process still running + } +} + +// safeBuffer is a goroutine-safe bytes.Buffer used to capture server stderr +// while the process is still running. +type safeBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +func (b *safeBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +func (b *safeBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +/* +Scenario: Debug logging on example removal +Given a running server with verbose logging enabled +When a TTL example expires and the background sweep removes it +Then a debug-level log entry about the removal is emitted + +Related spec scenarios: RS.MSC.47 +*/ +func TestManagementAPITTLDebugLog(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + t.Parallel() + + // Start the server manually to capture stderr (debug log output). + ln, err := net.Listen("tcp", ":0") + require.NoError(t, err, "failed to find free port") + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + + cmd := exec.Command(binhelper.GetBuilded(t), "mock", + "--from", "../_shared/resources/test.yaml", + "--port", fmt.Sprintf("%d", port), + "--verbose") + var stderr safeBuffer + cmd.Stderr = &stderr + require.NoError(t, cmd.Start(), "failed to start oasmock") + defer func() { + _ = cmd.Process.Signal(syscall.SIGTERM) + _ = cmd.Wait() + }() + + if !clihelper.WaitForServer(t, port, 2*time.Second) { + t.Fatal("server did not start within timeout") + } + + // Add a short-lived example + exampleJSON := `{ + "path": "/conditional", + "method": "GET", + "ttl": 1, + "response": { "code": 200, "body": { "message": "expires soon" } } + }` + resp, err := http.Post(fmt.Sprintf("http://localhost:%d/_mock/examples", port), "application/json", strings.NewReader(exampleJSON)) + require.NoError(t, err, "failed to POST example with TTL") + resp.Body.Close() //nolint:errcheck + assert.Equal(t, 200, resp.StatusCode, "expected status 200") + + // Wait for the sweep (1s ticker) to remove the expired example and log it + require.Eventually(t, func() bool { + return strings.Contains(stderr.String(), "Removed expired dynamic example") + }, 4*time.Second, 100*time.Millisecond, "expected debug log about removed expired example") +}