From 96bb6643ad86449a93596f7a81e326c99e488f67 Mon Sep 17 00:00:00 2001 From: Corvid Agent <95454608+corvid-agent@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:36:42 -0700 Subject: [PATCH 1/5] test: add unit tests for ai.rs and generator.rs; update docs Add 35 unit tests for ai.rs covering safe_truncate, command_for_provider, ResolvedProvider Display, postprocess_spec, build_prompt, build_regen_prompt, and resolve_ai_provider. Add 25 unit tests for generator.rs covering detect_primary_language, language_template selection, generate_spec, companion file generation, and find_files_for_module. Update docs: - configuration.md: document 11 missing config options (aiProvider, aiModel, aiApiKey, aiBaseUrl, exportLevel, modules, rules, taskArchiveDays, github), add validation rules and GitHub config sections - architecture.md: add 9 missing modules to source layout (hash_cache, registry, manifest, schema, merge, archive, compact, view, github), add php/ruby exporters, update dependency table - cli.md: expand --provider docs with provider table, update flag description Closes #110 Co-Authored-By: Claude Opus 4.6 --- docs/architecture.md | 31 ++-- docs/cli.md | 15 +- docs/configuration.md | 143 +++++++++++++++++- src/ai.rs | 326 ++++++++++++++++++++++++++++++++++++++++++ src/generator.rs | 316 ++++++++++++++++++++++++++++++++++++++++ 5 files changed, 815 insertions(+), 16 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 60786db..9e8559c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,15 +24,24 @@ How SpecSync is built. Useful for contributors and anyone adding language suppor ``` src/ ├── main.rs CLI entry point (clap) + output formatting -├── ai.rs AI-powered spec generation (prompt builder + command runner) -├── mcp.rs MCP server (JSON-RPC over stdio, tools for check/generate/score) -├── scoring.rs Spec quality scoring (0–100, weighted rubric) -├── types.rs Core data types + config schema -├── config.rs specsync.json / specsync.toml loading +├── types.rs Core data types, config schema, enums +├── config.rs specsync.json / specsync.toml loading + auto-detection ├── parser.rs Frontmatter + spec body parsing -├── validator.rs Validation + coverage computation -├── generator.rs Spec scaffolding +├── validator.rs Validation pipeline + coverage computation +├── generator.rs Spec scaffolding (template + AI-powered) +├── ai.rs AI provider resolution, prompt building, API/CLI execution +├── scoring.rs Spec quality scoring (0–100, weighted rubric) +├── mcp.rs MCP server (JSON-RPC over stdio, tools for check/generate/score) ├── watch.rs File watcher (notify, 500ms debounce) +├── hash_cache.rs Content-hash cache for incremental validation +├── registry.rs Cross-project module registry (specsync-registry.toml) +├── manifest.rs Package manifest parsing (package.json, Cargo.toml, go.mod, etc.) +├── schema.rs SQL schema parsing for db_tables validation +├── merge.rs Git conflict resolution for spec files +├── archive.rs Task archival from companion tasks.md files +├── compact.rs Changelog compaction (trim old entries) +├── view.rs Role-filtered spec viewing (dev, qa, product, agent) +├── github.rs GitHub integration (repo detection, drift issues) └── exports/ ├── mod.rs Language dispatch + file utilities ├── typescript.rs TS/JS exports @@ -43,7 +52,9 @@ src/ ├── kotlin.rs Kotlin top-level ├── java.rs Java public items ├── csharp.rs C# public items - └── dart.rs Dart public items + ├── dart.rs Dart public items + ├── php.rs PHP public classes/functions + └── ruby.rs Ruby public methods/classes ``` --- @@ -107,8 +118,8 @@ Each extractor: strip comments, apply regex, return symbol names. No compiler ne | `walkdir` | Recursive directory traversal | | `colored` | Terminal colors | | `notify` + `notify-debouncer-full` | File watching for `watch` command | -| `toml` | TOML config file parsing | -| `tokio` | Async runtime for MCP server | +| `ureq` | HTTP client for Anthropic/OpenAI API calls | +| `sha2` | Content hashing for incremental validation cache | ### Dev diff --git a/docs/cli.md b/docs/cli.md index e48dc5e..1b7e20b 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -63,7 +63,18 @@ specsync generate --provider auto # AI mode — auto-detect provider, writ specsync generate --provider anthropic # AI mode — use Anthropic API directly ``` -With `--provider`, source code is piped to an LLM which generates filled-in specs (Purpose, Public API tables, Invariants, etc.). Use `--provider auto` to auto-detect an installed provider, or specify one by name (`anthropic`, `openai`, `command`). The `command` provider resolves from: `aiCommand` in config → `SPECSYNC_AI_COMMAND` env var → `claude -p --output-format text`. See [Configuration](configuration) for `aiCommand` and `aiTimeout`. +With `--provider`, source code is sent to an LLM which generates filled-in specs (Purpose, Public API tables, Invariants, etc.). Use `--provider auto` to auto-detect an installed provider, or specify one by name: + +| Provider | How it works | +|:---------|:-------------| +| `auto` | Auto-detect: checks installed CLIs (`claude`, `ollama`, `copilot`), then API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) | +| `claude` | Shells out to Claude Code CLI (`claude -p --output-format text`) | +| `anthropic` | Calls Anthropic Messages API directly (requires `ANTHROPIC_API_KEY`) | +| `openai` | Calls OpenAI Chat Completions API directly (requires `OPENAI_API_KEY`) | +| `ollama` | Shells out to Ollama CLI (`ollama run `) | +| `copilot` | Shells out to GitHub Copilot CLI (`gh copilot suggest`) | + +See [Configuration](configuration) for `aiProvider`, `aiModel`, `aiApiKey`, `aiBaseUrl`, and `aiTimeout`. ### `score` @@ -198,7 +209,7 @@ specsync watch | `--strict` | Warnings become errors. Recommended for CI. | | `--require-coverage N` | Fail if file coverage < N%. | | `--root ` | Project root directory (default: cwd). | -| `--provider ` | Enable AI-powered generation and select provider: `auto` (auto-detect), `anthropic`, `openai`, or `command`. Without this flag, `generate` uses templates only. | +| `--provider ` | Enable AI-powered generation and select provider: `auto`, `claude`, `anthropic`, `openai`, `ollama`, or `copilot`. Without this flag, `generate` uses templates only. | | `--format ` | Output format: `text` (default), `json`, or `markdown`. Markdown produces clean tables suitable for PRs and docs. | | `--json` | Shorthand for `--format json`. Structured output, no color codes. | diff --git a/docs/configuration.md b/docs/configuration.md index a043456..b36a227 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -35,11 +35,23 @@ SpecSync also supports `specsync.toml` as an alternative to JSON: specs_dir = "specs" source_dirs = ["src"] schema_dir = "db/migrations" -ai_command = "claude -p --output-format text" +ai_provider = "anthropic" +ai_model = "claude-sonnet-4-20250514" ai_timeout = 120 +export_level = "member" required_sections = ["Purpose", "Public API", "Invariants", "Behavioral Examples", "Error Cases", "Dependencies", "Change Log"] exclude_dirs = ["__tests__"] exclude_patterns = ["**/__tests__/**", "**/*.test.ts"] +task_archive_days = 30 + +[rules] +max_changelog_entries = 20 +require_behavioral_examples = true +min_invariants = 1 + +[github] +drift_labels = ["spec-drift"] +verify_issues = true ``` Config resolution order: `specsync.json` → `specsync.toml` → defaults. @@ -58,8 +70,27 @@ Config resolution order: `specsync.json` → `specsync.toml` → defaults. "excludeDirs": ["__tests__"], "excludePatterns": ["**/__tests__/**", "**/*.test.ts", "**/*.spec.ts"], "sourceExtensions": [], - "aiCommand": "claude -p --output-format text", - "aiTimeout": 120 + "exportLevel": "member", + "aiProvider": "anthropic", + "aiModel": "claude-sonnet-4-20250514", + "aiCommand": null, + "aiApiKey": null, + "aiBaseUrl": null, + "aiTimeout": 120, + "taskArchiveDays": 30, + "modules": {}, + "rules": { + "maxChangelogEntries": 20, + "requireBehavioralExamples": true, + "minInvariants": 1, + "maxSpecSizeKb": 50, + "requireDependsOn": false + }, + "github": { + "repo": "owner/repo", + "driftLabels": ["spec-drift"], + "verifyIssues": true + } } ``` @@ -77,8 +108,112 @@ Config resolution order: `specsync.json` → `specsync.toml` → defaults. | `excludeDirs` | `string[]` | `["__tests__"]` | Directory names skipped during coverage scanning | | `excludePatterns` | `string[]` | Common test globs | File patterns excluded from coverage (additive with language-specific test exclusions) | | `sourceExtensions` | `string[]` | All supported | Restrict to specific extensions (e.g., `["ts", "rs"]`) | -| `aiCommand` | `string?` | `claude -p ...` | Command for `generate --provider command` (reads stdin prompt, writes stdout markdown) | +| `aiProvider` | `string?` | — | AI provider name: `claude`, `anthropic`, `openai`, `ollama`, `copilot`, or `custom` | +| `aiModel` | `string?` | Provider default | Model name override (e.g., `"claude-sonnet-4-20250514"`, `"gpt-4o"`, `"mistral"`) | +| `aiCommand` | `string?` | — | Custom CLI command for AI generation (reads stdin prompt, writes stdout markdown) | +| `aiApiKey` | `string?` | — | API key for `anthropic` or `openai` providers (prefer env vars `ANTHROPIC_API_KEY` / `OPENAI_API_KEY` instead) | +| `aiBaseUrl` | `string?` | — | Custom base URL for API providers (e.g., for proxies or self-hosted endpoints) | | `aiTimeout` | `number?` | `120` | Seconds before AI command times out per module | +| `exportLevel` | `string?` | `"member"` | Export validation depth: `"type"` (classes/structs only) or `"member"` (all public symbols) | +| `modules` | `object?` | `{}` | Custom module definitions mapping module names to `{ files, depends_on }` | +| `rules` | `object?` | `{}` | Custom validation rules (see [Validation Rules](#validation-rules) below) | +| `taskArchiveDays` | `number?` | — | Days after which completed tasks in companion `tasks.md` files are auto-archived | +| `github` | `object?` | — | GitHub integration settings (see [GitHub Config](#github-config) below) | + +--- + +## AI Provider Resolution + +When you run `specsync generate --provider auto`, the provider is resolved in this order: + +1. `--provider` CLI flag (explicit) +2. `aiCommand` in config (custom command always wins) +3. `aiProvider` in config (resolved to CLI or API) +4. `SPECSYNC_AI_COMMAND` env var +5. Auto-detect: installed CLIs first (`claude`, `ollama`, `copilot`), then API keys (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`) + +### API Providers + +The `anthropic` and `openai` providers call their respective APIs directly — no CLI tool needed. Just set the API key: + +```json +{ + "aiProvider": "anthropic" +} +``` + +Then set `ANTHROPIC_API_KEY` in your environment (or use `aiApiKey` in config for local use — **not recommended for shared repos**). + +--- + +## Validation Rules + +Fine-tune validation behavior with the `rules` object: + +```json +{ + "rules": { + "maxChangelogEntries": 20, + "requireBehavioralExamples": true, + "minInvariants": 2, + "maxSpecSizeKb": 50, + "requireDependsOn": false + } +} +``` + +| Rule | Type | Description | +|:-----|:-----|:------------| +| `maxChangelogEntries` | `number?` | Warn if a spec's Change Log exceeds this many entries | +| `requireBehavioralExamples` | `bool?` | Require at least one Behavioral Example scenario | +| `minInvariants` | `number?` | Minimum number of invariants required per spec | +| `maxSpecSizeKb` | `number?` | Warn if spec file exceeds this size in KB | +| `requireDependsOn` | `bool?` | Require non-empty `depends_on` in frontmatter | + +--- + +## GitHub Config + +Configure GitHub integration for drift detection and issue verification: + +```json +{ + "github": { + "repo": "owner/repo", + "driftLabels": ["spec-drift"], + "verifyIssues": true + } +} +``` + +| Option | Type | Default | Description | +|:-------|:-----|:--------|:------------| +| `repo` | `string?` | Auto-detected | Repository in `owner/repo` format (auto-detected from git remote) | +| `driftLabels` | `string[]` | `["spec-drift"]` | Labels applied when creating drift issues | +| `verifyIssues` | `bool` | `true` | Whether to verify linked issues exist during `specsync check` | + +--- + +## Custom Module Definitions + +Map custom module names to specific files when auto-detection doesn't fit your layout: + +```json +{ + "modules": { + "auth": { + "files": ["src/auth/service.ts", "src/auth/middleware.ts"], + "dependsOn": ["database"] + }, + "api": { + "files": ["src/routes/"], + "dependsOn": ["auth", "database"] + } + } +} +``` + +Module definitions override the default subdirectory/flat-file discovery for `specsync generate` and `specsync coverage`. --- diff --git a/src/ai.rs b/src/ai.rs index 871bd4b..359b646 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -817,3 +817,329 @@ pub fn generate_spec_with_ai( postprocess_spec(&raw) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::AiProvider; + + // ── safe_truncate ────────────────────────────────────────────── + + #[test] + fn safe_truncate_within_limit() { + assert_eq!(safe_truncate("hello", 10), "hello"); + } + + #[test] + fn safe_truncate_exact_limit() { + assert_eq!(safe_truncate("hello", 5), "hello"); + } + + #[test] + fn safe_truncate_truncates_ascii() { + assert_eq!(safe_truncate("hello world", 5), "hello"); + } + + #[test] + fn safe_truncate_respects_utf8_boundary() { + // '€' is 3 bytes (E2 82 AC). Cutting at byte 2 should back up to 0. + let s = "€abc"; + assert_eq!(safe_truncate(s, 2), ""); + // Cutting at byte 3 should give the full '€'. + assert_eq!(safe_truncate(s, 3), "€"); + // Cutting at byte 4 gives '€a'. + assert_eq!(safe_truncate(s, 4), "€a"); + } + + #[test] + fn safe_truncate_multibyte_sequence() { + // '🦀' is 4 bytes. Cutting at 1, 2, 3 should all yield "". + let s = "🦀rust"; + assert_eq!(safe_truncate(s, 1), ""); + assert_eq!(safe_truncate(s, 3), ""); + assert_eq!(safe_truncate(s, 4), "🦀"); + } + + #[test] + fn safe_truncate_empty_string() { + assert_eq!(safe_truncate("", 10), ""); + } + + // ── command_for_provider ─────────────────────────────────────── + + #[test] + fn command_for_claude() { + let cmd = command_for_provider(&AiProvider::Claude, None).unwrap(); + assert_eq!(cmd, "claude -p --output-format text"); + } + + #[test] + fn command_for_ollama_default_model() { + let cmd = command_for_provider(&AiProvider::Ollama, None).unwrap(); + assert_eq!(cmd, "ollama run llama3"); + } + + #[test] + fn command_for_ollama_custom_model() { + let cmd = command_for_provider(&AiProvider::Ollama, Some("mistral")).unwrap(); + assert_eq!(cmd, "ollama run mistral"); + } + + #[test] + fn command_for_copilot() { + let cmd = command_for_provider(&AiProvider::Copilot, None).unwrap(); + assert_eq!(cmd, "gh copilot suggest -t shell"); + } + + #[test] + fn command_for_cursor_errors() { + let err = command_for_provider(&AiProvider::Cursor, None).unwrap_err(); + assert!(err.contains("Cursor does not have a CLI pipe mode")); + } + + #[test] + fn command_for_anthropic_errors() { + let err = command_for_provider(&AiProvider::Anthropic, None).unwrap_err(); + assert!(err.contains("resolve_api_provider")); + } + + #[test] + fn command_for_custom_errors() { + let err = command_for_provider(&AiProvider::Custom, None).unwrap_err(); + assert!(err.contains("aiCommand")); + } + + // ── ResolvedProvider Display ──────────────────────────────────── + + #[test] + fn display_cli_provider() { + let p = ResolvedProvider::Cli("claude -p".to_string()); + assert_eq!(format!("{p}"), "CLI: claude -p"); + } + + #[test] + fn display_anthropic_provider() { + let p = ResolvedProvider::AnthropicApi { + api_key: "sk-test".to_string(), + model: "claude-sonnet-4-20250514".to_string(), + base_url: None, + }; + assert_eq!(format!("{p}"), "Anthropic API (claude-sonnet-4-20250514)"); + } + + #[test] + fn display_openai_provider_no_base_url() { + let p = ResolvedProvider::OpenAiApi { + api_key: "sk-test".to_string(), + model: "gpt-4o".to_string(), + base_url: None, + }; + assert_eq!(format!("{p}"), "OpenAI API (gpt-4o)"); + } + + #[test] + fn display_openai_provider_with_base_url() { + let p = ResolvedProvider::OpenAiApi { + api_key: "sk-test".to_string(), + model: "gpt-4o".to_string(), + base_url: Some("https://custom.api.com".to_string()), + }; + assert_eq!(format!("{p}"), "OpenAI API (gpt-4o @ https://custom.api.com)"); + } + + // ── postprocess_spec ─────────────────────────────────────────── + + #[test] + fn postprocess_strips_markdown_fence() { + let raw = "```markdown\n---\nmodule: test\n---\n# Test\n```"; + let result = postprocess_spec(raw).unwrap(); + assert!(result.starts_with("---")); + assert!(!result.contains("```")); + } + + #[test] + fn postprocess_strips_plain_fence() { + let raw = "```\n---\nmodule: test\n---\n# Test\n```"; + let result = postprocess_spec(raw).unwrap(); + assert!(result.starts_with("---")); + assert!(!result.contains("```")); + } + + #[test] + fn postprocess_strips_md_fence() { + let raw = "```md\n---\nmodule: test\n---\n# Test\n```"; + let result = postprocess_spec(raw).unwrap(); + assert!(result.starts_with("---")); + } + + #[test] + fn postprocess_no_fence_passthrough() { + let raw = "---\nmodule: test\n---\n# Test\n"; + let result = postprocess_spec(raw).unwrap(); + assert_eq!(result, raw); + } + + #[test] + fn postprocess_missing_frontmatter_errors() { + let raw = "# No frontmatter here\nJust some text."; + let err = postprocess_spec(raw).unwrap_err(); + assert!(err.contains("missing YAML frontmatter")); + } + + #[test] + fn postprocess_leading_whitespace_before_frontmatter() { + let raw = " \n---\nmodule: test\n---\n# Test\n"; + let result = postprocess_spec(raw).unwrap(); + assert!(result.contains("module: test")); + } + + // ── build_prompt ─────────────────────────────────────────────── + + #[test] + fn build_prompt_contains_module_name() { + let prompt = build_prompt( + "auth", + &[("src/auth.rs".to_string(), "pub fn login() {}".to_string())], + &["Purpose".to_string(), "Public API".to_string()], + ); + assert!(prompt.contains("\"auth\"")); + assert!(prompt.contains("## Purpose")); + assert!(prompt.contains("## Public API")); + assert!(prompt.contains("src/auth.rs")); + assert!(prompt.contains("pub fn login() {}")); + } + + #[test] + fn build_prompt_truncates_large_files() { + let large_content = "x".repeat(MAX_FILE_CHARS + 1000); + let prompt = build_prompt( + "big", + &[("src/big.rs".to_string(), large_content)], + &["Purpose".to_string()], + ); + assert!(prompt.contains("truncated at")); + // The full content should not appear + assert!(prompt.len() < MAX_FILE_CHARS + 10_000); + } + + #[test] + fn build_prompt_skips_files_over_prompt_limit() { + // Create enough files to exceed MAX_PROMPT_CHARS + let file_content = "a".repeat(MAX_FILE_CHARS); + let mut files = Vec::new(); + for i in 0..10 { + files.push((format!("src/file{i}.rs"), file_content.clone())); + } + let prompt = build_prompt("multi", &files, &["Purpose".to_string()]); + assert!(prompt.contains("skipped: prompt size limit")); + } + + #[test] + fn build_prompt_empty_files() { + let prompt = build_prompt("empty", &[], &["Purpose".to_string()]); + assert!(prompt.contains("\"empty\"")); + assert!(prompt.contains("Source files:")); + } + + // ── build_regen_prompt ───────────────────────────────────────── + + #[test] + fn build_regen_prompt_contains_spec_and_requirements() { + let current = "---\nmodule: auth\n---\n# Auth\n"; + let requirements = "## User Stories\n- login flow\n"; + let prompt = build_regen_prompt( + "auth", + current, + requirements, + &[("src/auth.rs".to_string(), "pub fn login() {}".to_string())], + ); + assert!(prompt.contains("## Current Spec")); + assert!(prompt.contains("## Updated Requirements")); + assert!(prompt.contains("login flow")); + assert!(prompt.contains("src/auth.rs")); + assert!(prompt.contains("bump the version by 1")); + } + + #[test] + fn build_regen_prompt_no_source_files() { + let prompt = build_regen_prompt("auth", "spec content", "requirements", &[]); + assert!(!prompt.contains("## Source Files")); + assert!(prompt.contains("## Instructions")); + } + + #[test] + fn build_regen_prompt_truncates_large_sources() { + let large = "y".repeat(40_000); + let prompt = build_regen_prompt( + "big", + "spec", + "reqs", + &[("src/big.rs".to_string(), large)], + ); + // safe_truncate should have capped it at 30_000 + assert!(prompt.len() < 200_000); + } + + // ── resolve_ai_provider ──────────────────────────────────────── + + #[test] + fn resolve_with_ai_command_in_config() { + let mut config = SpecSyncConfig::default(); + config.ai_command = Some("my-custom-ai".to_string()); + let result = resolve_ai_provider(&config, None).unwrap(); + match result { + ResolvedProvider::Cli(cmd) => assert_eq!(cmd, "my-custom-ai"), + _ => panic!("Expected CLI provider"), + } + } + + #[test] + fn resolve_with_env_var() { + let config = SpecSyncConfig::default(); + // SAFETY: single-threaded test — no concurrent env access + unsafe { + std::env::set_var("SPECSYNC_AI_COMMAND", "env-ai-tool"); + } + let result = resolve_ai_provider(&config, None); + unsafe { + std::env::remove_var("SPECSYNC_AI_COMMAND"); + } + match result.unwrap() { + ResolvedProvider::Cli(cmd) => assert_eq!(cmd, "env-ai-tool"), + _ => panic!("Expected CLI provider"), + } + } + + #[test] + fn resolve_unknown_provider_errors() { + let config = SpecSyncConfig::default(); + let err = resolve_ai_provider(&config, Some("nonexistent")).unwrap_err(); + assert!(err.contains("Unknown provider")); + } + + #[test] + fn resolve_cursor_provider_errors() { + let config = SpecSyncConfig::default(); + let err = resolve_ai_provider(&config, Some("cursor")).unwrap_err(); + assert!(err.contains("Cursor does not have a CLI pipe mode")); + } + + // ── resolve_ai_command (compat alias) ────────────────────────── + + #[test] + fn resolve_ai_command_returns_cli_string() { + let mut config = SpecSyncConfig::default(); + config.ai_command = Some("test-cmd".to_string()); + let result = resolve_ai_command(&config, None).unwrap(); + assert_eq!(result, "test-cmd"); + } + + // ── constants ────────────────────────────────────────────────── + + #[test] + fn constants_are_reasonable() { + assert_eq!(MAX_FILE_CHARS, 30_000); + assert_eq!(MAX_PROMPT_CHARS, 150_000); + assert_eq!(DEFAULT_AI_TIMEOUT_SECS, 120); + } +} diff --git a/src/generator.rs b/src/generator.rs index 4a81d37..f1ea467 100644 --- a/src/generator.rs +++ b/src/generator.rs @@ -819,3 +819,319 @@ pub fn generate_specs_for_unspecced_modules_paths( generated_paths } + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + // ── detect_primary_language ───────────────────────────────────── + + #[test] + fn detect_language_rust() { + let files = vec!["src/main.rs".to_string(), "src/lib.rs".to_string()]; + assert_eq!(detect_primary_language(&files), Some(Language::Rust)); + } + + #[test] + fn detect_language_typescript() { + let files = vec![ + "src/app.ts".to_string(), + "src/util.ts".to_string(), + "src/types.tsx".to_string(), + ]; + assert_eq!(detect_primary_language(&files), Some(Language::TypeScript)); + } + + #[test] + fn detect_language_python() { + let files = vec!["app.py".to_string(), "models.py".to_string()]; + assert_eq!(detect_primary_language(&files), Some(Language::Python)); + } + + #[test] + fn detect_language_go() { + let files = vec!["main.go".to_string()]; + assert_eq!(detect_primary_language(&files), Some(Language::Go)); + } + + #[test] + fn detect_language_mixed_majority_wins() { + let files = vec![ + "src/main.rs".to_string(), + "src/lib.rs".to_string(), + "src/utils.rs".to_string(), + "build.py".to_string(), + ]; + assert_eq!(detect_primary_language(&files), Some(Language::Rust)); + } + + #[test] + fn detect_language_empty() { + let files: Vec = vec![]; + assert_eq!(detect_primary_language(&files), None); + } + + #[test] + fn detect_language_unknown_extensions() { + let files = vec!["data.csv".to_string(), "readme.md".to_string()]; + assert_eq!(detect_primary_language(&files), None); + } + + // ── language_template ────────────────────────────────────────── + + #[test] + fn template_rust_has_structs_enums_section() { + let t = language_template(Language::Rust); + assert!(t.contains("### Structs & Enums")); + assert!(t.contains("### Traits")); + assert!(t.contains("Crate/Module")); + } + + #[test] + fn template_swift_has_protocols_section() { + let t = language_template(Language::Swift); + assert!(t.contains("### Protocols")); + assert!(t.contains("### Types")); + } + + #[test] + fn template_go_has_package_terminology() { + let t = language_template(Language::Go); + assert!(t.contains("package")); + } + + #[test] + fn template_kotlin_has_classes_interfaces() { + let t = language_template(Language::Kotlin); + assert!(t.contains("### Classes & Interfaces")); + } + + #[test] + fn template_python_has_classes() { + let t = language_template(Language::Python); + assert!(t.contains("### Classes")); + } + + #[test] + fn template_typescript_uses_default() { + let t = language_template(Language::TypeScript); + // TypeScript falls through to DEFAULT_TEMPLATE + assert!(t.contains("### Exported Functions")); + assert!(t.contains("### Exported Types")); + } + + // ── generate_spec (template-based) ───────────────────────────── + + #[test] + fn generate_spec_fills_module_name() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let specs_dir = root.join("specs"); + fs::create_dir_all(&specs_dir).unwrap(); + + let src_dir = root.join("src"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write(src_dir.join("auth.rs"), "pub fn login() {}").unwrap(); + + let files = vec![src_dir.join("auth.rs").to_string_lossy().to_string()]; + let spec = generate_spec("auth", &files, root, &specs_dir); + + assert!(spec.contains("module: auth")); + assert!(spec.contains("# Auth")); + assert!(spec.contains("version: 1")); + assert!(spec.contains("status: draft")); + } + + #[test] + fn generate_spec_hyphenated_name_title_case() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let specs_dir = root.join("specs"); + fs::create_dir_all(&specs_dir).unwrap(); + + let spec = generate_spec("api-gateway", &[], root, &specs_dir); + assert!(spec.contains("# Api Gateway")); + assert!(spec.contains("module: api-gateway")); + } + + #[test] + fn generate_spec_uses_custom_template() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let specs_dir = root.join("specs"); + fs::create_dir_all(&specs_dir).unwrap(); + + let custom_template = "---\nmodule: module-name\nversion: 1\nstatus: draft\nfiles: []\ndb_tables: []\ndepends_on: []\n---\n\n# Module Name\n\n## Purpose\n\nCustom template marker\n"; + fs::write(specs_dir.join("_template.spec.md"), custom_template).unwrap(); + + let spec = generate_spec("my-mod", &[], root, &specs_dir); + assert!(spec.contains("Custom template marker")); + assert!(spec.contains("module: my-mod")); + } + + #[test] + fn generate_spec_rust_files_use_rust_template() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let specs_dir = root.join("specs"); + fs::create_dir_all(&specs_dir).unwrap(); + + let files = vec!["src/parser.rs".to_string()]; + let spec = generate_spec("parser", &files, root, &specs_dir); + // Should use Rust template (no custom template file exists) + assert!(spec.contains("### Structs & Enums")); + } + + // ── companion file templates ─────────────────────────────────── + + #[test] + fn tasks_template_has_required_sections() { + assert!(TASKS_TEMPLATE.contains("## Tasks")); + assert!(TASKS_TEMPLATE.contains("## Gaps")); + assert!(TASKS_TEMPLATE.contains("## Review Sign-offs")); + assert!(TASKS_TEMPLATE.contains("{module}")); + } + + #[test] + fn requirements_template_has_required_sections() { + assert!(REQUIREMENTS_TEMPLATE.contains("## User Stories")); + assert!(REQUIREMENTS_TEMPLATE.contains("## Acceptance Criteria")); + assert!(REQUIREMENTS_TEMPLATE.contains("## Constraints")); + assert!(REQUIREMENTS_TEMPLATE.contains("## Out of Scope")); + } + + #[test] + fn context_template_has_required_sections() { + assert!(CONTEXT_TEMPLATE.contains("## Key Decisions")); + assert!(CONTEXT_TEMPLATE.contains("## Files to Read First")); + assert!(CONTEXT_TEMPLATE.contains("## Current Status")); + assert!(CONTEXT_TEMPLATE.contains("## Notes")); + } + + #[test] + fn default_template_has_all_required_sections() { + assert!(DEFAULT_TEMPLATE.contains("## Purpose")); + assert!(DEFAULT_TEMPLATE.contains("## Public API")); + assert!(DEFAULT_TEMPLATE.contains("## Invariants")); + assert!(DEFAULT_TEMPLATE.contains("## Behavioral Examples")); + assert!(DEFAULT_TEMPLATE.contains("## Error Cases")); + assert!(DEFAULT_TEMPLATE.contains("## Dependencies")); + assert!(DEFAULT_TEMPLATE.contains("## Change Log")); + } + + // ── generate_companion_files ─────────────────────────────────── + + #[test] + fn companion_files_created_when_absent() { + let tmp = TempDir::new().unwrap(); + let spec_dir = tmp.path(); + + generate_companion_files(spec_dir, "auth"); + + assert!(spec_dir.join("tasks.md").exists()); + assert!(spec_dir.join("context.md").exists()); + assert!(spec_dir.join("requirements.md").exists()); + + let tasks = fs::read_to_string(spec_dir.join("tasks.md")).unwrap(); + assert!(tasks.contains("spec: auth.spec.md")); + + let reqs = fs::read_to_string(spec_dir.join("requirements.md")).unwrap(); + assert!(reqs.contains("spec: auth.spec.md")); + } + + #[test] + fn companion_files_not_overwritten() { + let tmp = TempDir::new().unwrap(); + let spec_dir = tmp.path(); + + fs::write(spec_dir.join("tasks.md"), "existing content").unwrap(); + generate_companion_files(spec_dir, "auth"); + + let tasks = fs::read_to_string(spec_dir.join("tasks.md")).unwrap(); + assert_eq!(tasks, "existing content"); + } + + // ── find_files_for_module ────────────────────────────────────── + + #[test] + fn find_files_flat_module() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let src_dir = root.join("src"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write(src_dir.join("auth.rs"), "pub fn login() {}").unwrap(); + fs::write(src_dir.join("other.rs"), "pub fn other() {}").unwrap(); + + let config = SpecSyncConfig::default(); + let files = find_files_for_module(root, "auth", &config); + assert_eq!(files.len(), 1); + assert!(files[0].contains("auth.rs")); + } + + #[test] + fn find_files_subdir_module() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let mod_dir = root.join("src").join("auth"); + fs::create_dir_all(&mod_dir).unwrap(); + fs::write(mod_dir.join("service.ts"), "export function login() {}").unwrap(); + fs::write(mod_dir.join("types.ts"), "export interface User {}").unwrap(); + + let mut config = SpecSyncConfig::default(); + config.source_extensions = vec!["ts".to_string()]; + let files = find_files_for_module(root, "auth", &config); + assert_eq!(files.len(), 2); + } + + #[test] + fn find_files_excludes_test_files() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let src_dir = root.join("src"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write(src_dir.join("auth.ts"), "export function login() {}").unwrap(); + fs::write(src_dir.join("auth.test.ts"), "test('login', () => {})").unwrap(); + + let mut config = SpecSyncConfig::default(); + config.source_extensions = vec!["ts".to_string()]; + let files = find_files_for_module(root, "auth", &config); + assert_eq!(files.len(), 1); + assert!(!files[0].contains("test")); + } + + #[test] + fn find_files_no_match() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let src_dir = root.join("src"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write(src_dir.join("other.rs"), "fn other() {}").unwrap(); + + let config = SpecSyncConfig::default(); + let files = find_files_for_module(root, "nonexistent", &config); + assert!(files.is_empty()); + } + + #[test] + fn find_files_user_defined_module() { + let tmp = TempDir::new().unwrap(); + let root = tmp.path(); + let src_dir = root.join("src"); + fs::create_dir_all(&src_dir).unwrap(); + fs::write(src_dir.join("foo.rs"), "pub fn foo() {}").unwrap(); + fs::write(src_dir.join("bar.rs"), "pub fn bar() {}").unwrap(); + + let mut config = SpecSyncConfig::default(); + config.modules.insert( + "my-module".to_string(), + crate::types::ModuleDefinition { + files: vec!["src/foo.rs".to_string(), "src/bar.rs".to_string()], + depends_on: vec![], + }, + ); + let files = find_files_for_module(root, "my-module", &config); + assert_eq!(files.len(), 2); + } +} From 5b8458744e23eaef17a322ded3c8bb6df577f379 Mon Sep 17 00:00:00 2001 From: Corvid Agent <95454608+corvid-agent@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:40:48 -0700 Subject: [PATCH 2/5] test: add 40 unit tests for hooks.rs Cover all public API: HookTarget enum methods (all, name, description, from_str with aliases and case insensitivity), is_installed detection for all 6 targets, install_hook (create, append, idempotent, merge JSON, Unix permissions), uninstall_hook (remove section, preserve other content, refuse Claude Code hook removal), and remove_section_from_file (empty file deletion, marker boundary, section-aware stop at next heading). Full suite: 283 unit + 80 integration = 363 total tests. Closes #112 Co-Authored-By: Claude Opus 4.6 --- src/hooks.rs | 391 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 391 insertions(+) diff --git a/src/hooks.rs b/src/hooks.rs index 163813a..1cf58ca 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -696,3 +696,394 @@ pub fn cmd_status(root: &Path) { println!("Install all: specsync hooks install"); println!("Install one: specsync hooks install --claude --precommit"); } + +// ─── Tests ───────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn setup() -> TempDir { + tempfile::tempdir().unwrap() + } + + // ── HookTarget::all ──────────────────────────────────────────── + + #[test] + fn hook_target_all_returns_six_targets() { + let all = HookTarget::all(); + assert_eq!(all.len(), 6); + } + + #[test] + fn hook_target_all_contains_all_variants() { + let all = HookTarget::all(); + assert!(all.contains(&HookTarget::Claude)); + assert!(all.contains(&HookTarget::Cursor)); + assert!(all.contains(&HookTarget::Copilot)); + assert!(all.contains(&HookTarget::Agents)); + assert!(all.contains(&HookTarget::Precommit)); + assert!(all.contains(&HookTarget::ClaudeCodeHook)); + } + + // ── HookTarget::name ─────────────────────────────────────────── + + #[test] + fn hook_target_name_returns_expected_strings() { + assert_eq!(HookTarget::Claude.name(), "claude"); + assert_eq!(HookTarget::Cursor.name(), "cursor"); + assert_eq!(HookTarget::Copilot.name(), "copilot"); + assert_eq!(HookTarget::Agents.name(), "agents"); + assert_eq!(HookTarget::Precommit.name(), "precommit"); + assert_eq!(HookTarget::ClaudeCodeHook.name(), "claude-code-hook"); + } + + // ── HookTarget::description ──────────────────────────────────── + + #[test] + fn hook_target_description_returns_human_readable() { + assert_eq!(HookTarget::Claude.description(), "CLAUDE.md agent instructions"); + assert_eq!(HookTarget::Precommit.description(), "Git pre-commit hook"); + assert_eq!(HookTarget::ClaudeCodeHook.description(), "Claude Code settings.json hook"); + } + + // ── HookTarget::from_str ─────────────────────────────────────── + + #[test] + fn from_str_parses_all_targets() { + assert_eq!(HookTarget::from_str("claude"), Some(HookTarget::Claude)); + assert_eq!(HookTarget::from_str("cursor"), Some(HookTarget::Cursor)); + assert_eq!(HookTarget::from_str("copilot"), Some(HookTarget::Copilot)); + assert_eq!(HookTarget::from_str("agents"), Some(HookTarget::Agents)); + assert_eq!(HookTarget::from_str("precommit"), Some(HookTarget::Precommit)); + assert_eq!(HookTarget::from_str("claude-code-hook"), Some(HookTarget::ClaudeCodeHook)); + } + + #[test] + fn from_str_is_case_insensitive() { + assert_eq!(HookTarget::from_str("CLAUDE"), Some(HookTarget::Claude)); + assert_eq!(HookTarget::from_str("Cursor"), Some(HookTarget::Cursor)); + assert_eq!(HookTarget::from_str("PreCommit"), Some(HookTarget::Precommit)); + } + + #[test] + fn from_str_accepts_aliases() { + assert_eq!(HookTarget::from_str("pre-commit"), Some(HookTarget::Precommit)); + assert_eq!(HookTarget::from_str("claude-hook"), Some(HookTarget::ClaudeCodeHook)); + } + + #[test] + fn from_str_returns_none_for_unknown() { + assert_eq!(HookTarget::from_str("unknown"), None); + assert_eq!(HookTarget::from_str(""), None); + assert_eq!(HookTarget::from_str("windsurf"), None); + } + + // ── is_installed ─────────────────────────────────────────────── + + #[test] + fn is_installed_returns_false_for_empty_dir() { + let tmp = setup(); + for target in HookTarget::all() { + assert!(!is_installed(tmp.path(), *target), "expected not installed: {:?}", target); + } + } + + #[test] + fn is_installed_claude_detects_marker() { + let tmp = setup(); + let path = tmp.path().join("CLAUDE.md"); + fs::write(&path, "# Spec-Sync Integration\nSome content").unwrap(); + assert!(is_installed(tmp.path(), HookTarget::Claude)); + } + + #[test] + fn is_installed_claude_false_without_marker() { + let tmp = setup(); + let path = tmp.path().join("CLAUDE.md"); + fs::write(&path, "# Some other content\nNo spec-sync here").unwrap(); + assert!(!is_installed(tmp.path(), HookTarget::Claude)); + } + + #[test] + fn is_installed_cursor_detects_marker() { + let tmp = setup(); + let path = tmp.path().join(".cursorrules"); + fs::write(&path, "# Spec-Sync Rules\nSome content").unwrap(); + assert!(is_installed(tmp.path(), HookTarget::Cursor)); + } + + #[test] + fn is_installed_copilot_detects_marker() { + let tmp = setup(); + let github_dir = tmp.path().join(".github"); + fs::create_dir_all(&github_dir).unwrap(); + fs::write(github_dir.join("copilot-instructions.md"), "# Spec-Sync Integration").unwrap(); + assert!(is_installed(tmp.path(), HookTarget::Copilot)); + } + + #[test] + fn is_installed_agents_detects_marker() { + let tmp = setup(); + fs::write(tmp.path().join("AGENTS.md"), "# Spec-Sync Integration\ncontent").unwrap(); + assert!(is_installed(tmp.path(), HookTarget::Agents)); + } + + #[test] + fn is_installed_precommit_detects_marker() { + let tmp = setup(); + let hooks_dir = tmp.path().join(".git").join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\n# spec-sync pre-commit hook\nspecsync check").unwrap(); + assert!(is_installed(tmp.path(), HookTarget::Precommit)); + } + + #[test] + fn is_installed_claude_code_hook_detects_marker() { + let tmp = setup(); + let claude_dir = tmp.path().join(".claude"); + fs::create_dir_all(&claude_dir).unwrap(); + fs::write(claude_dir.join("settings.json"), r#"{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"specsync check"}]}]}}"#).unwrap(); + assert!(is_installed(tmp.path(), HookTarget::ClaudeCodeHook)); + } + + // ── install_hook ─────────────────────────────────────────────── + + #[test] + fn install_claude_creates_file() { + let tmp = setup(); + let result = install_hook(tmp.path(), HookTarget::Claude).unwrap(); + assert!(result); + let content = fs::read_to_string(tmp.path().join("CLAUDE.md")).unwrap(); + assert!(content.contains("Spec-Sync Integration")); + assert!(content.contains("specsync check")); + } + + #[test] + fn install_claude_appends_to_existing() { + let tmp = setup(); + let path = tmp.path().join("CLAUDE.md"); + fs::write(&path, "# My Project\n\nExisting content here.").unwrap(); + let result = install_hook(tmp.path(), HookTarget::Claude).unwrap(); + assert!(result); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.starts_with("# My Project")); + assert!(content.contains("Spec-Sync Integration")); + } + + #[test] + fn install_claude_is_idempotent() { + let tmp = setup(); + assert!(install_hook(tmp.path(), HookTarget::Claude).unwrap()); + assert!(!install_hook(tmp.path(), HookTarget::Claude).unwrap()); + } + + #[test] + fn install_cursor_creates_file() { + let tmp = setup(); + assert!(install_hook(tmp.path(), HookTarget::Cursor).unwrap()); + let content = fs::read_to_string(tmp.path().join(".cursorrules")).unwrap(); + assert!(content.contains("Spec-Sync Rules")); + } + + #[test] + fn install_copilot_creates_github_dir() { + let tmp = setup(); + assert!(install_hook(tmp.path(), HookTarget::Copilot).unwrap()); + assert!(tmp.path().join(".github").join("copilot-instructions.md").exists()); + let content = fs::read_to_string(tmp.path().join(".github").join("copilot-instructions.md")).unwrap(); + assert!(content.contains("Spec-Sync Integration")); + } + + #[test] + fn install_agents_creates_file() { + let tmp = setup(); + assert!(install_hook(tmp.path(), HookTarget::Agents).unwrap()); + let content = fs::read_to_string(tmp.path().join("AGENTS.md")).unwrap(); + assert!(content.contains("Spec-Sync Integration")); + } + + #[test] + fn install_precommit_creates_hook_file() { + let tmp = setup(); + // Need .git/hooks directory structure + fs::create_dir_all(tmp.path().join(".git").join("hooks")).unwrap(); + assert!(install_hook(tmp.path(), HookTarget::Precommit).unwrap()); + let path = tmp.path().join(".git").join("hooks").join("pre-commit"); + assert!(path.exists()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("spec-sync pre-commit hook")); + assert!(content.contains("specsync check --strict")); + } + + #[test] + fn install_precommit_appends_to_existing_hook() { + let tmp = setup(); + let hooks_dir = tmp.path().join(".git").join("hooks"); + fs::create_dir_all(&hooks_dir).unwrap(); + fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho 'existing hook'").unwrap(); + assert!(install_hook(tmp.path(), HookTarget::Precommit).unwrap()); + let content = fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(); + assert!(content.contains("existing hook")); + assert!(content.contains("spec-sync pre-commit hook")); + } + + #[test] + fn install_precommit_creates_hooks_dir_if_missing() { + let tmp = setup(); + // Don't create .git/hooks — let install do it + assert!(install_hook(tmp.path(), HookTarget::Precommit).unwrap()); + assert!(tmp.path().join(".git").join("hooks").join("pre-commit").exists()); + } + + #[cfg(unix)] + #[test] + fn install_precommit_sets_executable_permission() { + use std::os::unix::fs::PermissionsExt; + let tmp = setup(); + install_hook(tmp.path(), HookTarget::Precommit).unwrap(); + let path = tmp.path().join(".git").join("hooks").join("pre-commit"); + let perms = fs::metadata(&path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o755, 0o755); + } + + #[test] + fn install_claude_code_hook_creates_settings() { + let tmp = setup(); + assert!(install_hook(tmp.path(), HookTarget::ClaudeCodeHook).unwrap()); + let path = tmp.path().join(".claude").join("settings.json"); + assert!(path.exists()); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("specsync check")); + } + + #[test] + fn install_claude_code_hook_merges_into_existing() { + let tmp = setup(); + let claude_dir = tmp.path().join(".claude"); + fs::create_dir_all(&claude_dir).unwrap(); + fs::write(claude_dir.join("settings.json"), r#"{"existingKey": true}"#).unwrap(); + assert!(install_hook(tmp.path(), HookTarget::ClaudeCodeHook).unwrap()); + let content = fs::read_to_string(claude_dir.join("settings.json")).unwrap(); + assert!(content.contains("existingKey")); + assert!(content.contains("specsync check")); + } + + #[test] + fn install_claude_code_hook_idempotent() { + let tmp = setup(); + assert!(install_hook(tmp.path(), HookTarget::ClaudeCodeHook).unwrap()); + assert!(!install_hook(tmp.path(), HookTarget::ClaudeCodeHook).unwrap()); + } + + // ── uninstall_hook ───────────────────────────────────────────── + + #[test] + fn uninstall_returns_false_when_not_installed() { + let tmp = setup(); + assert!(!uninstall_hook(tmp.path(), HookTarget::Claude).unwrap()); + } + + #[test] + fn uninstall_claude_removes_section() { + let tmp = setup(); + install_hook(tmp.path(), HookTarget::Claude).unwrap(); + assert!(is_installed(tmp.path(), HookTarget::Claude)); + let result = uninstall_hook(tmp.path(), HookTarget::Claude).unwrap(); + assert!(result); + // File should be removed since it only had our content + assert!(!tmp.path().join("CLAUDE.md").exists()); + } + + #[test] + fn uninstall_claude_preserves_other_content() { + let tmp = setup(); + let path = tmp.path().join("CLAUDE.md"); + fs::write(&path, "# My Project\n\nExisting rules.\n").unwrap(); + install_hook(tmp.path(), HookTarget::Claude).unwrap(); + uninstall_hook(tmp.path(), HookTarget::Claude).unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("My Project")); + assert!(!content.contains("Spec-Sync Integration")); + } + + #[test] + fn uninstall_cursor_removes_section() { + let tmp = setup(); + install_hook(tmp.path(), HookTarget::Cursor).unwrap(); + let result = uninstall_hook(tmp.path(), HookTarget::Cursor).unwrap(); + assert!(result); + assert!(!tmp.path().join(".cursorrules").exists()); + } + + #[test] + fn uninstall_precommit_removes_hook_file() { + let tmp = setup(); + install_hook(tmp.path(), HookTarget::Precommit).unwrap(); + let result = uninstall_hook(tmp.path(), HookTarget::Precommit).unwrap(); + assert!(result); + assert!(!tmp.path().join(".git").join("hooks").join("pre-commit").exists()); + } + + #[test] + fn uninstall_claude_code_hook_is_refused() { + let tmp = setup(); + install_hook(tmp.path(), HookTarget::ClaudeCodeHook).unwrap(); + let result = uninstall_hook(tmp.path(), HookTarget::ClaudeCodeHook); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("manually")); + } + + // ── remove_section_from_file ─────────────────────────────────── + + #[test] + fn remove_section_deletes_file_if_empty_after() { + let tmp = setup(); + let path = tmp.path().join("test.md"); + fs::write(&path, "# Spec-Sync Integration\nSome content\n").unwrap(); + let result = remove_section_from_file(&path, "# Spec-Sync Integration").unwrap(); + assert!(result); + assert!(!path.exists()); + } + + #[test] + fn remove_section_preserves_content_before_marker() { + let tmp = setup(); + let path = tmp.path().join("test.md"); + fs::write(&path, "# My Project\n\nKeep this.\n\n# Spec-Sync Integration\nRemove this.\n").unwrap(); + remove_section_from_file(&path, "# Spec-Sync Integration").unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("My Project")); + assert!(content.contains("Keep this")); + assert!(!content.contains("Spec-Sync Integration")); + } + + #[test] + fn remove_section_returns_false_for_missing_marker() { + let tmp = setup(); + let path = tmp.path().join("test.md"); + fs::write(&path, "# No marker here\n").unwrap(); + assert!(!remove_section_from_file(&path, "# Spec-Sync Integration").unwrap()); + } + + #[test] + fn remove_section_returns_false_for_missing_file() { + let tmp = setup(); + let path = tmp.path().join("nonexistent.md"); + assert!(!remove_section_from_file(&path, "# Spec-Sync Integration").unwrap()); + } + + #[test] + fn remove_section_stops_at_next_top_level_heading() { + let tmp = setup(); + let path = tmp.path().join("test.md"); + fs::write(&path, "# Before\n\nKeep.\n\n# Spec-Sync Integration\nRemove.\n\n# After\n\nAlso keep.\n").unwrap(); + remove_section_from_file(&path, "# Spec-Sync Integration").unwrap(); + let content = fs::read_to_string(&path).unwrap(); + assert!(content.contains("Before")); + assert!(content.contains("Also keep")); + assert!(!content.contains("Spec-Sync Integration")); + } +} From 23f4bef2d12b3ab9b296560c874d06c395472f7a Mon Sep 17 00:00:00 2001 From: Corvid Agent <95454608+corvid-agent@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:45:27 -0700 Subject: [PATCH 3/5] feat: link all specs to GitHub issues via tracks frontmatter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds tracks fields to 7 specs, connecting them to their corresponding open issues for full traceability dogfooding of spec-sync's own issue linking feature. - ai.spec.md → #110 (unit tests) - hooks.spec.md → #112 (unit tests) - mcp.spec.md → #113 (unit tests) - watch.spec.md → #114 (unit tests) - github.spec.md → #97 (external importers) - merge.spec.md → #98 (merge conflict handling) - generator.spec.md → #99 (interactive wizard) Co-Authored-By: Claude Opus 4.6 --- specs/ai/ai.spec.md | 1 + specs/generator/generator.spec.md | 1 + specs/github/github.spec.md | 1 + specs/hooks/hooks.spec.md | 1 + specs/mcp/mcp.spec.md | 1 + specs/merge/merge.spec.md | 1 + specs/watch/watch.spec.md | 1 + 7 files changed, 7 insertions(+) diff --git a/specs/ai/ai.spec.md b/specs/ai/ai.spec.md index 0af5c8c..7227f64 100644 --- a/specs/ai/ai.spec.md +++ b/specs/ai/ai.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/ai.rs db_tables: [] +tracks: [110] depends_on: - specs/types/types.spec.md --- diff --git a/specs/generator/generator.spec.md b/specs/generator/generator.spec.md index a6a914c..012f612 100644 --- a/specs/generator/generator.spec.md +++ b/specs/generator/generator.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/generator.rs db_tables: [] +tracks: [99] depends_on: - specs/types/types.spec.md - specs/ai/ai.spec.md diff --git a/specs/github/github.spec.md b/specs/github/github.spec.md index d9879a7..beff779 100644 --- a/specs/github/github.spec.md +++ b/specs/github/github.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/github.rs db_tables: [] +tracks: [97] depends_on: - specs/parser/parser.spec.md --- diff --git a/specs/hooks/hooks.spec.md b/specs/hooks/hooks.spec.md index 79b1a65..9699e49 100644 --- a/specs/hooks/hooks.spec.md +++ b/specs/hooks/hooks.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/hooks.rs db_tables: [] +tracks: [112] depends_on: [] --- diff --git a/specs/mcp/mcp.spec.md b/specs/mcp/mcp.spec.md index 7b9e9d0..8ca2a09 100644 --- a/specs/mcp/mcp.spec.md +++ b/specs/mcp/mcp.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/mcp.rs db_tables: [] +tracks: [113] depends_on: - specs/types/types.spec.md - specs/validator/validator.spec.md diff --git a/specs/merge/merge.spec.md b/specs/merge/merge.spec.md index 9365730..6e966bc 100644 --- a/specs/merge/merge.spec.md +++ b/specs/merge/merge.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/merge.rs db_tables: [] +tracks: [98] depends_on: - specs/parser/parser.spec.md - specs/validator/validator.spec.md diff --git a/specs/watch/watch.spec.md b/specs/watch/watch.spec.md index 2ea5ba8..186fd7d 100644 --- a/specs/watch/watch.spec.md +++ b/specs/watch/watch.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/watch.rs db_tables: [] +tracks: [114] depends_on: - specs/config/config.spec.md --- From f207ff2c7a9c1c43e6b78f395749074d088639c8 Mon Sep 17 00:00:00 2001 From: Corvid Agent <95454608+corvid-agent@users.noreply.github.com> Date: Mon, 6 Apr 2026 22:59:29 -0700 Subject: [PATCH 4/5] fix: resolve fmt and test CI failures - Fix resolve_ai_provider to check command_for_provider before binary availability, so Cursor returns its specific "no CLI pipe mode" error instead of a generic "not installed" message - Apply cargo fmt to all test code in ai.rs, config.rs, and hooks.rs Co-Authored-By: Claude Opus 4.6 --- src/ai.rs | 26 +++++++----- src/config.rs | 17 ++------ src/hooks.rs | 109 +++++++++++++++++++++++++++++++++++++++++--------- 3 files changed, 108 insertions(+), 44 deletions(-) diff --git a/src/ai.rs b/src/ai.rs index 359b646..8a4e658 100644 --- a/src/ai.rs +++ b/src/ai.rs @@ -168,14 +168,18 @@ pub fn resolve_ai_provider( return resolve_api_provider(&provider, config); } + // Check command_for_provider first — some providers (e.g. Cursor) have + // no CLI pipe mode and should return their specific error message + // before we check binary availability. + let cmd = command_for_provider(&provider, config.ai_model.as_deref())?; + if !is_binary_available(provider.binary_name()) { return Err(format!( "Provider \"{name}\" selected but `{}` is not installed or not on PATH", provider.binary_name() )); } - return command_for_provider(&provider, config.ai_model.as_deref()) - .map(ResolvedProvider::Cli); + return Ok(ResolvedProvider::Cli(cmd)); } // 2. aiCommand in config (explicit override) @@ -189,6 +193,8 @@ pub fn resolve_ai_provider( return resolve_api_provider(provider, config); } + let cmd = command_for_provider(provider, config.ai_model.as_deref())?; + if !is_binary_available(provider.binary_name()) { return Err(format!( "Provider \"{}\" configured but `{}` is not installed or not on PATH", @@ -196,8 +202,7 @@ pub fn resolve_ai_provider( provider.binary_name() )); } - return command_for_provider(provider, config.ai_model.as_deref()) - .map(ResolvedProvider::Cli); + return Ok(ResolvedProvider::Cli(cmd)); } // 4. Environment variable @@ -944,7 +949,10 @@ mod tests { model: "gpt-4o".to_string(), base_url: Some("https://custom.api.com".to_string()), }; - assert_eq!(format!("{p}"), "OpenAI API (gpt-4o @ https://custom.api.com)"); + assert_eq!( + format!("{p}"), + "OpenAI API (gpt-4o @ https://custom.api.com)" + ); } // ── postprocess_spec ─────────────────────────────────────────── @@ -1070,12 +1078,8 @@ mod tests { #[test] fn build_regen_prompt_truncates_large_sources() { let large = "y".repeat(40_000); - let prompt = build_regen_prompt( - "big", - "spec", - "reqs", - &[("src/big.rs".to_string(), large)], - ); + let prompt = + build_regen_prompt("big", "spec", "reqs", &[("src/big.rs".to_string(), large)]); // safe_truncate should have capped it at 30_000 assert!(prompt.len() < 200_000); } diff --git a/src/config.rs b/src/config.rs index 85166dd..e0a1698 100644 --- a/src/config.rs +++ b/src/config.rs @@ -561,11 +561,7 @@ mod tests { #[test] fn test_load_config_json_without_source_dirs_auto_detects() { let tmp = TempDir::new().unwrap(); - fs::write( - tmp.path().join("specsync.json"), - r#"{"specsDir": "specs"}"#, - ) - .unwrap(); + fs::write(tmp.path().join("specsync.json"), r#"{"specsDir": "specs"}"#).unwrap(); let config = load_config(tmp.path()); // sourceDirs not in JSON, so it should auto-detect @@ -626,10 +622,7 @@ verify_issues = false )); assert_eq!(config.ai_model.as_deref(), Some("opus")); assert_eq!(config.ai_timeout, Some(120)); - assert_eq!( - config.required_sections, - vec!["Purpose", "Public API"] - ); + assert_eq!(config.required_sections, vec!["Purpose", "Public API"]); assert_eq!(config.task_archive_days, Some(30)); // Rules @@ -663,11 +656,7 @@ verify_issues = false #[test] fn test_toml_without_source_dirs_auto_detects() { let tmp = TempDir::new().unwrap(); - fs::write( - tmp.path().join(".specsync.toml"), - "specs_dir = \"specs\"\n", - ) - .unwrap(); + fs::write(tmp.path().join(".specsync.toml"), "specs_dir = \"specs\"\n").unwrap(); let config = load_config(tmp.path()); // source_dirs not specified, should auto-detect diff --git a/src/hooks.rs b/src/hooks.rs index 1cf58ca..4e8f46a 100644 --- a/src/hooks.rs +++ b/src/hooks.rs @@ -744,9 +744,15 @@ mod tests { #[test] fn hook_target_description_returns_human_readable() { - assert_eq!(HookTarget::Claude.description(), "CLAUDE.md agent instructions"); + assert_eq!( + HookTarget::Claude.description(), + "CLAUDE.md agent instructions" + ); assert_eq!(HookTarget::Precommit.description(), "Git pre-commit hook"); - assert_eq!(HookTarget::ClaudeCodeHook.description(), "Claude Code settings.json hook"); + assert_eq!( + HookTarget::ClaudeCodeHook.description(), + "Claude Code settings.json hook" + ); } // ── HookTarget::from_str ─────────────────────────────────────── @@ -757,21 +763,36 @@ mod tests { assert_eq!(HookTarget::from_str("cursor"), Some(HookTarget::Cursor)); assert_eq!(HookTarget::from_str("copilot"), Some(HookTarget::Copilot)); assert_eq!(HookTarget::from_str("agents"), Some(HookTarget::Agents)); - assert_eq!(HookTarget::from_str("precommit"), Some(HookTarget::Precommit)); - assert_eq!(HookTarget::from_str("claude-code-hook"), Some(HookTarget::ClaudeCodeHook)); + assert_eq!( + HookTarget::from_str("precommit"), + Some(HookTarget::Precommit) + ); + assert_eq!( + HookTarget::from_str("claude-code-hook"), + Some(HookTarget::ClaudeCodeHook) + ); } #[test] fn from_str_is_case_insensitive() { assert_eq!(HookTarget::from_str("CLAUDE"), Some(HookTarget::Claude)); assert_eq!(HookTarget::from_str("Cursor"), Some(HookTarget::Cursor)); - assert_eq!(HookTarget::from_str("PreCommit"), Some(HookTarget::Precommit)); + assert_eq!( + HookTarget::from_str("PreCommit"), + Some(HookTarget::Precommit) + ); } #[test] fn from_str_accepts_aliases() { - assert_eq!(HookTarget::from_str("pre-commit"), Some(HookTarget::Precommit)); - assert_eq!(HookTarget::from_str("claude-hook"), Some(HookTarget::ClaudeCodeHook)); + assert_eq!( + HookTarget::from_str("pre-commit"), + Some(HookTarget::Precommit) + ); + assert_eq!( + HookTarget::from_str("claude-hook"), + Some(HookTarget::ClaudeCodeHook) + ); } #[test] @@ -787,7 +808,11 @@ mod tests { fn is_installed_returns_false_for_empty_dir() { let tmp = setup(); for target in HookTarget::all() { - assert!(!is_installed(tmp.path(), *target), "expected not installed: {:?}", target); + assert!( + !is_installed(tmp.path(), *target), + "expected not installed: {:?}", + target + ); } } @@ -820,14 +845,22 @@ mod tests { let tmp = setup(); let github_dir = tmp.path().join(".github"); fs::create_dir_all(&github_dir).unwrap(); - fs::write(github_dir.join("copilot-instructions.md"), "# Spec-Sync Integration").unwrap(); + fs::write( + github_dir.join("copilot-instructions.md"), + "# Spec-Sync Integration", + ) + .unwrap(); assert!(is_installed(tmp.path(), HookTarget::Copilot)); } #[test] fn is_installed_agents_detects_marker() { let tmp = setup(); - fs::write(tmp.path().join("AGENTS.md"), "# Spec-Sync Integration\ncontent").unwrap(); + fs::write( + tmp.path().join("AGENTS.md"), + "# Spec-Sync Integration\ncontent", + ) + .unwrap(); assert!(is_installed(tmp.path(), HookTarget::Agents)); } @@ -836,7 +869,11 @@ mod tests { let tmp = setup(); let hooks_dir = tmp.path().join(".git").join("hooks"); fs::create_dir_all(&hooks_dir).unwrap(); - fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\n# spec-sync pre-commit hook\nspecsync check").unwrap(); + fs::write( + hooks_dir.join("pre-commit"), + "#!/bin/sh\n# spec-sync pre-commit hook\nspecsync check", + ) + .unwrap(); assert!(is_installed(tmp.path(), HookTarget::Precommit)); } @@ -845,7 +882,11 @@ mod tests { let tmp = setup(); let claude_dir = tmp.path().join(".claude"); fs::create_dir_all(&claude_dir).unwrap(); - fs::write(claude_dir.join("settings.json"), r#"{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"specsync check"}]}]}}"#).unwrap(); + fs::write( + claude_dir.join("settings.json"), + r#"{"hooks":{"PostToolUse":[{"matcher":"Edit","hooks":[{"type":"command","command":"specsync check"}]}]}}"#, + ) + .unwrap(); assert!(is_installed(tmp.path(), HookTarget::ClaudeCodeHook)); } @@ -892,8 +933,14 @@ mod tests { fn install_copilot_creates_github_dir() { let tmp = setup(); assert!(install_hook(tmp.path(), HookTarget::Copilot).unwrap()); - assert!(tmp.path().join(".github").join("copilot-instructions.md").exists()); - let content = fs::read_to_string(tmp.path().join(".github").join("copilot-instructions.md")).unwrap(); + assert!( + tmp.path() + .join(".github") + .join("copilot-instructions.md") + .exists() + ); + let content = + fs::read_to_string(tmp.path().join(".github").join("copilot-instructions.md")).unwrap(); assert!(content.contains("Spec-Sync Integration")); } @@ -923,7 +970,11 @@ mod tests { let tmp = setup(); let hooks_dir = tmp.path().join(".git").join("hooks"); fs::create_dir_all(&hooks_dir).unwrap(); - fs::write(hooks_dir.join("pre-commit"), "#!/bin/sh\necho 'existing hook'").unwrap(); + fs::write( + hooks_dir.join("pre-commit"), + "#!/bin/sh\necho 'existing hook'", + ) + .unwrap(); assert!(install_hook(tmp.path(), HookTarget::Precommit).unwrap()); let content = fs::read_to_string(hooks_dir.join("pre-commit")).unwrap(); assert!(content.contains("existing hook")); @@ -935,7 +986,13 @@ mod tests { let tmp = setup(); // Don't create .git/hooks — let install do it assert!(install_hook(tmp.path(), HookTarget::Precommit).unwrap()); - assert!(tmp.path().join(".git").join("hooks").join("pre-commit").exists()); + assert!( + tmp.path() + .join(".git") + .join("hooks") + .join("pre-commit") + .exists() + ); } #[cfg(unix)] @@ -1024,7 +1081,13 @@ mod tests { install_hook(tmp.path(), HookTarget::Precommit).unwrap(); let result = uninstall_hook(tmp.path(), HookTarget::Precommit).unwrap(); assert!(result); - assert!(!tmp.path().join(".git").join("hooks").join("pre-commit").exists()); + assert!( + !tmp.path() + .join(".git") + .join("hooks") + .join("pre-commit") + .exists() + ); } #[test] @@ -1052,7 +1115,11 @@ mod tests { fn remove_section_preserves_content_before_marker() { let tmp = setup(); let path = tmp.path().join("test.md"); - fs::write(&path, "# My Project\n\nKeep this.\n\n# Spec-Sync Integration\nRemove this.\n").unwrap(); + fs::write( + &path, + "# My Project\n\nKeep this.\n\n# Spec-Sync Integration\nRemove this.\n", + ) + .unwrap(); remove_section_from_file(&path, "# Spec-Sync Integration").unwrap(); let content = fs::read_to_string(&path).unwrap(); assert!(content.contains("My Project")); @@ -1079,7 +1146,11 @@ mod tests { fn remove_section_stops_at_next_top_level_heading() { let tmp = setup(); let path = tmp.path().join("test.md"); - fs::write(&path, "# Before\n\nKeep.\n\n# Spec-Sync Integration\nRemove.\n\n# After\n\nAlso keep.\n").unwrap(); + fs::write( + &path, + "# Before\n\nKeep.\n\n# Spec-Sync Integration\nRemove.\n\n# After\n\nAlso keep.\n", + ) + .unwrap(); remove_section_from_file(&path, "# Spec-Sync Integration").unwrap(); let content = fs::read_to_string(&path).unwrap(); assert!(content.contains("Before")); From 51b1c5165967bbbc54c0f9b2f2aae4a9cbe8e49b Mon Sep 17 00:00:00 2001 From: Corvid Agent <95454608+corvid-agent@users.noreply.github.com> Date: Tue, 7 Apr 2026 06:21:30 -0700 Subject: [PATCH 5/5] fix: link all specs to their core feature issues instead of test tickets Previously, specs were either unlinked or pointed at unit test tickets (#109-#114). Now every spec tracks the GitHub issue that defines its core feature. Created 4 new tracking issues for foundational modules (parser #117, types #118, validator #119, cli #120). Co-Authored-By: Claude Opus 4.6 --- specs/ai/ai.spec.md | 2 +- specs/archive/archive.spec.md | 1 + specs/cli/cli.spec.md | 1 + specs/compact/compact.spec.md | 1 + specs/config/config.spec.md | 1 + specs/exports/exports.spec.md | 1 + specs/generator/generator.spec.md | 2 +- specs/github/github.spec.md | 2 +- specs/hash_cache/hash_cache.spec.md | 1 + specs/hooks/hooks.spec.md | 2 +- specs/manifest/manifest.spec.md | 1 + specs/mcp/mcp.spec.md | 2 +- specs/parser/parser.spec.md | 1 + specs/registry/registry.spec.md | 1 + specs/schema/schema.spec.md | 1 + specs/scoring/scoring.spec.md | 1 + specs/types/types.spec.md | 1 + specs/validator/validator.spec.md | 1 + specs/view/view.spec.md | 1 + specs/watch/watch.spec.md | 2 +- 20 files changed, 20 insertions(+), 6 deletions(-) diff --git a/specs/ai/ai.spec.md b/specs/ai/ai.spec.md index 7227f64..a34665f 100644 --- a/specs/ai/ai.spec.md +++ b/specs/ai/ai.spec.md @@ -5,7 +5,7 @@ status: stable files: - src/ai.rs db_tables: [] -tracks: [110] +tracks: [19] depends_on: - specs/types/types.spec.md --- diff --git a/specs/archive/archive.spec.md b/specs/archive/archive.spec.md index cda61d1..274d6a7 100644 --- a/specs/archive/archive.spec.md +++ b/specs/archive/archive.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/archive.rs db_tables: [] +tracks: [94] depends_on: - specs/validator/validator.spec.md --- diff --git a/specs/cli/cli.spec.md b/specs/cli/cli.spec.md index 368d39d..163e8a6 100644 --- a/specs/cli/cli.spec.md +++ b/specs/cli/cli.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/main.rs db_tables: [] +tracks: [120] depends_on: - specs/config/config.spec.md - specs/parser/parser.spec.md diff --git a/specs/compact/compact.spec.md b/specs/compact/compact.spec.md index 43959f5..8bfc59c 100644 --- a/specs/compact/compact.spec.md +++ b/specs/compact/compact.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/compact.rs db_tables: [] +tracks: [94] depends_on: - specs/validator/validator.spec.md --- diff --git a/specs/config/config.spec.md b/specs/config/config.spec.md index 97e5acf..5e7c9fd 100644 --- a/specs/config/config.spec.md +++ b/specs/config/config.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/config.rs db_tables: [] +tracks: [31] depends_on: - specs/types/types.spec.md - specs/exports/exports.spec.md diff --git a/specs/exports/exports.spec.md b/specs/exports/exports.spec.md index cc50157..8a5ad78 100644 --- a/specs/exports/exports.spec.md +++ b/specs/exports/exports.spec.md @@ -16,6 +16,7 @@ files: - src/exports/php.rs - src/exports/ruby.rs db_tables: [] +tracks: [60] depends_on: - specs/types/types.spec.md --- diff --git a/specs/generator/generator.spec.md b/specs/generator/generator.spec.md index 012f612..70032e5 100644 --- a/specs/generator/generator.spec.md +++ b/specs/generator/generator.spec.md @@ -5,7 +5,7 @@ status: stable files: - src/generator.rs db_tables: [] -tracks: [99] +tracks: [73] depends_on: - specs/types/types.spec.md - specs/ai/ai.spec.md diff --git a/specs/github/github.spec.md b/specs/github/github.spec.md index beff779..093df8c 100644 --- a/specs/github/github.spec.md +++ b/specs/github/github.spec.md @@ -5,7 +5,7 @@ status: stable files: - src/github.rs db_tables: [] -tracks: [97] +tracks: [102] depends_on: - specs/parser/parser.spec.md --- diff --git a/specs/hash_cache/hash_cache.spec.md b/specs/hash_cache/hash_cache.spec.md index 82ac687..2fe68ab 100644 --- a/specs/hash_cache/hash_cache.spec.md +++ b/specs/hash_cache/hash_cache.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/hash_cache.rs db_tables: [] +tracks: [90] depends_on: - specs/parser/parser.spec.md --- diff --git a/specs/hooks/hooks.spec.md b/specs/hooks/hooks.spec.md index 9699e49..4163705 100644 --- a/specs/hooks/hooks.spec.md +++ b/specs/hooks/hooks.spec.md @@ -5,7 +5,7 @@ status: stable files: - src/hooks.rs db_tables: [] -tracks: [112] +tracks: [39] depends_on: [] --- diff --git a/specs/manifest/manifest.spec.md b/specs/manifest/manifest.spec.md index a2e5653..0ab89e8 100644 --- a/specs/manifest/manifest.spec.md +++ b/specs/manifest/manifest.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/manifest.rs db_tables: [] +tracks: [55] depends_on: [] --- diff --git a/specs/mcp/mcp.spec.md b/specs/mcp/mcp.spec.md index 8ca2a09..98c28ba 100644 --- a/specs/mcp/mcp.spec.md +++ b/specs/mcp/mcp.spec.md @@ -5,7 +5,7 @@ status: stable files: - src/mcp.rs db_tables: [] -tracks: [113] +tracks: [30] depends_on: - specs/types/types.spec.md - specs/validator/validator.spec.md diff --git a/specs/parser/parser.spec.md b/specs/parser/parser.spec.md index 287caf8..831b125 100644 --- a/specs/parser/parser.spec.md +++ b/specs/parser/parser.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/parser.rs db_tables: [] +tracks: [117] depends_on: - specs/types/types.spec.md --- diff --git a/specs/registry/registry.spec.md b/specs/registry/registry.spec.md index 42d7772..79053e8 100644 --- a/specs/registry/registry.spec.md +++ b/specs/registry/registry.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/registry.rs db_tables: [] +tracks: [52] depends_on: - specs/types/types.spec.md --- diff --git a/specs/schema/schema.spec.md b/specs/schema/schema.spec.md index f24fcea..558d111 100644 --- a/specs/schema/schema.spec.md +++ b/specs/schema/schema.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/schema.rs db_tables: [] +tracks: [63] depends_on: [] --- diff --git a/specs/scoring/scoring.spec.md b/specs/scoring/scoring.spec.md index 0b99a0a..28a7664 100644 --- a/specs/scoring/scoring.spec.md +++ b/specs/scoring/scoring.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/scoring.rs db_tables: [] +tracks: [31] depends_on: - specs/types/types.spec.md - specs/parser/parser.spec.md diff --git a/specs/types/types.spec.md b/specs/types/types.spec.md index bd2b570..ae7583d 100644 --- a/specs/types/types.spec.md +++ b/specs/types/types.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/types.rs db_tables: [] +tracks: [118] depends_on: [] --- diff --git a/specs/validator/validator.spec.md b/specs/validator/validator.spec.md index 918364b..d55df1c 100644 --- a/specs/validator/validator.spec.md +++ b/specs/validator/validator.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/validator.rs db_tables: [] +tracks: [119] depends_on: - specs/types/types.spec.md - specs/parser/parser.spec.md diff --git a/specs/view/view.spec.md b/specs/view/view.spec.md index 3ac7357..73f9828 100644 --- a/specs/view/view.spec.md +++ b/specs/view/view.spec.md @@ -5,6 +5,7 @@ status: stable files: - src/view.rs db_tables: [] +tracks: [94] depends_on: - specs/parser/parser.spec.md --- diff --git a/specs/watch/watch.spec.md b/specs/watch/watch.spec.md index 186fd7d..8818a26 100644 --- a/specs/watch/watch.spec.md +++ b/specs/watch/watch.spec.md @@ -5,7 +5,7 @@ status: stable files: - src/watch.rs db_tables: [] -tracks: [114] +tracks: [4] depends_on: - specs/config/config.spec.md ---