From f96e881ede1970c4fc0149f6ea7dbad5091b2585 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Sun, 9 Aug 2026 16:36:34 -0700 Subject: [PATCH 1/7] repair documentation capability parity --- README.md | 3 +- crates/no-mistakes/tests/docs_coverage.rs | 185 ++++++++++++------ docs/cli/README.md | 7 + docs/node-api.md | 70 +++++++ skills/no-mistakes/SKILL.md | 19 +- .../references/limits-and-fallbacks.md | 8 +- 6 files changed, 225 insertions(+), 67 deletions(-) diff --git a/README.md b/README.md index bd890e914..a39d36dfe 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,8 @@ Deterministic AST-based codebase intelligence for AI agents. `no-mistakes` answers structural questions about TypeScript, JavaScript, -React, Next.js, Playwright, queue, server-route, and Rust repository code +React, Next.js, Playwright, queue, server-route, CI-workflow, +Terraform/OpenTofu, and Swift code without running the application or calling an AI model. It is built for agents that need small, reliable answers they can feed into follow-up edits and tests. diff --git a/crates/no-mistakes/tests/docs_coverage.rs b/crates/no-mistakes/tests/docs_coverage.rs index 363f2ab53..612e23d07 100644 --- a/crates/no-mistakes/tests/docs_coverage.rs +++ b/crates/no-mistakes/tests/docs_coverage.rs @@ -1,5 +1,6 @@ use no_mistakes::codebase::{rules, unique_exports}; use no_mistakes::playwright::rules as playwright_rules; +use std::collections::{BTreeSet, VecDeque}; use std::path::{Path, PathBuf}; fn repo_root() -> PathBuf { @@ -10,74 +11,136 @@ fn read(path: &Path) -> String { std::fs::read_to_string(path).unwrap_or_else(|err| panic!("{}: {err}", path.display())) } -fn joined_docs(dir: &Path) -> String { - let mut body = String::new(); - let mut paths = std::fs::read_dir(dir) - .unwrap() - .map(|entry| entry.unwrap().path()) +#[test] +fn cli_commands_have_docs() { + let root = repo_root(); + let source = read(&root.join("crates/no-mistakes/src/main.rs")); + let index = read(&root.join("docs/cli/README.md")); + let command_block = source + .split_once("enum Command {") + .and_then(|(_, rest)| rest.split_once("\n}\n")) + .map(|(block, _)| block) + .expect("main.rs must define a closed Command enum"); + assert!( + !command_block.lines().any(|line| line.contains("name =")), + "a clap command name override needs an explicit docs-coverage mapping" + ); + + let commands = command_block + .lines() + .filter_map(|line| { + let name = line.trim().split_once('(')?.0; + if name.is_empty() || name.starts_with('#') || name.starts_with("///") { + return None; + } + Some(kebab_case(name)) + }) .collect::>(); - paths.sort(); - for path in paths { - if path.extension().and_then(|ext| ext.to_str()) == Some("md") { - body.push_str(&read(&path)); - body.push('\n'); + assert!( + !commands.is_empty(), + "Command enum inventory must not be empty" + ); + + for command in commands { + let file = format!("{command}.md"); + let path = root.join("docs/cli").join(&file); + assert!(path.exists(), "missing CLI doc {}", path.display()); + assert!( + index.contains(&format!("({file})")), + "docs/cli/README.md must index {file}" + ); + } + + // Every leaf page must be reachable from the CLI index or its command + // group page. Follow only links rooted under docs/cli so an orphan page + // cannot make itself appear reachable by containing its own filename. + let cli_dir = root.join("docs/cli"); + let linked_pages = reachable_cli_pages(&cli_dir); + for entry in std::fs::read_dir(root.join("docs/cli")).unwrap() { + let path = entry.unwrap().path(); + if path.extension().and_then(|ext| ext.to_str()) != Some("md") + || path.file_name().and_then(|name| name.to_str()) == Some("README.md") + { + continue; + } + let file = path.file_name().unwrap().to_string_lossy(); + assert!( + linked_pages.contains(file.as_ref()), + "CLI page {file} is not linked by a CLI index or command group" + ); + } +} + +fn reachable_cli_pages(cli_dir: &Path) -> BTreeSet { + let cli_dir = cli_dir.canonicalize().unwrap(); + let mut seen = BTreeSet::new(); + let mut pending = VecDeque::from([cli_dir.join("README.md")]); + while let Some(path) = pending.pop_front() { + let Ok(relative) = path.strip_prefix(&cli_dir) else { + continue; + }; + let relative = relative.to_string_lossy().into_owned(); + if !seen.insert(relative) { + continue; + } + let body = read(&path); + let mut remaining = body.as_str(); + while let Some(start) = remaining.find("](") { + remaining = &remaining[start + 2..]; + let Some(end) = remaining.find(')') else { + break; + }; + let target = remaining[..end].split('#').next().unwrap_or_default(); + remaining = &remaining[end + 1..]; + if target.is_empty() || target.starts_with("http") { + continue; + } + let target_path = path.parent().unwrap().join(target); + if target_path.extension().and_then(|ext| ext.to_str()) != Some("md") { + continue; + } + let Ok(target_path) = target_path.canonicalize() else { + continue; + }; + if target_path.starts_with(&cli_dir) { + pending.push_back(target_path); + } + } + } + seen +} + +fn kebab_case(value: &str) -> String { + let mut result = String::new(); + for (index, character) in value.chars().enumerate() { + if character.is_uppercase() && index != 0 { + result.push('-'); } + result.extend(character.to_lowercase()); } - body + result } #[test] -fn cli_leaf_commands_have_docs() { +fn node_runtime_exports_have_api_docs() { let root = repo_root(); - let cli_docs = joined_docs(&root.join("docs/cli")); - let commands = [ - "dependencies", - "dependents", - "related", - "symbols", - "importers", - "exports-of", - "dead-exports", - "call-sites", - "resolve-check", - "fetches", - "flow", - "check", - "tests-plan", - "tests-targets", - "tests-impact", - "tests-why", - "tests-comment", - "tests-graph", - "playwright-check", - "playwright-edges", - "playwright-related", - "playwright-tests", - "react-analyze", - "react-check", - "react-usages", - "queues-edges", - "queues-related", - "queues-check", - "server-routes", - "server-edges", - "server-related", - "server-contracts", - "ci-impact", - "ci-env", - "ci-topology", - "impacted-checks", - "infra-resource-refs", - "infra-outputs", - "infra-test-for", - "swift-importers", - "swift-test-targets", - ]; - for command in commands { - let file = format!("{command}.md"); - let path = root.join("docs/cli").join(&file); - assert!(path.exists(), "missing CLI doc {}", path.display()); - assert!(cli_docs.contains(&file), "docs/cli/*.md must link {file}"); + let source = read(&root.join("packages/no-mistakes/index.js")); + let docs = read(&root.join("docs/node-api.md")); + let exports = source + .lines() + .filter_map(|line| line.trim().strip_prefix("module.exports.")) + .filter_map(|assignment| assignment.split_once(' ').map(|(name, _)| name)) + .collect::>(); + assert!( + !exports.is_empty(), + "runtime export inventory must not be empty" + ); + for export in exports { + assert!( + docs.lines() + .any(|line| line.starts_with('|') && line.contains(&format!("`{export}`"))), + "docs/node-api.md must map runtime export `{export}`" + ); } } diff --git a/docs/cli/README.md b/docs/cli/README.md index 234203f74..0d3c891bc 100644 --- a/docs/cli/README.md +++ b/docs/cli/README.md @@ -48,6 +48,10 @@ command name. See [Performance diagnostics](diagnostics.md). | [`resolve-check`](resolve-check.md) | Whether all imports in a file resolve. | | [`fetches`](fetches.md) | Next.js routes mapped to static fetch API calls. | | [`flow`](flow.md) | Compact dependency/symbol flow around one file or export. | +| [`data-pw`](data-pw.md) | Find selector-attribute usages of a value across source and tests. | +| [`effects`](effects.md) | Find configured transitive effect call sites from an entry file. | +| [`rsc-callers`](rsc-callers.md) | Find server components/pages that import a component. | +| [`registry-extension`](registry-extension.md) | Summarize how entries register in a registry file. | | [`check`](check.md) | Configured project-wide checks. | | [`lockfile`](lockfile.md) | Show which packages changed between two lockfile versions. | | [`tests`](tests.md) | Test plan, impact, explanation, comments, and graphs. | @@ -60,6 +64,9 @@ command name. See [Performance diagnostics](diagnostics.md). | [`infra`](infra.md) | Terraform/OpenTofu resource, module, and output relationships. | | [`swift`](swift.md) | Swift package importers and covering test targets. | +The [graph reference](graph.md) explains shared options and relationship +filters used by the graph commands. + ## Shared Output Formats Most commands accept `--format json|yml|md|paths|human` plus `--json`. diff --git a/docs/node-api.md b/docs/node-api.md index 73ba1a1e9..cab30ce26 100644 --- a/docs/node-api.md +++ b/docs/node-api.md @@ -56,9 +56,18 @@ const { | `related` | `related(options)` | | `symbols` | `symbols(options)` | | `import-usages` | `importUsages(options)` | +| `importers` | `importers(options)` | +| `exports-of` | `exportsOf(options)` | +| `dead-exports` | `deadExports(options)` | +| `call-sites` | `callSites(options)` | +| `resolve-check` | `resolveCheck(options)` | | `fetches` | `fetches(options)` | | `flow` | `flow(options)` | | `check` | `check(options)` | +| `data-pw` | `dataPw(options)` | +| `effects` | `effects(options)` | +| `rsc-callers` | `rscCallers(options)` | +| `registry-extension` | `registryExtension(options)` | | `tests plan` | `testsPlan(options)`; `framework` accepts `vitest`, `playwright`, `dotnet`, or `swift` | | `tests targets` | `testsTargets(options)` | | `tests impact` | `testsImpact(options)` | @@ -77,6 +86,67 @@ const { | `ci topology` | `ciTopology(options)` | | `impacted-checks` | `impactedChecks(options)` | +The following inventory is the complete runtime export surface. Keeping this +list exhaustive makes a newly added function visible to agents even when it +does not have a one-to-one CLI command: + +| Runtime export | API | +| --- | --- | +| `createWorkflowTopologyIndex` | `createWorkflowTopologyIndex(topology)` | +| `version` | `version()` | +| `analyzeProject` | `analyzeProject(options)` | +| `callSites` | `callSites(options)` | +| `check` | `check(options)` | +| `ciEnv` | `ciEnv(options)` | +| `ciImpact` | `ciImpact(options)` | +| `ciTopology` | `ciTopology(options)` | +| `dataPw` | `dataPw(options)` | +| `deadExports` | `deadExports(options)` | +| `dependencies` | `dependencies(options)` | +| `dependents` | `dependents(options)` | +| `effects` | `effects(options)` | +| `exportsOf` | `exportsOf(options)` | +| `fetches` | `fetches(options)` | +| `flow` | `flow(options)` | +| `impactedChecks` | `impactedChecks(options)` | +| `importUsages` | `importUsages(options)` | +| `importers` | `importers(options)` | +| `infraOutputs` | `infraOutputs(options)` | +| `infraResourceRefs` | `infraResourceRefs(options)` | +| `infraTestFor` | `infraTestFor(options)` | +| `lockfileDiff` | `lockfileDiff(options)` | +| `validateMermaidMarkdown` | `validateMermaidMarkdown(options)` | +| `playwrightCheck` | `playwrightCheck(options)` | +| `playwrightEdges` | `playwrightEdges(options)` | +| `playwrightRelated` | `playwrightRelated(options)` | +| `playwrightTests` | `playwrightTests(options)` | +| `reactAnalyze` | `reactAnalyze(options)` | +| `reactCheck` | `reactCheck(options)` | +| `reactUsages` | `reactUsages(options)` | +| `registryExtension` | `registryExtension(options)` | +| `related` | `related(options)` | +| `resolveCheck` | `resolveCheck(options)` | +| `rscCallers` | `rscCallers(options)` | +| `swiftImporters` | `swiftImporters(options)` | +| `swiftTestTargets` | `swiftTestTargets(options)` | +| `symbols` | `symbols(options)` | +| `testsComment` | `testsComment(options)` | +| `testsGraphMermaid` | `testsGraphMermaid(options)` | +| `queueCheck` | `queueCheck(options)` | +| `queueEdges` | `queueEdges(options)` | +| `queueRelated` | `queueRelated(options)` | +| `queues` | `queues(options)` | +| `serverContracts` | `serverContracts(options)` | +| `serverRouteEdges` | `serverRouteEdges(options)` | +| `serverRouteList` | `serverRouteList(options)` | +| `serverRouteRelated` | `serverRouteRelated(options)` | +| `serverRoutes` | `serverRoutes(options)` | +| `testsGraph` | `testsGraph(options)` | +| `testsImpact` | `testsImpact(options)` | +| `testsPlan` | `testsPlan(options)` | +| `testsTargets` | `testsTargets(options)` | +| `testsWhy` | `testsWhy(options)` | + `testsTargets()` and test-plan targets set `workspace: true` when a Vitest workspace/project-array source must be passed with `--workspace`; the emitted `runner_args` already contain the correct flag. This includes configured and diff --git a/skills/no-mistakes/SKILL.md b/skills/no-mistakes/SKILL.md index 963332c2a..05909ab0b 100644 --- a/skills/no-mistakes/SKILL.md +++ b/skills/no-mistakes/SKILL.md @@ -169,6 +169,19 @@ no-mistakes symbols src/api.mts --include both --format json no-mistakes import-usages --root . --filter 'src/**' --format json no-mistakes symbols src/api.mts --mode signature-impact --symbol handler --format json +# Reuse one prepared analysis for related reports +node --input-type=module - <<'NODE' +import { analyzeProject } from "no-mistakes"; +const report = await analyzeProject({ + root: process.cwd(), + reports: [ + { type: "dependencies", files: ["src/api.mts"] }, + { type: "symbols", files: ["src/api.mts"], include: "both" }, + ], +}); +console.log(report); +NODE + # Playwright coverage gate before finishing Next.js / Playwright work no-mistakes playwright check --json no-mistakes playwright related web/app/users/page.tsx --json @@ -221,7 +234,7 @@ reports. Note: `analyzeProject` does not support `testsPlan`, `fetches`, or - `--filter ` to include only matching files; repeatable. - `--target-module ` to include only matching external module nodes (useful with `--relationship package`). - `--test vitest|playwright|cargo|dotnet|swift` to filter to test files. -- `--relationship import|import-static|import-dynamic|import-type|import-require|route-import|workspace|package|test|route|queue|md|ci|workflow|workflow-job|workflow-step|workflow-needs|workflow-uses|workflow-run|workflow-artifact|http|process|asset|react|dotnet|swift|terraform|all`. +- `--relationship import|import-static|import-dynamic|import-type|import-require|route-import|workspace|package|test|route|queue|resource|md|ci|workflow|workflow-job|workflow-step|workflow-needs|workflow-uses|workflow-run|workflow-artifact|http|process|asset|react|dotnet|swift|terraform|all`. - `--direction deps|dependents|both` for `queues related` and `server related`. - `--format json|md|yml|paths|human`, `--json`, root-global `--timings` / `--verbose-timings` (stderr), and `--jobs`. @@ -256,7 +269,9 @@ member usage. ## Hard Limits -- `baseUrl`-only imports are not resolved; use `compilerOptions.paths`. +- `baseUrl`-only imports are resolved when `compilerOptions.baseUrl` is set. + Prefer `compilerOptions.paths` for explicit aliases, especially when an + alias should be shared across workspace packages. - Dynamic `import()` and `require()` are tracked only with literals. - `route-import` is deliberately conservative: it includes runtime static imports/re-exports and literal dynamic imports inside functions, but excludes diff --git a/skills/no-mistakes/references/limits-and-fallbacks.md b/skills/no-mistakes/references/limits-and-fallbacks.md index 74c813ae0..d963932f5 100644 --- a/skills/no-mistakes/references/limits-and-fallbacks.md +++ b/skills/no-mistakes/references/limits-and-fallbacks.md @@ -4,18 +4,20 @@ These patterns need extra care when using the module-graph tools. When you hit a ## baseUrl-only imports -`compilerOptions.baseUrl` resolves bare specifiers not listed in `paths`. The tool only reads `paths`, not `baseUrl`. +`compilerOptions.baseUrl` resolves bare specifiers that are not listed in +`paths`. The resolver follows that setting, including extension and index-file +candidates. Prefer `paths` for explicit aliases, especially when an alias is +shared across workspace packages. ```json { "compilerOptions": { "baseUrl": "./src" - // import 'utils' resolves to './src/utils.ts' via baseUrl — NOT supported + // import 'utils' resolves to './src/utils.ts' via baseUrl } } ``` -**Workaround:** use `rg 'from .utils.' src/` — these imports still appear as literal strings. ## Dynamic import() From b0344c4f445037955ba9a4cfa032cff18b6941e8 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 09:05:56 -0700 Subject: [PATCH 2/7] test: cover nested CLI documentation --- crates/no-mistakes/tests/docs_coverage.rs | 265 ++++++++++++++++-- .../references/limits-and-fallbacks.md | 2 +- 2 files changed, 237 insertions(+), 30 deletions(-) diff --git a/crates/no-mistakes/tests/docs_coverage.rs b/crates/no-mistakes/tests/docs_coverage.rs index 612e23d07..3b76a4c86 100644 --- a/crates/no-mistakes/tests/docs_coverage.rs +++ b/crates/no-mistakes/tests/docs_coverage.rs @@ -14,47 +14,81 @@ fn read(path: &Path) -> String { #[test] fn cli_commands_have_docs() { let root = repo_root(); - let source = read(&root.join("crates/no-mistakes/src/main.rs")); - let index = read(&root.join("docs/cli/README.md")); - let command_block = source - .split_once("enum Command {") - .and_then(|(_, rest)| rest.split_once("\n}\n")) - .map(|(block, _)| block) - .expect("main.rs must define a closed Command enum"); + let cli_dir = root.join("docs/cli"); + let index = read(&cli_dir.join("README.md")); + let source_dir = root.join("crates/no-mistakes/src"); + + // Inventory every clap subcommand field from source. This includes the + // top-level `Command` enum and nested enums such as `TestsCommand`; a + // guard that only knows about main.rs can silently lose `tests plan` and + // other leaf pages when a command is added or its page is deleted. + let mut inventories = Vec::new(); + for source_path in rust_sources(&source_dir) { + let source = read(&source_path); + for (parent, enum_name) in subcommand_enums(&source) { + let block = enum_block(&source, &enum_name).unwrap_or_else(|| { + panic!( + "{}: clap subcommand type `{enum_name}` must have an enum body", + source_path.display() + ) + }); + assert!( + !block.lines().any(|line| line.contains("name =")), + "{}: a clap command name override needs an explicit docs-coverage mapping", + source_path.display() + ); + let variants = enum_variants(block); + assert!( + !variants.is_empty(), + "{}: {enum_name} command inventory must not be empty", + source_path.display() + ); + inventories.push((parent, variants)); + } + } assert!( - !command_block.lines().any(|line| line.contains("name =")), - "a clap command name override needs an explicit docs-coverage mapping" + !inventories.is_empty(), + "source inventory must find at least one clap subcommand enum" ); - let commands = command_block - .lines() - .filter_map(|line| { - let name = line.trim().split_once('(')?.0; - if name.is_empty() || name.starts_with('#') || name.starts_with("///") { - return None; + for (parent, variants) in inventories { + let Some(prefix) = parent.strip_suffix("Args").map(kebab_case) else { + // `Cli` is the one top-level parser struct; its command pages are + // rooted directly at docs/cli and have no group prefix. + assert_eq!(parent, "Cli", "unexpected clap parser struct `{parent}`"); + for variant in variants { + assert_cli_page(&cli_dir, &index, &variant, None, 1); } - Some(kebab_case(name)) - }) - .collect::>(); - assert!( - !commands.is_empty(), - "Command enum inventory must not be empty" - ); + continue; + }; - for command in commands { - let file = format!("{command}.md"); - let path = root.join("docs/cli").join(&file); - assert!(path.exists(), "missing CLI doc {}", path.display()); + let group_file = format!("{prefix}.md"); + let group_path = cli_dir.join(&group_file); + assert!( + group_path.exists(), + "missing CLI group doc {}", + group_path.display() + ); assert!( - index.contains(&format!("({file})")), - "docs/cli/README.md must index {file}" + index.contains(&format!("({group_file})")), + "docs/cli/README.md must index {group_file}" ); + + let variant_count = variants.len(); + for variant in variants { + assert_cli_page( + &cli_dir, + &read(&group_path), + &variant, + Some(&prefix), + variant_count, + ); + } } // Every leaf page must be reachable from the CLI index or its command // group page. Follow only links rooted under docs/cli so an orphan page // cannot make itself appear reachable by containing its own filename. - let cli_dir = root.join("docs/cli"); let linked_pages = reachable_cli_pages(&cli_dir); for entry in std::fs::read_dir(root.join("docs/cli")).unwrap() { let path = entry.unwrap().path(); @@ -71,6 +105,179 @@ fn cli_commands_have_docs() { } } +fn assert_cli_page( + cli_dir: &Path, + parent_body: &str, + variant: &str, + prefix: Option<&str>, + variant_count: usize, +) { + let variant = kebab_case(variant); + let (file, indexed_by_parent) = match prefix { + Some(prefix) => { + let leaf_file = format!("{prefix}-{variant}.md"); + let leaf_path = cli_dir.join(&leaf_file); + if leaf_path.exists() { + (leaf_file, true) + } else { + // A one-command group may document its only leaf directly on + // the group page (currently `lockfile diff`). If a second + // variant is added, the caller's per-variant lookup will no + // longer permit this fallback without a dedicated leaf page. + let group_file = format!("{prefix}.md"); + assert_cli_group_has_one_leaf(parent_body, prefix, &group_file, variant_count); + (group_file, false) + } + } + None => (format!("{variant}.md"), false), + }; + let path = cli_dir.join(&file); + assert!(path.exists(), "missing CLI doc {}", path.display()); + if indexed_by_parent { + assert!( + parent_body.contains(&format!("({file})")), + "CLI group doc must index {file}" + ); + } +} + +fn assert_cli_group_has_one_leaf( + parent_body: &str, + prefix: &str, + group_file: &str, + variant_count: usize, +) { + assert_eq!( + variant_count, 1, + "{group_file} has multiple subcommands; every leaf needs a dedicated CLI page" + ); + let linked_children = parent_body + .lines() + .filter_map(|line| { + let target = line.split_once("](")?.1.split_once(')')?.0; + Some(target.to_string()) + }) + .filter(|target| target.ends_with(".md") && target.starts_with(&format!("{prefix}-"))) + .count(); + assert_eq!( + linked_children, 0, + "{group_file} has linked leaf pages; missing {prefix}-.md must be fixed explicitly" + ); +} + +fn rust_sources(dir: &Path) -> Vec { + let mut paths = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + paths.extend(rust_sources(&path)); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + paths.push(path); + } + } + paths.sort(); + paths +} + +fn subcommand_enums(source: &str) -> Vec<(String, String)> { + let mut result = Vec::new(); + let mut search_from = 0; + while let Some(relative) = source[search_from..].find("#[command(subcommand)]") { + let attribute_start = search_from + relative; + let after_attribute = attribute_start + "#[command(subcommand)]".len(); + let field_end = source[after_attribute..] + .find('}') + .map(|offset| after_attribute + offset) + .unwrap_or(source.len()); + let field = &source[after_attribute..field_end]; + let Some(command_field) = field.find("command:") else { + search_from = after_attribute; + continue; + }; + let enum_name = field[command_field + "command:".len()..] + .split(|character: char| character == ',' || character == ';' || character == '}') + .next() + .unwrap() + .trim() + .to_string(); + let parent = source[..attribute_start] + .rfind("struct ") + .and_then(|offset| { + source[offset + "struct ".len()..] + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .next() + }) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| panic!("clap subcommand attribute has no containing struct")) + .to_string(); + result.push((parent, enum_name)); + search_from = after_attribute; + } + result +} + +fn enum_block<'a>(source: &'a str, enum_name: &str) -> Option<&'a str> { + let marker = format!("enum {enum_name}"); + let mut search_from = 0; + while let Some(relative) = source[search_from..].find(&marker) { + let start = search_from + relative; + let after_name = start + marker.len(); + if source[after_name..] + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_') + { + search_from = after_name; + continue; + } + let open = source[after_name..].find('{')? + after_name; + let mut depth = 0; + for (offset, character) in source[open..].char_indices() { + match character { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(&source[open + 1..open + offset]); + } + } + _ => {} + } + } + return None; + } + None +} + +fn enum_variants(block: &str) -> Vec { + block + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with("//") { + return None; + } + let name = line + .split(|character: char| { + character == '(' + || character == '{' + || character == ',' + || character.is_whitespace() + }) + .next()?; + if name.chars().next()?.is_ascii_uppercase() + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') + { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + fn reachable_cli_pages(cli_dir: &Path) -> BTreeSet { let cli_dir = cli_dir.canonicalize().unwrap(); let mut seen = BTreeSet::new(); diff --git a/skills/no-mistakes/references/limits-and-fallbacks.md b/skills/no-mistakes/references/limits-and-fallbacks.md index d963932f5..f06bfdd91 100644 --- a/skills/no-mistakes/references/limits-and-fallbacks.md +++ b/skills/no-mistakes/references/limits-and-fallbacks.md @@ -9,7 +9,7 @@ These patterns need extra care when using the module-graph tools. When you hit a candidates. Prefer `paths` for explicit aliases, especially when an alias is shared across workspace packages. -```json +```jsonc { "compilerOptions": { "baseUrl": "./src" From 4fd9dc328050a776b7c3bea7789440b7ef07edea Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 09:20:06 -0700 Subject: [PATCH 3/7] test: keep CLI docs guard within line budget --- crates/no-mistakes/tests/docs_coverage.rs | 170 +----------------- .../support/docs_coverage_cli_helpers.rs | 165 +++++++++++++++++ 2 files changed, 171 insertions(+), 164 deletions(-) create mode 100644 crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs diff --git a/crates/no-mistakes/tests/docs_coverage.rs b/crates/no-mistakes/tests/docs_coverage.rs index 3b76a4c86..c655f6804 100644 --- a/crates/no-mistakes/tests/docs_coverage.rs +++ b/crates/no-mistakes/tests/docs_coverage.rs @@ -1,8 +1,13 @@ use no_mistakes::codebase::{rules, unique_exports}; use no_mistakes::playwright::rules as playwright_rules; -use std::collections::{BTreeSet, VecDeque}; use std::path::{Path, PathBuf}; +#[path = "support/docs_coverage_cli_helpers.rs"] +mod cli_docs_helpers; +use cli_docs_helpers::{ + enum_block, enum_variants, kebab_case, reachable_cli_pages, rust_sources, subcommand_enums, +}; + fn repo_root() -> PathBuf { PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") } @@ -165,169 +170,6 @@ fn assert_cli_group_has_one_leaf( ); } -fn rust_sources(dir: &Path) -> Vec { - let mut paths = Vec::new(); - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - paths.extend(rust_sources(&path)); - } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { - paths.push(path); - } - } - paths.sort(); - paths -} - -fn subcommand_enums(source: &str) -> Vec<(String, String)> { - let mut result = Vec::new(); - let mut search_from = 0; - while let Some(relative) = source[search_from..].find("#[command(subcommand)]") { - let attribute_start = search_from + relative; - let after_attribute = attribute_start + "#[command(subcommand)]".len(); - let field_end = source[after_attribute..] - .find('}') - .map(|offset| after_attribute + offset) - .unwrap_or(source.len()); - let field = &source[after_attribute..field_end]; - let Some(command_field) = field.find("command:") else { - search_from = after_attribute; - continue; - }; - let enum_name = field[command_field + "command:".len()..] - .split(|character: char| character == ',' || character == ';' || character == '}') - .next() - .unwrap() - .trim() - .to_string(); - let parent = source[..attribute_start] - .rfind("struct ") - .and_then(|offset| { - source[offset + "struct ".len()..] - .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') - .next() - }) - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| panic!("clap subcommand attribute has no containing struct")) - .to_string(); - result.push((parent, enum_name)); - search_from = after_attribute; - } - result -} - -fn enum_block<'a>(source: &'a str, enum_name: &str) -> Option<&'a str> { - let marker = format!("enum {enum_name}"); - let mut search_from = 0; - while let Some(relative) = source[search_from..].find(&marker) { - let start = search_from + relative; - let after_name = start + marker.len(); - if source[after_name..] - .chars() - .next() - .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_') - { - search_from = after_name; - continue; - } - let open = source[after_name..].find('{')? + after_name; - let mut depth = 0; - for (offset, character) in source[open..].char_indices() { - match character { - '{' => depth += 1, - '}' => { - depth -= 1; - if depth == 0 { - return Some(&source[open + 1..open + offset]); - } - } - _ => {} - } - } - return None; - } - None -} - -fn enum_variants(block: &str) -> Vec { - block - .lines() - .filter_map(|line| { - let line = line.trim(); - if line.is_empty() || line.starts_with('#') || line.starts_with("//") { - return None; - } - let name = line - .split(|character: char| { - character == '(' - || character == '{' - || character == ',' - || character.is_whitespace() - }) - .next()?; - if name.chars().next()?.is_ascii_uppercase() - && name - .chars() - .all(|character| character.is_ascii_alphanumeric() || character == '_') - { - Some(name.to_string()) - } else { - None - } - }) - .collect() -} - -fn reachable_cli_pages(cli_dir: &Path) -> BTreeSet { - let cli_dir = cli_dir.canonicalize().unwrap(); - let mut seen = BTreeSet::new(); - let mut pending = VecDeque::from([cli_dir.join("README.md")]); - while let Some(path) = pending.pop_front() { - let Ok(relative) = path.strip_prefix(&cli_dir) else { - continue; - }; - let relative = relative.to_string_lossy().into_owned(); - if !seen.insert(relative) { - continue; - } - let body = read(&path); - let mut remaining = body.as_str(); - while let Some(start) = remaining.find("](") { - remaining = &remaining[start + 2..]; - let Some(end) = remaining.find(')') else { - break; - }; - let target = remaining[..end].split('#').next().unwrap_or_default(); - remaining = &remaining[end + 1..]; - if target.is_empty() || target.starts_with("http") { - continue; - } - let target_path = path.parent().unwrap().join(target); - if target_path.extension().and_then(|ext| ext.to_str()) != Some("md") { - continue; - } - let Ok(target_path) = target_path.canonicalize() else { - continue; - }; - if target_path.starts_with(&cli_dir) { - pending.push_back(target_path); - } - } - } - seen -} - -fn kebab_case(value: &str) -> String { - let mut result = String::new(); - for (index, character) in value.chars().enumerate() { - if character.is_uppercase() && index != 0 { - result.push('-'); - } - result.extend(character.to_lowercase()); - } - result -} - #[test] fn node_runtime_exports_have_api_docs() { let root = repo_root(); diff --git a/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs b/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs new file mode 100644 index 000000000..cb41e1654 --- /dev/null +++ b/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs @@ -0,0 +1,165 @@ +use std::collections::{BTreeSet, VecDeque}; +use std::path::{Path, PathBuf}; + +pub(super) fn rust_sources(dir: &Path) -> Vec { + let mut paths = Vec::new(); + for entry in std::fs::read_dir(dir).unwrap() { + let path = entry.unwrap().path(); + if path.is_dir() { + paths.extend(rust_sources(&path)); + } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { + paths.push(path); + } + } + paths.sort(); + paths +} + +pub(super) fn subcommand_enums(source: &str) -> Vec<(String, String)> { + let mut result = Vec::new(); + let mut search_from = 0; + while let Some(relative) = source[search_from..].find("#[command(subcommand)]") { + let attribute_start = search_from + relative; + let after_attribute = attribute_start + "#[command(subcommand)]".len(); + let field_end = source[after_attribute..] + .find('}') + .map(|offset| after_attribute + offset) + .unwrap_or(source.len()); + let field = &source[after_attribute..field_end]; + let Some(command_field) = field.find("command:") else { + search_from = after_attribute; + continue; + }; + let enum_name = field[command_field + "command:".len()..] + .split([',', ';', '}']) + .next() + .unwrap() + .trim() + .to_string(); + let parent = source[..attribute_start] + .rfind("struct ") + .and_then(|offset| { + source[offset + "struct ".len()..] + .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') + .next() + }) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| panic!("clap subcommand attribute has no containing struct")) + .to_string(); + result.push((parent, enum_name)); + search_from = after_attribute; + } + result +} + +pub(super) fn enum_block<'a>(source: &'a str, enum_name: &str) -> Option<&'a str> { + let marker = format!("enum {enum_name}"); + let mut search_from = 0; + while let Some(relative) = source[search_from..].find(&marker) { + let start = search_from + relative; + let after_name = start + marker.len(); + if source[after_name..] + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_') + { + search_from = after_name; + continue; + } + let open = source[after_name..].find('{')? + after_name; + let mut depth = 0; + for (offset, character) in source[open..].char_indices() { + match character { + '{' => depth += 1, + '}' => { + depth -= 1; + if depth == 0 { + return Some(&source[open + 1..open + offset]); + } + } + _ => {} + } + } + return None; + } + None +} + +pub(super) fn enum_variants(block: &str) -> Vec { + block + .lines() + .filter_map(|line| { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with("//") { + return None; + } + let name = line + .split(|character: char| { + character == '(' + || character == '{' + || character == ',' + || character.is_whitespace() + }) + .next()?; + if name.chars().next()?.is_ascii_uppercase() + && name + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '_') + { + Some(name.to_string()) + } else { + None + } + }) + .collect() +} + +pub(super) fn reachable_cli_pages(cli_dir: &Path) -> BTreeSet { + let cli_dir = cli_dir.canonicalize().unwrap(); + let mut seen = BTreeSet::new(); + let mut pending = VecDeque::from([cli_dir.join("README.md")]); + while let Some(path) = pending.pop_front() { + let Ok(relative) = path.strip_prefix(&cli_dir) else { + continue; + }; + let relative = relative.to_string_lossy().into_owned(); + if !seen.insert(relative) { + continue; + } + let body = super::read(&path); + let mut remaining = body.as_str(); + while let Some(start) = remaining.find("](") { + remaining = &remaining[start + 2..]; + let Some(end) = remaining.find(')') else { + break; + }; + let target = remaining[..end].split('#').next().unwrap_or_default(); + remaining = &remaining[end + 1..]; + if target.is_empty() || target.starts_with("http") { + continue; + } + let target_path = path.parent().unwrap().join(target); + if target_path.extension().and_then(|ext| ext.to_str()) != Some("md") { + continue; + } + let Ok(target_path) = target_path.canonicalize() else { + continue; + }; + if target_path.starts_with(&cli_dir) { + pending.push_back(target_path); + } + } + } + seen +} + +pub(super) fn kebab_case(value: &str) -> String { + let mut result = String::new(); + for (index, character) in value.chars().enumerate() { + if character.is_uppercase() && index != 0 { + result.push('-'); + } + result.extend(character.to_lowercase()); + } + result +} From 87301e02e2fd21f146424ea38a26ec542db28e98 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 09:26:20 -0700 Subject: [PATCH 4/7] test: enforce direct docs inventory links --- crates/no-mistakes/tests/docs_coverage.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/no-mistakes/tests/docs_coverage.rs b/crates/no-mistakes/tests/docs_coverage.rs index c655f6804..2b2a11732 100644 --- a/crates/no-mistakes/tests/docs_coverage.rs +++ b/crates/no-mistakes/tests/docs_coverage.rs @@ -134,7 +134,7 @@ fn assert_cli_page( (group_file, false) } } - None => (format!("{variant}.md"), false), + None => (format!("{variant}.md"), true), }; let path = cli_dir.join(&file); assert!(path.exists(), "missing CLI doc {}", path.display()); @@ -184,10 +184,13 @@ fn node_runtime_exports_have_api_docs() { !exports.is_empty(), "runtime export inventory must not be empty" ); + let runtime_inventory = docs + .split_once("| Runtime export | API |\n") + .and_then(|(_, rest)| rest.split_once("\n\n").map(|(table, _)| table)) + .expect("docs/node-api.md must contain a complete runtime export inventory table"); for export in exports { assert!( - docs.lines() - .any(|line| line.starts_with('|') && line.contains(&format!("`{export}`"))), + runtime_inventory.contains(&format!("| `{export}` |")), "docs/node-api.md must map runtime export `{export}`" ); } From 7cc1def28d965c6714938c70096d514c30fbc1d0 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 09:37:07 -0700 Subject: [PATCH 5/7] test: reject stale runtime export docs --- crates/no-mistakes/tests/docs_coverage.rs | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/crates/no-mistakes/tests/docs_coverage.rs b/crates/no-mistakes/tests/docs_coverage.rs index 2b2a11732..475157c16 100644 --- a/crates/no-mistakes/tests/docs_coverage.rs +++ b/crates/no-mistakes/tests/docs_coverage.rs @@ -1,5 +1,6 @@ use no_mistakes::codebase::{rules, unique_exports}; use no_mistakes::playwright::rules as playwright_rules; +use std::collections::BTreeSet; use std::path::{Path, PathBuf}; #[path = "support/docs_coverage_cli_helpers.rs"] @@ -188,7 +189,20 @@ fn node_runtime_exports_have_api_docs() { .split_once("| Runtime export | API |\n") .and_then(|(_, rest)| rest.split_once("\n\n").map(|(table, _)| table)) .expect("docs/node-api.md must contain a complete runtime export inventory table"); - for export in exports { + let source_exports = exports.iter().copied().collect::>(); + let documented_exports = runtime_inventory + .lines() + .filter_map(|line| { + line.strip_prefix("| `")? + .split_once("` |") + .map(|(name, _)| name) + }) + .collect::>(); + assert_eq!( + documented_exports, source_exports, + "runtime export inventory must exactly match packages/no-mistakes/index.js" + ); + for export in source_exports { assert!( runtime_inventory.contains(&format!("| `{export}` |")), "docs/node-api.md must map runtime export `{export}`" From 5d13b3f107305364135a098fba2074febf1e4ba9 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 09:50:02 -0700 Subject: [PATCH 6/7] test: harden docs inventory discovery --- crates/no-mistakes/tests/docs_coverage.rs | 14 +- .../support/docs_coverage_cli_helpers.rs | 151 +++++++++++++----- .../multiline-subcommand/fixture.rs | 14 ++ 3 files changed, 135 insertions(+), 44 deletions(-) create mode 100644 fixtures/docs-coverage/multiline-subcommand/fixture.rs diff --git a/crates/no-mistakes/tests/docs_coverage.rs b/crates/no-mistakes/tests/docs_coverage.rs index 475157c16..2e9ff4d19 100644 --- a/crates/no-mistakes/tests/docs_coverage.rs +++ b/crates/no-mistakes/tests/docs_coverage.rs @@ -190,13 +190,23 @@ fn node_runtime_exports_have_api_docs() { .and_then(|(_, rest)| rest.split_once("\n\n").map(|(table, _)| table)) .expect("docs/node-api.md must contain a complete runtime export inventory table"); let source_exports = exports.iter().copied().collect::>(); - let documented_exports = runtime_inventory + let documented_rows = runtime_inventory .lines() .filter_map(|line| { line.strip_prefix("| `")? .split_once("` |") - .map(|(name, _)| name) + .map(|(name, api)| (name, api.trim().trim_end_matches('|').trim())) }) + .collect::>(); + for (export, api) in &documented_rows { + assert!( + !api.is_empty(), + "runtime export `{export}` needs an API mapping" + ); + } + let documented_exports = documented_rows + .iter() + .map(|(name, _)| *name) .collect::>(); assert_eq!( documented_exports, source_exports, diff --git a/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs b/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs index cb41e1654..a10226b8e 100644 --- a/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs +++ b/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs @@ -1,55 +1,111 @@ use std::collections::{BTreeSet, VecDeque}; use std::path::{Path, PathBuf}; +use std::process::Command; +use syn::{Item, Meta, Type}; pub(super) fn rust_sources(dir: &Path) -> Vec { - let mut paths = Vec::new(); - for entry in std::fs::read_dir(dir).unwrap() { - let path = entry.unwrap().path(); - if path.is_dir() { - paths.extend(rust_sources(&path)); - } else if path.extension().and_then(|ext| ext.to_str()) == Some("rs") { - paths.push(path); - } - } + let repo = git_repo_root(dir); + let output = Command::new("git") + .args([ + "-C", + repo.to_str().expect("repository root must be UTF-8"), + "ls-files", + "--cached", + "--others", + "--exclude-standard", + "--", + "crates/no-mistakes/src", + ]) + .output() + .expect("git ls-files must be available for docs coverage"); + assert!( + output.status.success(), + "git ls-files failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + let mut paths = String::from_utf8(output.stdout) + .expect("git ls-files output must be UTF-8") + .lines() + .filter_map(|relative| { + let path = repo.join(relative); + (path.extension().and_then(|ext| ext.to_str()) == Some("rs") + && std::fs::symlink_metadata(&path) + .map(|metadata| metadata.file_type().is_file()) + .unwrap_or(false)) + .then_some(path) + }) + .collect::>(); paths.sort(); paths } +fn git_repo_root(dir: &Path) -> PathBuf { + let output = Command::new("git") + .args([ + "-C", + dir.to_str().expect("source directory must be UTF-8"), + "rev-parse", + "--show-toplevel", + ]) + .output() + .expect("git rev-parse must be available for docs coverage"); + assert!( + output.status.success(), + "git rev-parse failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + PathBuf::from( + String::from_utf8(output.stdout) + .expect("git repository root must be UTF-8") + .trim(), + ) +} + pub(super) fn subcommand_enums(source: &str) -> Vec<(String, String)> { - let mut result = Vec::new(); - let mut search_from = 0; - while let Some(relative) = source[search_from..].find("#[command(subcommand)]") { - let attribute_start = search_from + relative; - let after_attribute = attribute_start + "#[command(subcommand)]".len(); - let field_end = source[after_attribute..] - .find('}') - .map(|offset| after_attribute + offset) - .unwrap_or(source.len()); - let field = &source[after_attribute..field_end]; - let Some(command_field) = field.find("command:") else { - search_from = after_attribute; - continue; - }; - let enum_name = field[command_field + "command:".len()..] - .split([',', ';', '}']) - .next() - .unwrap() - .trim() - .to_string(); - let parent = source[..attribute_start] - .rfind("struct ") - .and_then(|offset| { - source[offset + "struct ".len()..] - .split(|character: char| !character.is_ascii_alphanumeric() && character != '_') - .next() + syn::parse_file(source) + .expect("Rust sources must parse before extracting clap commands") + .items + .into_iter() + .filter_map(|item| { + let Item::Struct(item) = item else { + return None; + }; + let parent = item.ident.to_string(); + let fields = match item.fields { + syn::Fields::Named(fields) => fields.named, + _ => return None, + }; + fields.into_iter().find_map(|field| { + if !field.attrs.iter().any(is_subcommand_attribute) { + return None; + } + let Type::Path(path) = field.ty else { + panic!("clap subcommand field must have a named type"); + }; + Some(( + parent.clone(), + path.path + .segments + .last() + .expect("clap subcommand type must have a path") + .ident + .to_string(), + )) }) - .filter(|name| !name.is_empty()) - .unwrap_or_else(|| panic!("clap subcommand attribute has no containing struct")) - .to_string(); - result.push((parent, enum_name)); - search_from = after_attribute; - } - result + }) + .collect() +} + +fn is_subcommand_attribute(attribute: &syn::Attribute) -> bool { + let Meta::List(list) = &attribute.meta else { + return false; + }; + attribute.path().is_ident("command") + && list + .tokens + .to_string() + .split(',') + .any(|part| part.trim() == "subcommand") } pub(super) fn enum_block<'a>(source: &'a str, enum_name: &str) -> Option<&'a str> { @@ -163,3 +219,14 @@ pub(super) fn kebab_case(value: &str) -> String { } result } + +#[test] +fn parses_multiline_subcommand_fixture() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/docs-coverage/multiline-subcommand/fixture.rs"); + let source = std::fs::read_to_string(&fixture).unwrap(); + assert_eq!( + subcommand_enums(&source), + vec![("FixtureArgs".to_string(), "FixtureCommand".to_string())] + ); +} diff --git a/fixtures/docs-coverage/multiline-subcommand/fixture.rs b/fixtures/docs-coverage/multiline-subcommand/fixture.rs new file mode 100644 index 000000000..a56d8d612 --- /dev/null +++ b/fixtures/docs-coverage/multiline-subcommand/fixture.rs @@ -0,0 +1,14 @@ +use clap::{Args, Subcommand}; + +#[derive(Args)] +struct FixtureArgs { + #[command( + subcommand + )] + command: FixtureCommand, +} + +#[derive(Subcommand)] +enum FixtureCommand { + Check, +} From 9f3d08b424aad1f02c2568c3b869f448ee9890f3 Mon Sep 17 00:00:00 2001 From: Jonathan Ong Date: Mon, 10 Aug 2026 09:59:18 -0700 Subject: [PATCH 7/7] test: cover nested optional clap commands --- .../support/docs_coverage_cli_helpers.rs | 95 +++++++++++++------ .../inline-optional-subcommand/fixture.rs | 15 +++ .../multiline-subcommand/fixture.rs | 1 + 3 files changed, 80 insertions(+), 31 deletions(-) create mode 100644 fixtures/docs-coverage/inline-optional-subcommand/fixture.rs diff --git a/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs b/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs index a10226b8e..8b4db4145 100644 --- a/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs +++ b/crates/no-mistakes/tests/support/docs_coverage_cli_helpers.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeSet, VecDeque}; use std::path::{Path, PathBuf}; use std::process::Command; -use syn::{Item, Meta, Type}; +use syn::{GenericArgument, Item, Meta, PathArguments, Type}; pub(super) fn rust_sources(dir: &Path) -> Vec { let repo = git_repo_root(dir); @@ -62,38 +62,60 @@ fn git_repo_root(dir: &Path) -> PathBuf { } pub(super) fn subcommand_enums(source: &str) -> Vec<(String, String)> { - syn::parse_file(source) - .expect("Rust sources must parse before extracting clap commands") - .items - .into_iter() - .filter_map(|item| { - let Item::Struct(item) = item else { + let file = + syn::parse_file(source).expect("Rust sources must parse before extracting clap commands"); + let mut result = Vec::new(); + collect_subcommand_enums(&file.items, &mut result); + result +} + +fn collect_subcommand_enums(items: &[Item], result: &mut Vec<(String, String)>) { + for item in items { + match item { + Item::Struct(item) => { + let parent = item.ident.to_string(); + let syn::Fields::Named(fields) = &item.fields else { + continue; + }; + if let Some(field) = fields + .named + .iter() + .find(|field| field.attrs.iter().any(is_subcommand_attribute)) + { + result.push(( + parent, + subcommand_type_name(&field.ty) + .expect("clap subcommand field must have a named type"), + )); + } + } + Item::Mod(item) => { + if let Some((_, nested_items)) = &item.content { + collect_subcommand_enums(nested_items, result); + } + } + _ => {} + } + } +} + +fn subcommand_type_name(ty: &Type) -> Option { + let Type::Path(path) = ty else { + return None; + }; + let segment = path.path.segments.last()?; + if segment.ident == "Option" { + let PathArguments::AngleBracketed(arguments) = &segment.arguments else { + return None; + }; + return arguments.args.iter().find_map(|argument| { + let GenericArgument::Type(inner) = argument else { return None; }; - let parent = item.ident.to_string(); - let fields = match item.fields { - syn::Fields::Named(fields) => fields.named, - _ => return None, - }; - fields.into_iter().find_map(|field| { - if !field.attrs.iter().any(is_subcommand_attribute) { - return None; - } - let Type::Path(path) = field.ty else { - panic!("clap subcommand field must have a named type"); - }; - Some(( - parent.clone(), - path.path - .segments - .last() - .expect("clap subcommand type must have a path") - .ident - .to_string(), - )) - }) - }) - .collect() + subcommand_type_name(inner) + }); + } + Some(segment.ident.to_string()) } fn is_subcommand_attribute(attribute: &syn::Attribute) -> bool { @@ -230,3 +252,14 @@ fn parses_multiline_subcommand_fixture() { vec![("FixtureArgs".to_string(), "FixtureCommand".to_string())] ); } + +#[test] +fn parses_inline_optional_subcommand_fixture() { + let fixture = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../fixtures/docs-coverage/inline-optional-subcommand/fixture.rs"); + let source = std::fs::read_to_string(&fixture).unwrap(); + assert_eq!( + subcommand_enums(&source), + vec![("OptionalArgs".to_string(), "OptionalCommand".to_string())] + ); +} diff --git a/fixtures/docs-coverage/inline-optional-subcommand/fixture.rs b/fixtures/docs-coverage/inline-optional-subcommand/fixture.rs new file mode 100644 index 000000000..9189f980d --- /dev/null +++ b/fixtures/docs-coverage/inline-optional-subcommand/fixture.rs @@ -0,0 +1,15 @@ +// Syntax-parser fixture: the nested module and imports intentionally need not compile. +use clap::{Args, Subcommand}; + +mod nested { + #[derive(Args)] + struct OptionalArgs { + #[command(subcommand)] + command: Option, + } + + #[derive(Subcommand)] + enum OptionalCommand { + Check, + } +} diff --git a/fixtures/docs-coverage/multiline-subcommand/fixture.rs b/fixtures/docs-coverage/multiline-subcommand/fixture.rs index a56d8d612..fa7a5cb01 100644 --- a/fixtures/docs-coverage/multiline-subcommand/fixture.rs +++ b/fixtures/docs-coverage/multiline-subcommand/fixture.rs @@ -1,3 +1,4 @@ +// Syntax-parser fixture: this source shape intentionally need not compile. use clap::{Args, Subcommand}; #[derive(Args)]