From 9f6128b7e9f1c98e43f8a3503965b39c695f9a9f Mon Sep 17 00:00:00 2001 From: hongyi-chen Date: Thu, 11 Jun 2026 05:47:30 +0000 Subject: [PATCH 1/4] Overhaul missing_docs skill: fix audit blind spots, add change detection - audit_docs.py: fail loud (exit 2 + audits_skipped) when repos are missing; fix repo auto-detection to siblings of the docs repo root - GA detection via the app/src/features.rs cargo-feature bridge plus RELEASE_FLAGS/PREVIEW_FLAGS/DOGFOOD_FLAGS instead of snake_case guessing - New audits: public API routes (router/handlers/public_api gin groups vs OpenAPI spec), CLI subcommands (clap enum tree, hidden skipped), slash commands (static registry), surface-map hygiene (dead entries) - Staleness: strip code spans, word-boundary matching, skip historical changelog pages (73 -> 29 findings; 'oz agent' CLI noise eliminated) - Change detection: references/surface_snapshot.json + --diff mode reporting added/removed/promoted surfaces and changelog items since last run; --update-snapshot regenerates the baseline - Seed feature_surface_map.md: map handoff/orchestration/queueing/BYOK/ billing/etc. flags, prune 45+ dead entries, add slash-command section and API internal sentinels - SKILL.md: document the exit-code contract, diff workflow, drift-watch mode for the recurring agent (with copyable scheduled-agent prompt), and make surface-map + snapshot updates explicit drafting steps Co-Authored-By: Oz --- .agents/skills/missing_docs/SKILL.md | 170 +- .../references/feature_surface_map.md | 180 ++- .../references/surface_snapshot.json | 670 ++++++++ .../skills/missing_docs/scripts/audit_docs.py | 1416 +++++++++++++---- 4 files changed, 2057 insertions(+), 379 deletions(-) create mode 100644 .agents/skills/missing_docs/references/surface_snapshot.json diff --git a/.agents/skills/missing_docs/SKILL.md b/.agents/skills/missing_docs/SKILL.md index 5d38bf5a..fb6246aa 100644 --- a/.agents/skills/missing_docs/SKILL.md +++ b/.agents/skills/missing_docs/SKILL.md @@ -4,19 +4,33 @@ description: >- Find and fill documentation gaps in Warp's Astro Starlight docs by auditing coverage against code surfaces in warp-internal and warp-server, then drafting missing pages. Use when asked to find missing docs, audit documentation coverage, - identify undocumented features, draft docs for new features, or do a docs - coverage check. Runs a Python audit script to identify gaps, then researches + identify undocumented features, draft docs for new features, detect doc-impacting + code changes since the last audit, or do a docs coverage check. Runs a Python + audit script (coverage + snapshot-based change detection), then researches source code and writes first-pass doc pages. Can run audit-only, draft-only, - or end-to-end. + drift-watch (recurring agent), or end-to-end. --- # Missing Docs -Find documentation gaps and draft missing pages in one workflow. +Find documentation gaps, detect doc-impacting code changes, and draft missing pages. -## Two-phase workflow +## Requirements -### Phase 1: Audit +The audit compares docs against code, so both source repos must be available: +- `warp-internal` and `warp-server`, auto-detected as siblings of the docs repo + root (e.g. `/workspace/docs` next to `/workspace/warp-internal` and + `/workspace/warp-server`), or passed explicitly via `--warp-internal PATH` / + `--warp-server PATH`. + +The script FAILS LOUD when a repo is missing: it exits with code 2 and lists the +skipped audits in the report's `audits_skipped` field. Never treat an exit-2 run +as a clean audit — fix the repo paths and re-run. Exit 0 means all requested +audits ran (findings may still exist). + +## Workflows + +### Phase 1: Audit (coverage) Run the audit script to identify gaps: @@ -25,36 +39,87 @@ python3 .agents/skills/missing_docs/scripts/audit_docs.py ``` Options: -- `--category features|cli|api|staleness` — run a single audit category +- `--category features|cli|api|slash|staleness|map` — run a single audit category - `--severity high|medium|low` — filter by minimum severity - `--weak-coverage` — also flag GA features whose mapped doc exists but doesn't mention feature keywords (low-severity, noisy) - `--output report.json` — save JSON report to file -- `--warp-internal PATH` — explicit path to warp-internal (auto-detected from sibling dirs) -- `--warp-server PATH` — explicit path to warp-server (auto-detected from sibling dirs) +- `--warp-internal PATH` / `--warp-server PATH` — explicit repo paths +- `--diff` — change detection against the committed snapshot (see Phase 2) +- `--update-snapshot` — regenerate `references/surface_snapshot.json` (full runs only) The script resolves doc paths from the docs repo root and accepts `.md` and `.mdx` interchangeably (and `README.md` ↔ `index.mdx`), so surface-map entries can use the canonical filename even when the on-disk extension differs. -The script performs 4 audits: -1. **Feature flag coverage** — compares GA flags in `crates/warp_features/src/lib.rs` + `app/Cargo.toml` against the surface map; default mode trusts a mapped entry as verified, `--weak-coverage` additionally checks the target page mentions feature keywords -2. **CLI command coverage** — compares `warp_cli/src/lib.rs` subcommands against `src/content/docs/reference/cli/` -3. **API endpoint coverage** — compares `router/router.go` routes against `src/content/docs/reference/api-and-sdk/` and the OpenAPI spec -4. **Docs staleness** — checks for outdated terminology in existing docs +The script performs 6 coverage audits: +1. **Feature flag coverage** — classifies every `FeatureFlag` by rollout status using + the cargo-feature→flag bridge in warp-internal `app/src/features.rs` plus + `RELEASE_FLAGS`/`PREVIEW_FLAGS`/`DOGFOOD_FLAGS` in `crates/warp_features/src/lib.rs`. + GA flags must be mapped in the surface map or covered in docs; Preview flags produce + low-severity "docs needed soon" findings; dogfood/other flags are tracked by the + snapshot only. +2. **CLI command coverage** — parses the full `oz` command tree (top-level commands and + subcommands, skipping `hide = true`) from `crates/warp_cli/src/` and checks the CLI + reference docs. +3. **API endpoint coverage** — extracts public routes from warp-server + `router/handlers/public_api/*.go` (nested gin groups resolved) and checks them + against `developers/agent-api-openapi.yaml` and the API reference docs. For spec + drift, run the docs `sync-openapi-spec` skill (or warp-server's + `update-open-api-spec`) instead of hand-editing the YAML. +4. **Slash command coverage** — parses the static registry in warp-internal + `app/src/search/slash_command_menu/static_commands/` and checks each `/command` + is mentioned in docs. +5. **Docs staleness** — flags renamed/removed-feature terminology in prose (code + spans stripped; historical changelog pages excluded). Broader terminology and + style enforcement is owned by the `style_lint` skill — delegate pure wording + issues there. +6. **Surface map hygiene** — flags map entries whose flag/command no longer exists in + code, and mapped doc targets that no longer exist. Verify the doc page is still + accurate, then prune or update the entry. Present the report to the user, grouped by category and sorted by severity. -### Phase 2: Draft +### Phase 2: Change detection (diff mode) + +The snapshot at `references/surface_snapshot.json` records all extracted surfaces +(flags + rollout status, CLI commands, API routes, slash commands) plus the last-seen +docs-changelog version. It makes change detection possible: a feature flag that is +deleted after stabilizing (per warp-internal's remove-feature-flag policy) would +otherwise vanish from the audit's universe silently. + +```bash +python3 .agents/skills/missing_docs/scripts/audit_docs.py --diff +``` + +Diff mode reports, since the snapshot was last updated: +- **Added / removed / promoted surfaces** — e.g. a new GA flag (high), a flag promoted + dogfood→ga (high), a removed flag ("feature stabilized or killed — verify docs and + map entry"), new CLI/API/slash surfaces. +- **Changelog items to verify** — "New features" and "Improvements" bullets from + `src/content/docs/changelog/.mdx` entries newer than the snapshot's last-seen + version. This is the best signal for launches no static code parse can see + (server-side features, Oz web app, experiment rollouts). A changelog mention is NOT + documentation — verify each item has real doc coverage. + +After triaging and addressing diff findings, refresh the snapshot and commit it with +your PR so the next run diffs against the new baseline: -For each gap the user wants to address (prioritize high → medium → low): +```bash +python3 .agents/skills/missing_docs/scripts/audit_docs.py --update-snapshot +``` + +### Phase 3: Draft + +For each gap to address (prioritize high → medium → low): 1. Read `references/feature_surface_map.md` to determine the target doc section 2. Read `AGENTS.md` in the docs repo root for the complete style guide 3. Read 2-3 strong examples in the target section to match formatting patterns 4. Research the relevant source code: - **Feature gaps** → read the implementation in warp-internal `app/src/`, check UI code, settings, user-facing strings - - **CLI gaps** → read command definition in `warp_cli/src/`, extract flags, arguments, help text - - **API gaps** → read handler in warp-server `router/handlers/`, route definition, request/response types + - **CLI gaps** → read command definition in `crates/warp_cli/src/`, extract flags, arguments, help text + - **API gaps** → read handler in warp-server `router/handlers/public_api/`, route definition, request/response types; prefer fixing the OpenAPI spec via the `sync-openapi-spec` skill + - **Slash command gaps** → read the registry entry and gating flags in `app/src/search/slash_command_menu/` 5. Draft the doc following style guide conventions: - YAML frontmatter with description - **All headings (H1–H4) must use sentence case** — capitalize only the first word and proper feature names (e.g., "Agent Mode", "Warp Drive"). ✅ `## How it works` ❌ `## How It Works` @@ -63,16 +128,63 @@ For each gap the user wants to address (prioritize high → medium → low): - Correct terminology (Agent, Agent Mode, Warp Drive, Oz, etc.) - Bold + dash format for list items: `* **Term** - Description` 6. Create the markdown file at the suggested path -7. Remind user to add new pages to the relevant `astro.config.mjs (sidebar config)` - -To find warp-internal and warp-server, search for sibling directories of the docs repo. If not found, ask the user. +7. Add new pages to the sidebar config in `astro.config.mjs` +8. **Update `references/feature_surface_map.md` in the same PR**: add a + `Flag -> src/content/docs/...` mapping for every feature you documented (or add the + flag to the ignore list with a comment if you confirmed it is internal-only). This + step is NOT optional — unmapped features become repeat findings, and an unmaintained + map is how gaps get lost. +9. Run `--update-snapshot` and commit the refreshed snapshot with the same PR. + +### Drift-watch mode (recurring scheduled agent) + +This is the end-to-end workflow for the scheduled cloud agent that keeps docs in sync +with the product. Each run: + +1. **Audit**: run both modes and save reports. Pass explicit repo paths; verify + exit code 0 — if the script exits 2, STOP and report the environment problem + instead of concluding "no gaps": + ```bash + python3 .agents/skills/missing_docs/scripts/audit_docs.py \ + --warp-internal ../warp-internal --warp-server ../warp-server \ + --diff --output /tmp/docs_audit.json + ``` +2. **Triage**: work through `surface_changes` and `changelog_review` first (what + changed since last run), then standing coverage findings (high → medium → low). + For each item decide: draft/update a doc page, update the OpenAPI spec via + `sync-openapi-spec`, add a surface-map entry (documented elsewhere), or add an + ignore/`internal` entry with a comment (internal-only). +3. **Draft**: follow Phase 3 for every item that needs docs. +4. **Update references**: apply surface-map edits, then regenerate the snapshot: + ```bash + python3 .agents/skills/missing_docs/scripts/audit_docs.py --update-snapshot + ``` +5. **Validate**: `npm run build` if doc pages changed; re-run the audit and confirm + the addressed findings are gone. +6. **Open a PR** with the doc pages + map + snapshot changes together, using the + `create_pr` skill. Summarize remaining (deferred) findings in the PR body so + nothing is silently dropped. + +Recommended scheduled-agent prompt (copy when setting up the agent): + +> Run the missing_docs skill in drift-watch mode. Use the audit script with explicit +> --warp-internal and --warp-server paths and --diff. If the script exits non-zero with +> skipped audits, report the environment problem and stop. Otherwise triage all +> surface_changes and changelog_review findings plus high/medium coverage findings: +> draft or update doc pages, update the surface map (mapping or ignore entry with a +> comment) for every triaged flag, and use the sync-openapi-spec skill for API spec +> gaps. Regenerate the surface snapshot with --update-snapshot. Open a single PR with +> the doc pages, feature_surface_map.md, and surface_snapshot.json changes, and list +> any findings you deferred in the PR body. ### Invocation modes -The user can trigger either phase or both: +The user can trigger any subset: - **"Run a docs audit"** or **"Check docs coverage"** → Phase 1 only -- **"Draft docs for [specific gap]"** → Phase 2 only (skip audit) -- **"Find and fix missing docs"** → Both phases end-to-end +- **"What changed since the last audit?"** → Phase 1 + 2 (`--diff`) +- **"Draft docs for [specific gap]"** → Phase 3 only (skip audit) +- **"Find and fix missing docs"** → Phases 1–3 end-to-end +- **Scheduled/recurring run** → Drift-watch mode ### Drafting standards @@ -84,6 +196,12 @@ The user can trigger either phase or both: ## References -- `references/feature_surface_map.md` — curated mapping of flags/commands to doc pages, also lists internal-only flags to ignore -- `references/stale_terms.md` — deprecated/outdated terms to flag during staleness audits, sourced from AGENTS.md terminology standards +- `references/feature_surface_map.md` — curated mapping of flags/commands/routes/slash + commands to doc pages, ignore list for internal flags, and the `internal` sentinel + for surfaces that intentionally have no public docs. Update it with every docs PR + that ships a feature. +- `references/surface_snapshot.json` — generated snapshot of all code surfaces used by + `--diff`. Regenerate with `--update-snapshot`; never hand-edit. +- `references/stale_terms.md` — renamed/removed-feature terms to flag during staleness + audits. Pure terminology/style policing belongs to the `style_lint` skill. - `AGENTS.md` (docs repo root) — full documentation style guide diff --git a/.agents/skills/missing_docs/references/feature_surface_map.md b/.agents/skills/missing_docs/references/feature_surface_map.md index 8d149d76..e0aa3ba1 100644 --- a/.agents/skills/missing_docs/references/feature_surface_map.md +++ b/.agents/skills/missing_docs/references/feature_surface_map.md @@ -1,14 +1,23 @@ # Feature Surface Map -Curated mapping of feature flags, CLI commands, and code modules to their expected documentation pages. -The audit script reads this file to reduce false positives — entries here are verified rather than flagged. +Curated mapping of feature flags, CLI commands, API endpoints, and slash commands +to their expected documentation pages. +The audit script reads this file to reduce false positives — entries here are +verified rather than flagged. -Format: `CodeIdentifier -> docs/path/to/page.md` (one per line within each section). +Format: `CodeIdentifier -> src/content/docs/path/to/page.md` (one per line within each section). Lines starting with `#` are comments. Blank lines are ignored. +The sentinel target `internal` marks surfaces that intentionally have no public docs. -# Maintenance: when a new GA feature flag ships, add a mapping here. -# Run `python3 .agents/skills/missing_docs/scripts/audit_docs.py` to find unmapped flags. -# This audit is also run as a recurring scheduled cloud agent to catch drift. +# Maintenance policy: +# - When a feature ships (GA or Preview), add a mapping here in the same PR that +# adds/updates its doc page. +# - When a flag/command/route is removed from code, the audit's map-hygiene check +# flags the dead entry — verify the doc page is still accurate, then prune it. +# - Run `python3 .agents/skills/missing_docs/scripts/audit_docs.py` to find unmapped +# surfaces, and `--update-snapshot` to refresh references/surface_snapshot.json. +# - This audit also runs as a recurring scheduled cloud agent to catch drift +# (see the drift-watch workflow in SKILL.md). ## Feature flags -> doc pages @@ -20,7 +29,6 @@ AgentModeWorkflows -> src/content/docs/knowledge-and-collaboration/warp-drive/wo AgentOnboarding -> src/content/docs/agent-platform/getting-started/agents-in-warp.md AIRules -> src/content/docs/agent-platform/capabilities/rules.mdx AIResumeButton -> src/content/docs/agent-platform/local-agents/interacting-with-agents/terminal-and-agent-modes.mdx -CodeReviewView -> src/content/docs/code/code-review.md InlineCodeReview -> src/content/docs/agent-platform/local-agents/interactive-code-review.mdx FileTree -> src/content/docs/code/code-editor/file-tree.md CodeFindReplace -> src/content/docs/code/code-editor/find-and-replace.md @@ -32,57 +40,33 @@ SelectionAsContext -> src/content/docs/agent-platform/local-agents/agent-context DiffSetAsContext -> src/content/docs/agent-platform/local-agents/agent-context/selection-as-context.mdx WebSearchUI -> src/content/docs/agent-platform/capabilities/web-search.mdx WebFetchUI -> src/content/docs/agent-platform/capabilities/web-search.mdx -CodebaseContext -> src/content/docs/agent-platform/capabilities/codebase-context.mdx CrossRepoContext -> src/content/docs/agent-platform/capabilities/codebase-context.mdx FullSourceCodeEmbedding -> src/content/docs/agent-platform/capabilities/codebase-context.mdx SearchCodebaseUI -> src/content/docs/agent-platform/capabilities/codebase-context.mdx +RemoteCodebaseIndexing -> src/content/docs/agent-platform/capabilities/codebase-context.mdx CloudEnvironments -> src/content/docs/agent-platform/cloud-agents/environments.md CloudMode -> src/content/docs/agent-platform/cloud-agents/overview.md AmbientAgentsCommandLine -> src/content/docs/agent-platform/cloud-agents/overview.md ScheduledAmbientAgents -> src/content/docs/agent-platform/cloud-agents/triggers/scheduled-agents.md WarpManagedSecrets -> src/content/docs/agent-platform/cloud-agents/secrets.md IntegrationCommand -> src/content/docs/reference/cli/integration-setup.md -ConversationManagement -> src/content/docs/agent-platform/local-agents/cloud-conversations.mdx -ForkConversationFromBlock -> src/content/docs/agent-platform/local-agents/interacting-with-agents/conversation-forking.mdx -Voice -> src/content/docs/agent-platform/local-agents/interacting-with-agents/voice.mdx -WarpDrive -> src/content/docs/knowledge-and-collaboration/warp-drive/index.mdx -EnvVars -> src/content/docs/knowledge-and-collaboration/warp-drive/environment-variables.md CommandPaletteFileSearch -> src/content/docs/terminal/command-palette.md -Themes -> src/content/docs/terminal/appearance/themes.md Ligatures -> src/content/docs/terminal/appearance/text-fonts-cursor.md UIZoom -> src/content/docs/terminal/appearance/size-opacity-blurring.md -SSH -> src/content/docs/terminal/warpify/ssh.md -SplitPanes -> src/content/docs/terminal/windows/split-panes.md -Tabs -> src/content/docs/terminal/windows/tabs.md -GlobalHotkey -> src/content/docs/terminal/windows/global-hotkey.md -LaunchConfigurations -> src/content/docs/terminal/sessions/launch-configurations.md -SessionRestoration -> src/content/docs/terminal/sessions/session-restoration.md -BlockBasics -> src/content/docs/terminal/blocks/block-basics.md -Autosuggestions -> src/content/docs/terminal/command-completions/autosuggestions.md -Completions -> src/content/docs/terminal/command-completions/completions.md -CommandHistory -> src/content/docs/terminal/entry/command-history.md -CommandCorrections -> src/content/docs/terminal/entry/command-corrections.md UsageBasedPricing -> src/content/docs/support-and-community/plans-and-billing/credits.md APIKeyAuthentication -> src/content/docs/reference/cli/api-keys.md APIKeyManagement -> src/content/docs/reference/cli/api-keys.md -SecretRedaction -> src/content/docs/support-and-community/privacy-and-security/secret-redaction.md CreatingSharedSessions -> src/content/docs/knowledge-and-collaboration/session-sharing/index.mdx AgentSharedSessions -> src/content/docs/agent-platform/local-agents/session-sharing.mdx ProfilesDesignRevamp -> src/content/docs/agent-platform/capabilities/agent-profiles-permissions.mdx MultiProfile -> src/content/docs/agent-platform/capabilities/agent-profiles-permissions.mdx InlineProfileSelector -> src/content/docs/agent-platform/capabilities/agent-profiles-permissions.mdx -ModelChoice -> src/content/docs/agent-platform/capabilities/model-choice.mdx -Skills -> src/content/docs/agent-platform/capabilities/skills.mdx ListSkills -> src/content/docs/agent-platform/capabilities/skills.mdx BundledSkills -> src/content/docs/agent-platform/capabilities/skills.mdx -Planning -> src/content/docs/agent-platform/capabilities/planning.mdx SyncAmbientPlans -> src/content/docs/agent-platform/capabilities/planning.mdx -TaskLists -> src/content/docs/agent-platform/capabilities/task-lists.mdx -SlashCommands -> src/content/docs/agent-platform/capabilities/slash-commands.mdx SuggestedRules -> src/content/docs/agent-platform/capabilities/rules.mdx RectSelection -> src/content/docs/terminal/more-features/text-selection.md ContextWindowUsageV2 -> src/content/docs/agent-platform/local-agents/interacting-with-agents/index.mdx -ConfigurableBlockLimits -> src/content/docs/terminal/blocks/block-basics.md CommandCorrectionKey -> src/content/docs/terminal/entry/command-corrections.md ClassicCompletions -> src/content/docs/terminal/command-completions/completions.md DynamicWorkflowEnums -> src/content/docs/knowledge-and-collaboration/warp-drive/workflows.md @@ -104,11 +88,13 @@ RevertToCheckpoints -> src/content/docs/agent-platform/capabilities/slash-comman RewindSlashCommand -> src/content/docs/agent-platform/capabilities/slash-commands.mdx ForkFromCommand -> src/content/docs/agent-platform/capabilities/slash-commands.mdx SummarizationConversationCommand -> src/content/docs/agent-platform/capabilities/slash-commands.mdx +CreateEnvironmentSlashCommand -> src/content/docs/agent-platform/capabilities/slash-commands.mdx CodeReviewFind -> src/content/docs/code/code-review.md CodeReviewSaveChanges -> src/content/docs/code/code-review.md DiscardPerFileAndAllChanges -> src/content/docs/code/code-review.md AutoOpenCodeReviewPane -> src/content/docs/code/code-review.md GitOperationsInCodeReview -> src/content/docs/code/code-review.md +RemoteCodeReview -> src/content/docs/code/code-review.md AgentView -> src/content/docs/agent-platform/local-agents/interacting-with-agents/terminal-and-agent-modes.mdx AgentViewBlockContext -> src/content/docs/agent-platform/local-agents/agent-context/blocks-as-context.mdx CloudConversations -> src/content/docs/agent-platform/local-agents/cloud-conversations.mdx @@ -134,6 +120,32 @@ KittyKeyboardProtocol -> src/content/docs/terminal/more-features/full-screen-app InlineRepoMenu -> src/content/docs/agent-platform/capabilities/codebase-context.mdx InlineHistoryMenu -> src/content/docs/agent-platform/local-agents/interacting-with-agents/terminal-and-agent-modes.mdx SkillArguments -> src/content/docs/agent-platform/capabilities/skills.mdx +ConfigurableToolbar -> src/content/docs/terminal/windows/configurable-toolbar.mdx +SettingsFile -> src/content/docs/terminal/settings/index.mdx +Changelog -> src/content/docs/changelog/index.mdx +Autoupdate -> src/content/docs/support-and-community/troubleshooting-and-support/updating-warp.mdx + +# Handoff (local <-> cloud, cloud <-> cloud) and snapshots +OzHandoff -> src/content/docs/agent-platform/cloud-agents/handoff/index.mdx +HandoffLocalCloud -> src/content/docs/agent-platform/cloud-agents/handoff/local-to-cloud.mdx +HandoffCloudCloud -> src/content/docs/agent-platform/cloud-agents/handoff/cloud-to-cloud.mdx + +# Orchestration / multi-agent runs +RunAgentsTool -> src/content/docs/agent-platform/cloud-agents/orchestration/multi-agent-runs.mdx + +# Prompt queueing +QueueSlashCommand -> src/content/docs/agent-platform/local-agents/interacting-with-agents/prompt-queueing.mdx +QueuedPromptsV2 -> src/content/docs/agent-platform/local-agents/interacting-with-agents/prompt-queueing.mdx + +# Reusable agents (named agents + agent-scoped API keys) +NamedAgents -> src/content/docs/agent-platform/cloud-agents/agents.mdx + +# Inference: BYOK and custom endpoints +SoloUserByok -> src/content/docs/agent-platform/inference/bring-your-own-api-key.mdx +CustomInferenceEndpoints -> src/content/docs/agent-platform/inference/custom-inference-endpoint.mdx + +# Billing & Usage settings page (redesigned) +BillingAndUsagePageV2 -> src/content/docs/support-and-community/plans-and-billing/index.mdx ## CLI commands -> doc pages @@ -152,40 +164,68 @@ oz secret -> src/content/docs/reference/cli/index.mdx oz provider -> src/content/docs/reference/cli/index.mdx oz federate -> src/content/docs/reference/cli/federate.mdx oz artifact -> src/content/docs/reference/cli/artifacts.mdx +oz api-key -> src/content/docs/reference/cli/api-keys.mdx # Internal/hidden command — not a user-facing surface, so no public docs. oz harness-support -> internal ## API endpoints -> doc pages -# Public API endpoints +# Paths are relative to /api/v1 and use OpenAPI-style {param} segments. +# Public API endpoints documented via the OpenAPI spec (developers/agent-api-openapi.yaml). POST /agent/run -> src/content/docs/reference/api-and-sdk/index.mdx GET /agent/runs -> src/content/docs/reference/api-and-sdk/index.mdx GET /agent/runs/{runId} -> src/content/docs/reference/api-and-sdk/index.mdx -# Internal/infrastructure endpoints (not part of public API, no docs needed) -GET /block/embed/:id -> internal -GET /block/:id -> internal -GET /referral/:id -> internal -GET /client_version -> internal -GET /client_version/daily -> internal -POST /receive_nps_response -> internal -POST /receive_pmf_response -> internal -GET /current_time -> internal -POST /graphql/v2 -> internal -GET /graphql/v2 -> internal -GET /graphiql -> internal -GET /graphiql/v2 -> internal -GET /download -> internal -GET /download/brew -> internal -GET /download/windows -> internal -GET /download/cli -> internal +# OAuth device-flow / OIDC plumbing used by `oz login` — not a public REST surface. +GET /oauth/authorize -> internal +POST /oauth/device/auth -> internal +POST /oauth/session -> internal +POST /oauth/token -> internal +GET /oauth/jwks.json -> internal +GET /.well-known/openid-configuration -> internal + +# Anonymous-viewer redirect probes (documented exceptions to auth, not API surfaces). +GET /agent/sessions/{session_uuid}/redirect -> internal +GET /agent/conversations/{conversation_id}/redirect -> internal + +# Legacy aliases of /agent/runs kept for compatibility. +GET /agent/tasks -> internal +GET /agent/tasks/{id} -> internal +POST /agent/tasks/{id}/cancel -> internal + +# Handoff/worker attachment plumbing (driven by clients and workers, not end users). +POST /agent/runs/{runId}/attachments/prepare -> internal +POST /agent/runs/{runId}/attachments/download -> internal +GET /agent/runs/{runId}/handoff/attachments -> internal +POST /agent/handoff/upload-snapshot -> internal +PATCH /agent/runs/{runId}/event-sequence -> internal +POST /agent/runs/{runId}/client-events -> internal +GET /agent/conversations/{conversation_id}/block-snapshot -> internal + +# Support endpoints for third-party harnesses (hidden `oz harness-support` CLI). +POST /harness-support/external-conversation -> internal +POST /harness-support/block-snapshot -> internal +POST /harness-support/transcript -> internal +GET /harness-support/transcript -> internal +POST /harness-support/resolve-prompt -> internal +POST /harness-support/report-artifact -> internal +POST /harness-support/notify-user -> internal +POST /harness-support/finish-task -> internal +POST /harness-support/report-shutdown -> internal +POST /harness-support/upload-snapshot -> internal + +## Slash commands -> doc pages + +# Most documented commands are matched automatically against the +# slash-commands page content; add entries here only for exceptions. +# Gated by the dogfood-only LocalDockerSandbox flag — not user-facing yet. +/docker-sandbox -> internal ## Flags to ignore (internal-only, not user-facing) # These flags are internal implementation details and don't need documentation CocoaSentry CrashReporting -CrashRecoveryForceX11 DebugMode LogExpensiveFramesInSentry WithSandboxTelemetry @@ -199,19 +239,14 @@ RecordPtyThroughput FetchGenericStringObjects IntegratedGPU LazySceneBuilding -RemoveAltScreenPadding MaximizeFlatStorage SharedBlockTitleGeneration RetryTruncatedCodeResponses ReloadStaleConversationFiles -NLDClassifierModelEnabled -ChangedLinesOnlyApplyDiffResult SendTelemetryToFile -SendEvalMetadata FileGlobV2Warnings ExpandEditToPane MCPGroupedServerContext -MultiAgentParallelToolCalls AgentDecidesCommandExecution AgentModePrimaryXML AgentModePrePlanXML @@ -221,12 +256,9 @@ GlobalAIAnalyticsCollection FastForwardAutoexecuteButton LinkedCodeBlocks V4AFileDiffs -NewWarpingAnimation -NewDiffModel SummarizationViaMessageReplacement SummarizationCancellationConfirmation TabCloseButtonOnLeft -LessHorizontalTerminalPadding RemoveAutosuggestionDuringTabCompletions ResizeFix ForceClassicCompletions @@ -237,21 +269,24 @@ MinimalistUI AvatarInTabBar SessionSharingAcls ImeMarkedText -ConvertLegacyMcps NewTabStyling AmbientAgentsRTC -OzBranding OzLaunchModal # One-time launch modal announcing Warp going open-source. # The announcement itself is covered in the 2026 changelog ("Warp is now open source.") # and the modal has no recurring user-facing surface that warrants its own doc page. OpenWarpLaunchModal +# One-time launch modal announcing multi-agent orchestration; the feature itself +# is documented via RunAgentsTool -> orchestration/multi-agent-runs.mdx. +OrchestrationLaunchModal GetStartedTab CreateProjectFlow CodeLaunchModal ValidateAutosuggestions ClearAutosuggestionOnEscape OzPlatformSkills +# Rendering detail for markdown tables in notebooks/AI output; no dedicated doc surface. +MarkdownTables # UI implementation details (not user-facing features) FallbackModelLoadOutputMessaging @@ -273,26 +308,27 @@ HOAOnboardingFlow AgentViewConversationListView BuildPlanAutoReloadBannerToggle BuildPlanAutoReloadPostPurchaseModal -UpgradeToProModal -UpgradeToProModalPromo FreeUserNoAi -SoloUserByok ForceLogin SimulateGithubUnauthed ConversationApi McpDebuggingIds ContextLineReviewComments RichTextMultiselect -ActiveConversationRequiresInteraction +# Redux iterations of the cloud mode setup/input UI; the cloud agents feature +# itself is documented via CloudMode -> cloud-agents/overview.md. +CloudModeSetupV2 +CloudModeInputV2 +# Internal GitHub credential refresh during task runs (changelog-only behavior fix). +GitCredentialRefresh +# Internal SSE streaming infrastructure for orchestration viewers/owners. +OrchestrationViewerStreamer +OwnerOrchestrationAncestorStreamer # Non-GA flags in dogfood/preview only -Orchestration -OrchestrationV2 -OrchestrationEventPush LSPAsATool SshRemoteServer EmbeddedCodeReviewComments -AgentManagementDetailsView InteractiveConversationManagementView MarkdownImages MarkdownMermaid @@ -301,11 +337,9 @@ OzIdentityFederation AgentHarness DirectoryTabColors ArtifactCommand -AgentViewBlockContext CloudModeImageContext CloudModeHostSelector AmbientAgentsImageUpload -NldImprovements CodebaseIndexSpeedbump CodebaseIndexPersistence SharedSessionWriteToLongRunningCommands @@ -317,8 +351,6 @@ CodeModeChip UndoClosedPanes RevertDiffHunk ViewingSharedSessions -SettingsImport -BlockToolbeltSaveAsWorkflow ShellSelector FullScreenZenMode WorkflowAliases @@ -339,7 +371,5 @@ PredictAMQueries UseTantivySearch CommandCorrectionsHistoryRule SuggestedAgentModeWorkflows -ConversationArtifacts -ConversationApi PRCommentsSkill FigmaDetection diff --git a/.agents/skills/missing_docs/references/surface_snapshot.json b/.agents/skills/missing_docs/references/surface_snapshot.json new file mode 100644 index 00000000..1dcccdc4 --- /dev/null +++ b/.agents/skills/missing_docs/references/surface_snapshot.json @@ -0,0 +1,670 @@ +{ + "schema_version": 1, + "flags": { + "AIBlockOverflowMenu": "other", + "AIContextMenuCode": "ga", + "AIContextMenuCommands": "other", + "AIContextMenuEnabled": "ga", + "AIGeneratedOnboardingSuggestions": "other", + "AIMemories": "other", + "AIResumeButton": "ga", + "AIRules": "ga", + "APIKeyAuthentication": "ga", + "APIKeyManagement": "ga", + "ActiveConversationRequiresInteraction": "ga", + "AgentDecidesCommandExecution": "ga", + "AgentHarness": "ga", + "AgentManagementDetailsView": "ga", + "AgentManagementView": "ga", + "AgentMode": "ga", + "AgentModeAnalytics": "dogfood", + "AgentModeComputerUse": "ga", + "AgentModePrePlanXML": "ga", + "AgentModePrimaryXML": "ga", + "AgentModeWorkflows": "ga", + "AgentOnboarding": "ga", + "AgentPredict": "other", + "AgentSharedSessions": "ga", + "AgentTips": "ga", + "AgentToolbarEditor": "ga", + "AgentView": "ga", + "AgentViewBlockContext": "ga", + "AgentViewConversationListView": "ga", + "AgentViewPromptChip": "other", + "AlacrittySettingsImport": "other", + "AllowIgnoringInputSuggestions": "ga", + "AllowOpeningFileLinksUsingEditorEnv": "ga", + "AmbientAgentsCommandLine": "ga", + "AmbientAgentsImageUpload": "ga", + "AmbientAgentsRTC": "ga", + "ArtifactCommand": "ga", + "AskUserQuestion": "ga", + "AsyncFind": "dogfood", + "AtMenuOutsideOfAIMode": "ga", + "AutoOpenCodeReviewPane": "ga", + "Autoupdate": "ga", + "AutoupdateUIRevamp": "ga", + "AvatarInTabBar": "ga", + "BillingAndUsagePageV2": "ga", + "BlocklistMarkdownImages": "ga", + "BlocklistMarkdownTableRendering": "ga", + "BuildPlanAutoReloadBannerToggle": "other", + "BuildPlanAutoReloadPostPurchaseModal": "other", + "BundledSkills": "ga", + "CLIAgentRichInput": "ga", + "Changelog": "ga", + "ClassicCompletions": "ga", + "ClearAutosuggestionOnEscape": "ga", + "CloudConversations": "ga", + "CloudEnvironments": "ga", + "CloudMode": "ga", + "CloudModeFromLocalSession": "ga", + "CloudModeHostSelector": "other", + "CloudModeImageContext": "ga", + "CloudModeInputV2": "ga", + "CloudModeSetupV2": "ga", + "CloudObjects": "other", + "CocoaSentry": "other", + "CodeFindReplace": "ga", + "CodeLaunchModal": "ga", + "CodeModeChip": "other", + "CodeReviewFind": "ga", + "CodeReviewSaveChanges": "ga", + "CodeReviewScrollPreservation": "dogfood", + "CodebaseIndexPersistence": "dogfood", + "CodebaseIndexSpeedbump": "dogfood", + "CodexNotifications": "ga", + "CodexPlugin": "dogfood", + "CommandCorrectionKey": "ga", + "CommandCorrectionsHistoryRule": "other", + "CommandPaletteFileSearch": "ga", + "ConfigurableToolbar": "ga", + "ContextChips": "other", + "ContextLineReviewComments": "dogfood", + "ContextWindowUsageV2": "ga", + "ConversationApi": "ga", + "ConversationArtifacts": "ga", + "ConversationsAsContext": "ga", + "CrashReporting": "ga", + "CreateEnvironmentSlashCommand": "ga", + "CreateProjectFlow": "ga", + "CreatingSharedSessions": "dogfood", + "CrossRepoContext": "dogfood", + "CustomInferenceEndpoints": "ga", + "CustomInferenceEndpointsEnterprise": "other", + "CycleNextCommandSuggestion": "other", + "DebugMode": "other", + "DefaultAdeberryTheme": "other", + "DefaultWaterfallMode": "ga", + "DiffSetAsContext": "ga", + "DirectoryTabColors": "ga", + "DiscardPerFileAndAllChanges": "ga", + "DragTabsToWindows": "preview", + "DriveObjectsAsContext": "ga", + "DynamicWorkflowEnums": "ga", + "EditableMarkdownMermaid": "dogfood", + "EmbeddedCodeReviewComments": "other", + "ExpandEditToPane": "ga", + "FallbackModelLoadOutputMessaging": "ga", + "FastForwardAutoexecuteButton": "ga", + "FetchChannelVersionsFromWarpServer": "other", + "FetchGenericStringObjects": "other", + "FigmaDetection": "ga", + "FileAndDiffSetComments": "dogfood", + "FileBasedMcp": "ga", + "FileGlobV2Warnings": "dogfood", + "FileRetrievalTools": "ga", + "FileTree": "ga", + "ForceClassicCompletions": "ga", + "ForceLogin": "other", + "ForkFromCommand": "ga", + "FreeUserNoAi": "other", + "FullScreenZenMode": "ga", + "FullSourceCodeEmbedding": "dogfood", + "GPTConfigurableContextWindow": "dogfood", + "GeminiNotifications": "dogfood", + "GetStartedTab": "ga", + "GitCredentialRefresh": "ga", + "GitOperationsInCodeReview": "ga", + "GithubPrPromptChip": "ga", + "GlobalAIAnalyticsBanner": "other", + "GlobalAIAnalyticsCollection": "ga", + "GlobalSearch": "ga", + "GrepTool": "ga", + "GroupedTabs": "preview", + "HOANotifications": "ga", + "HOAOnboardingFlow": "ga", + "HOARemoteControl": "ga", + "HandoffCloudCloud": "ga", + "HandoffLocalCloud": "ga", + "HarnessSessionHeader": "other", + "HoaCodeReview": "ga", + "ITermImages": "other", + "ImageAsContext": "ga", + "ImeMarkedText": "ga", + "InBandGeneratorsForSSH": "other", + "IncrementalAutoReload": "ga", + "InlineCodeReview": "ga", + "InlineHistoryMenu": "ga", + "InlineMenuHeaders": "ga", + "InlineProfileSelector": "ga", + "InlineRepoMenu": "ga", + "IntegratedGPU": "other", + "IntegrationCommand": "ga", + "InteractiveConversationManagementView": "ga", + "KittyImages": "ga", + "KittyKeyboardProtocol": "ga", + "KnowledgeSidebar": "other", + "LSPAsATool": "other", + "LazySceneBuilding": "dogfood", + "Ligatures": "ga", + "LinkedCodeBlocks": "ga", + "ListSkills": "ga", + "LocalClaudeCodexChildHarnesses": "other", + "LocalComputerUse": "dogfood", + "LocalDockerSandbox": "dogfood", + "LogExpensiveFramesInSentry": "dogfood", + "MCPGroupedServerContext": "ga", + "MSYS2Shells": "dogfood", + "MarkdownImages": "dogfood", + "MarkdownMermaid": "ga", + "MarkdownTables": "ga", + "MaximizeFlatStorage": "other", + "McpDebuggingIds": "other", + "McpOauth": "ga", + "McpServer": "ga", + "MinimalistUI": "ga", + "MultiProfile": "ga", + "MultiWorkspace": "dogfood", + "NamedAgents": "ga", + "NativeShellCompletions": "other", + "NewTabStyling": "ga", + "OpenCodeNotifications": "ga", + "OpenWarpLaunchModal": "ga", + "OpenWarpNewSettingsModes": "ga", + "OrchestrationLaunchModal": "ga", + "OrchestrationViewerStreamer": "ga", + "OwnerOrchestrationAncestorStreamer": "ga", + "OzChangelogUpdates": "ga", + "OzHandoff": "ga", + "OzIdentityFederation": "ga", + "OzLaunchModal": "ga", + "OzPlatformSkills": "ga", + "PRCommentsSkill": "ga", + "PRCommentsSlashCommand": "ga", + "PRCommentsV2": "ga", + "PartialNextCommandSuggestions": "other", + "PendingUserQueryIndicator": "ga", + "PinnedTabs": "other", + "PluggableNotifications": "ga", + "PredictAMQueries": "other", + "ProfilesDesignRevamp": "ga", + "Projects": "dogfood", + "PromptSuggestionsViaMAA": "other", + "ProviderCommand": "dogfood", + "QueueSlashCommand": "ga", + "QueuedPromptsV2": "ga", + "ReadImageFiles": "ga", + "RecordAppActiveEvents": "other", + "RecordPtyThroughput": "other", + "RectSelection": "ga", + "ReloadStaleConversationFiles": "ga", + "RememberFastForwardState": "dogfood", + "RemoteCodeReview": "ga", + "RemoteCodebaseIndexing": "ga", + "RemoveAutosuggestionDuringTabCompletions": "dogfood", + "ResizeFix": "dogfood", + "RestorePromptOnInlineModelSelectorSearch": "dogfood", + "RetryTruncatedCodeResponses": "dogfood", + "RevertDiffHunk": "ga", + "RevertToCheckpoints": "ga", + "RewindSlashCommand": "ga", + "RichTextMultiselect": "ga", + "RunAgentsTool": "ga", + "RunGeneratorsWithCmdExe": "dogfood", + "RuntimeFeatureFlags": "other", + "SSHTmuxWrapper": "dogfood", + "ScheduledAmbientAgents": "ga", + "SearchCodebaseUI": "ga", + "SelectablePrompt": "other", + "SelectionAsContext": "ga", + "SendTelemetryToFile": "other", + "SequentialStorage": "other", + "SessionSharingAcls": "ga", + "SettingsFile": "ga", + "SharedBlockTitleGeneration": "ga", + "SharedSessionWriteToLongRunningCommands": "ga", + "SharedWithMe": "ga", + "ShellSelector": "ga", + "SimulateGithubUnauthed": "other", + "SkillArguments": "ga", + "SkipFirebaseAnonymousUser": "ga", + "SoloUserByok": "ga", + "SshDragAndDrop": "dogfood", + "SshRemoteServer": "ga", + "SuggestedAgentModeWorkflows": "other", + "SuggestedRules": "ga", + "SummarizationCancellationConfirmation": "ga", + "SummarizationConversationCommand": "ga", + "SummarizationViaMessageReplacement": "dogfood", + "SuperGrok": "dogfood", + "SyncAmbientPlans": "ga", + "TabCloseButtonOnLeft": "ga", + "TabConfigs": "ga", + "TabbedEditorView": "ga", + "TeamApiKeys": "ga", + "ThinStrokes": "other", + "ToggleBootstrapBlock": "dogfood", + "TransferControlTool": "ga", + "TrimTrailingBlankLines": "ga", + "UIZoom": "ga", + "UndoClosedPanes": "ga", + "UsageBasedPricing": "ga", + "UseTantivySearch": "other", + "V4AFileDiffs": "ga", + "ValidateAutosuggestions": "ga", + "VerticalTabs": "ga", + "VerticalTabsSummaryMode": "ga", + "ViewingSharedSessions": "ga", + "VimCodeEditor": "ga", + "WarpControlCli": "other", + "WarpManagedSecrets": "ga", + "WarpPacks": "ga", + "WarpifyFooter": "ga", + "WebFetchUI": "ga", + "WebSearchUI": "ga", + "WelcomeBlock": "other", + "WelcomeTab": "other", + "WelcomeTips": "other", + "WithSandboxTelemetry": "other", + "WorkflowAliases": "ga" + }, + "cli_commands": [ + { + "command": "oz agent", + "hidden": false + }, + { + "command": "oz agent create", + "hidden": false + }, + { + "command": "oz agent delete", + "hidden": false + }, + { + "command": "oz agent get", + "hidden": false + }, + { + "command": "oz agent list", + "hidden": false + }, + { + "command": "oz agent profile", + "hidden": false + }, + { + "command": "oz agent run", + "hidden": false + }, + { + "command": "oz agent run-cloud", + "hidden": false + }, + { + "command": "oz agent skills", + "hidden": false + }, + { + "command": "oz agent update", + "hidden": false + }, + { + "command": "oz api-key", + "hidden": false + }, + { + "command": "oz api-key create", + "hidden": false + }, + { + "command": "oz api-key expire", + "hidden": false + }, + { + "command": "oz api-key list", + "hidden": false + }, + { + "command": "oz artifact", + "hidden": false + }, + { + "command": "oz artifact download", + "hidden": false + }, + { + "command": "oz artifact get", + "hidden": false + }, + { + "command": "oz artifact upload", + "hidden": true + }, + { + "command": "oz environment", + "hidden": false + }, + { + "command": "oz environment create", + "hidden": false + }, + { + "command": "oz environment delete", + "hidden": false + }, + { + "command": "oz environment get", + "hidden": false + }, + { + "command": "oz environment image", + "hidden": false + }, + { + "command": "oz environment list", + "hidden": false + }, + { + "command": "oz environment update", + "hidden": false + }, + { + "command": "oz federate", + "hidden": false + }, + { + "command": "oz federate issue-gcp-token", + "hidden": true + }, + { + "command": "oz federate issue-token", + "hidden": false + }, + { + "command": "oz harness-support", + "hidden": true + }, + { + "command": "oz harness-support finish-task", + "hidden": true + }, + { + "command": "oz harness-support notify-user", + "hidden": true + }, + { + "command": "oz harness-support ping", + "hidden": true + }, + { + "command": "oz harness-support report-artifact", + "hidden": true + }, + { + "command": "oz harness-support report-shutdown", + "hidden": true + }, + { + "command": "oz integration", + "hidden": false + }, + { + "command": "oz integration create", + "hidden": false + }, + { + "command": "oz integration list", + "hidden": false + }, + { + "command": "oz integration update", + "hidden": false + }, + { + "command": "oz login", + "hidden": false + }, + { + "command": "oz logout", + "hidden": false + }, + { + "command": "oz mcp", + "hidden": false + }, + { + "command": "oz mcp list", + "hidden": false + }, + { + "command": "oz model", + "hidden": false + }, + { + "command": "oz model list", + "hidden": false + }, + { + "command": "oz provider", + "hidden": false + }, + { + "command": "oz provider list", + "hidden": false + }, + { + "command": "oz provider setup", + "hidden": false + }, + { + "command": "oz run", + "hidden": false + }, + { + "command": "oz run conversation", + "hidden": false + }, + { + "command": "oz run get", + "hidden": false + }, + { + "command": "oz run list", + "hidden": false + }, + { + "command": "oz run message", + "hidden": false + }, + { + "command": "oz schedule", + "hidden": false + }, + { + "command": "oz schedule create", + "hidden": false + }, + { + "command": "oz schedule delete", + "hidden": false + }, + { + "command": "oz schedule get", + "hidden": false + }, + { + "command": "oz schedule list", + "hidden": false + }, + { + "command": "oz schedule pause", + "hidden": false + }, + { + "command": "oz schedule unpause", + "hidden": false + }, + { + "command": "oz schedule update", + "hidden": false + }, + { + "command": "oz secret", + "hidden": false + }, + { + "command": "oz secret create", + "hidden": false + }, + { + "command": "oz secret delete", + "hidden": false + }, + { + "command": "oz secret list", + "hidden": false + }, + { + "command": "oz secret update", + "hidden": false + }, + { + "command": "oz whoami", + "hidden": false + } + ], + "api_routes": [ + "DELETE /api/v1/agent/identities/{uid}", + "DELETE /api/v1/agent/schedules/{id}", + "DELETE /api/v1/memory_stores/{uid}", + "DELETE /api/v1/memory_stores/{uid}/memories/{memoryUid}", + "GET /.well-known/openid-configuration", + "GET /api/v1/agent", + "GET /api/v1/agent/artifacts/{uid}", + "GET /api/v1/agent/connected-self-hosted-workers", + "GET /api/v1/agent/conversations/{conversation_id}/block-snapshot", + "GET /api/v1/agent/conversations/{conversation_id}/redirect", + "GET /api/v1/agent/environments", + "GET /api/v1/agent/events", + "GET /api/v1/agent/events/stream", + "GET /api/v1/agent/identities", + "GET /api/v1/agent/identities/{uid}", + "GET /api/v1/agent/messages/{run_id}", + "GET /api/v1/agent/models", + "GET /api/v1/agent/runs", + "GET /api/v1/agent/runs/{runId}", + "GET /api/v1/agent/runs/{runId}/handoff/attachments", + "GET /api/v1/agent/runs/{runId}/timeline", + "GET /api/v1/agent/runs/{runId}/transcript", + "GET /api/v1/agent/schedules", + "GET /api/v1/agent/schedules/{id}", + "GET /api/v1/agent/sessions/{session_uuid}/redirect", + "GET /api/v1/agent/tasks", + "GET /api/v1/agent/tasks/{id}", + "GET /api/v1/harness-support/transcript", + "GET /api/v1/memory_stores", + "GET /api/v1/memory_stores/{uid}", + "GET /api/v1/memory_stores/{uid}/agents", + "GET /api/v1/memory_stores/{uid}/memories", + "GET /api/v1/memory_stores/{uid}/memories/{memoryUid}/versions", + "GET /oauth/authorize", + "GET /oauth/jwks.json", + "PATCH /api/v1/agent/runs/{runId}/event-sequence", + "POST /api/v1/agent/events/{run_id}", + "POST /api/v1/agent/handoff/upload-snapshot", + "POST /api/v1/agent/identities", + "POST /api/v1/agent/messages", + "POST /api/v1/agent/messages/{id}/delivered", + "POST /api/v1/agent/messages/{id}/read", + "POST /api/v1/agent/run", + "POST /api/v1/agent/runs", + "POST /api/v1/agent/runs/{runId}/attachments/download", + "POST /api/v1/agent/runs/{runId}/attachments/prepare", + "POST /api/v1/agent/runs/{runId}/cancel", + "POST /api/v1/agent/runs/{runId}/client-events", + "POST /api/v1/agent/runs/{runId}/followups", + "POST /api/v1/agent/schedules", + "POST /api/v1/agent/schedules/{id}/pause", + "POST /api/v1/agent/schedules/{id}/resume", + "POST /api/v1/agent/tasks/{id}/cancel", + "POST /api/v1/harness-support/block-snapshot", + "POST /api/v1/harness-support/external-conversation", + "POST /api/v1/harness-support/finish-task", + "POST /api/v1/harness-support/notify-user", + "POST /api/v1/harness-support/report-artifact", + "POST /api/v1/harness-support/report-shutdown", + "POST /api/v1/harness-support/resolve-prompt", + "POST /api/v1/harness-support/transcript", + "POST /api/v1/harness-support/upload-snapshot", + "POST /api/v1/memory_stores", + "POST /api/v1/memory_stores/{uid}/memories", + "POST /oauth/device/auth", + "POST /oauth/session", + "POST /oauth/token", + "PUT /api/v1/agent/identities/{uid}", + "PUT /api/v1/agent/schedules/{id}", + "PUT /api/v1/memory_stores/{uid}", + "PUT /api/v1/memory_stores/{uid}/memories/{memoryUid}" + ], + "slash_commands": [ + "/add-mcp", + "/add-prompt", + "/add-rule", + "/agent", + "/changelog", + "/cloud-agent", + "/compact", + "/compact-and", + "/continue-locally", + "/conversations", + "/cost", + "/create-environment", + "/create-new-project", + "/docker-sandbox", + "/environment", + "/export-to-clipboard", + "/export-to-file", + "/feedback", + "/fork", + "/fork-and-compact", + "/fork-from", + "/handoff", + "/harness", + "/host", + "/index", + "/init", + "/model", + "/new", + "/open-code-review", + "/open-file", + "/open-mcp-servers", + "/open-project-rules", + "/open-repo", + "/open-rules", + "/open-settings-file", + "/open-skill", + "/pr-comments", + "/profile", + "/prompts", + "/queue", + "/remote-control", + "/rename-tab", + "/rewind", + "/set-tab-color", + "/skills", + "/usage" + ], + "changelog_last_version": "2026.06.03" +} diff --git a/.agents/skills/missing_docs/scripts/audit_docs.py b/.agents/skills/missing_docs/scripts/audit_docs.py index 020d88d6..b4b21f16 100755 --- a/.agents/skills/missing_docs/scripts/audit_docs.py +++ b/.agents/skills/missing_docs/scripts/audit_docs.py @@ -3,13 +3,21 @@ Missing Docs Audit Script for Warp Astro Starlight Documentation Compares documentation coverage against code surfaces in warp-internal and -warp-server to identify gaps. Produces a structured JSON report. +warp-server to identify gaps, and (in --diff mode) detects surface changes +since the last committed snapshot. Produces a structured JSON report. Usage: python3 .agents/skills/missing_docs/scripts/audit_docs.py python3 .agents/skills/missing_docs/scripts/audit_docs.py --category features python3 .agents/skills/missing_docs/scripts/audit_docs.py --output report.json - python3 .agents/skills/missing_docs/scripts/audit_docs.py --weak-coverage + python3 .agents/skills/missing_docs/scripts/audit_docs.py --diff + python3 .agents/skills/missing_docs/scripts/audit_docs.py --update-snapshot + +Exit codes: + 0 — all requested audits ran (findings may still exist; check the report) + 1 — fatal setup error (docs directory not found, bad arguments) + 2 — one or more audits were SKIPPED (missing repo paths). Never treat a + run that exits 2 as a clean audit. """ import argparse @@ -33,6 +41,9 @@ SKILL_DIR = SCRIPT_DIR.parent SURFACE_MAP_PATH = SKILL_DIR / "references" / "feature_surface_map.md" STALE_TERMS_PATH = SKILL_DIR / "references" / "stale_terms.md" +DEFAULT_SNAPSHOT_PATH = SKILL_DIR / "references" / "surface_snapshot.json" + +SNAPSHOT_SCHEMA_VERSION = 1 # --------------------------------------------------------------------------- # Surface map parser @@ -44,6 +55,7 @@ def parse_surface_map(path: Path) -> dict: "feature_to_doc": {}, "cli_to_doc": {}, "api_to_doc": {}, + "slash_to_doc": {}, "ignore_flags": set(), } if not path.exists(): @@ -59,6 +71,8 @@ def parse_surface_map(path: Path) -> dict: current_section = "cli" elif line.startswith("## API endpoints"): current_section = "api" + elif line.startswith("## Slash commands"): + current_section = "slash" elif line.startswith("## Flags to ignore"): current_section = "ignore" continue @@ -77,6 +91,8 @@ def parse_surface_map(path: Path) -> dict: result["cli_to_doc"][key] = doc_path elif current_section == "api": result["api_to_doc"][key] = doc_path + elif current_section == "slash": + result["slash_to_doc"][key] = doc_path return result @@ -100,8 +116,11 @@ def parse_stale_terms(path: Path) -> list[tuple[str, str]]: # Helpers # --------------------------------------------------------------------------- -def find_repo(name: str, explicit_path: str | None, docs_root: Path) -> Path | None: - """Find a sibling repo by name, or use the explicit path.""" +def find_repo(name: str, explicit_path: str | None, repo_root: Path) -> Path | None: + """Find a source repo by explicit path or as a sibling of the docs repo root. + + e.g. docs at /workspace/docs -> look for /workspace/. + """ if explicit_path: p = Path(explicit_path).resolve() if p.exists(): @@ -109,8 +128,7 @@ def find_repo(name: str, explicit_path: str | None, docs_root: Path) -> Path | N print(f"Warning: explicit path {explicit_path} does not exist", file=sys.stderr) return None - # Try sibling directory (docs is at .../code/docs, look for .../code/) - sibling = docs_root.parent / name + sibling = repo_root.parent / name if sibling.exists(): return sibling return None @@ -128,7 +146,7 @@ def find_markdown_files(docs_root: Path) -> list[Path]: def read_all_docs_text(docs_root: Path) -> dict[str, str]: - """Read all doc files into a dict of {relative_path: content}.""" + """Read all doc files into a dict of {relative_path: content} (lowercased).""" result = {} for f in find_markdown_files(docs_root): try: @@ -139,6 +157,23 @@ def read_all_docs_text(docs_root: Path) -> dict[str, str]: return result +_FENCED_CODE_RE = re.compile(r"```.*?```", re.DOTALL) +_INLINE_CODE_RE = re.compile(r"`[^`\n]*`") +_HTML_CODE_RE = re.compile(r".*?", re.DOTALL | re.IGNORECASE) + + +def strip_code_spans(text: str) -> str: + """Remove fenced code blocks, inline code spans, and elements. + + Used by the staleness audit so CLI examples (e.g. `oz agent run`) don't + trigger terminology findings meant for prose. + """ + text = _FENCED_CODE_RE.sub(" ", text) + text = _HTML_CODE_RE.sub(" ", text) + text = _INLINE_CODE_RE.sub(" ", text) + return text + + def resolve_doc_path(doc_path: str, repo_root: Path) -> Path | None: """Return the first existing variant of a mapped doc path. @@ -197,28 +232,33 @@ def search_docs_for_terms(docs_text: dict[str, str], terms: list[str]) -> list[s break return matches + +def kebab_case(name: str) -> str: + """PascalCase -> kebab-case: RunCloud -> run-cloud, MCP -> mcp.""" + return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name).lower() + # --------------------------------------------------------------------------- -# Audit 1: Feature flag coverage +# Extraction: feature flags (warp-internal) # --------------------------------------------------------------------------- -def parse_feature_flags(warp_internal: Path) -> list[str]: - """Parse FeatureFlag enum variants from features.rs.""" - # Try known locations in order +def _features_lib_rs(warp_internal: Path) -> Path | None: candidates = [ warp_internal / "crates" / "warp_features" / "src" / "lib.rs", warp_internal / "crates" / "warp_core" / "src" / "features.rs", warp_internal / "app" / "src" / "features.rs", warp_internal / "warp_core" / "src" / "features.rs", ] - features_rs = next((c for c in candidates if c.exists()), None) + return next((c for c in candidates if c.exists()), None) + + +def parse_feature_flags(warp_internal: Path) -> list[str]: + """Parse FeatureFlag enum variants from the features lib.""" + features_rs = _features_lib_rs(warp_internal) if features_rs is None: - print(f"Warning: features.rs not found. Tried: {[str(c) for c in candidates]}", - file=sys.stderr) + print("Warning: FeatureFlag enum source not found in warp-internal", file=sys.stderr) return [] content = features_rs.read_text() - # Match enum variants (lines like " AgentMode," or " AgentMode {") - # inside the FeatureFlag enum in_enum = False flags = [] for line in content.splitlines(): @@ -229,16 +269,64 @@ def parse_feature_flags(warp_internal: Path) -> list[str]: if in_enum: if stripped == "}": break - # Skip comments, attributes, empty lines - if stripped.startswith("//") or stripped.startswith("#[") or stripped.startswith("///") or not stripped: + if stripped.startswith("//") or stripped.startswith("#[") or not stripped: continue - # Extract variant name match = re.match(r"^([A-Z]\w+)", stripped) if match: flags.append(match.group(1)) return flags +def parse_flag_list_const(warp_internal: Path, const_name: str) -> set[str]: + """Parse a `pub const : &[FeatureFlag] = &[...]` block into flag names.""" + features_rs = _features_lib_rs(warp_internal) + if features_rs is None: + return set() + content = features_rs.read_text() + match = re.search( + rf"const\s+{const_name}\s*:\s*&\[FeatureFlag\]\s*=\s*&\[(.*?)\];", + content, + re.DOTALL, + ) + if not match: + return set() + return set(re.findall(r"FeatureFlag::(\w+)", match.group(1))) + + +def parse_features_bridge(warp_internal: Path) -> dict[str, dict]: + """Parse the cargo-feature -> FeatureFlag bridge from app/src/features.rs. + + The authoritative mapping is the `enabled_features()` extend block: + + #[cfg(feature = "am_workflows")] + FeatureFlag::AgentModeWorkflows, + + Names frequently differ from a naive snake_case conversion, so this + bridge (not string transformation) decides which cargo feature gates a + flag. Entries gated on `debug_assertions` are never GA. + + Returns {flag_name: {"cargo_feature": str, "debug_only": bool}}. + """ + bridge_rs = warp_internal / "app" / "src" / "features.rs" + if not bridge_rs.exists(): + print(f"Warning: {bridge_rs} not found; GA detection will be incomplete", + file=sys.stderr) + return {} + + content = bridge_rs.read_text() + bridge: dict[str, dict] = {} + for match in re.finditer( + r"#\[cfg\(([^]]*?feature\s*=\s*\"(\w+)\"[^]]*?)\)\]\s*FeatureFlag::(\w+)", + content, + ): + cfg_expr, cargo_feature, flag = match.group(1), match.group(2), match.group(3) + bridge[flag] = { + "cargo_feature": cargo_feature, + "debug_only": "debug_assertions" in cfg_expr, + } + return bridge + + def parse_default_features(warp_internal: Path) -> set[str]: """Parse the default feature list from app/Cargo.toml.""" candidates = [ @@ -252,53 +340,428 @@ def parse_default_features(warp_internal: Path) -> set[str]: return set() content = cargo_toml.read_text() - # Find the default = [...] block match = re.search(r'default\s*=\s*\[(.*?)\]', content, re.DOTALL) if not match: return set() features_block = match.group(1) - # Extract quoted feature names return set(re.findall(r'"(\w+)"', features_block)) -def snake_to_pascal(snake: str) -> str: - """Convert snake_case to PascalCase: agent_mode -> AgentMode.""" - return "".join(word.capitalize() for word in snake.split("_")) +def compute_flag_statuses(warp_internal: Path) -> dict[str, str]: + """Classify every FeatureFlag by rollout status. + - "ga": gating cargo feature is in app/Cargo.toml default features, or the + flag is in RELEASE_FLAGS (enabled for all release builds). + - "preview": in PREVIEW_FLAGS (Preview builds; launching soon). + - "dogfood": in DOGFOOD_FLAGS (dev team only). + - "other": none of the above (runtime/experiment-gated or unused). These + may still be enabled via server-side experiments; the docs changelog + cross-check covers those launches. + """ + flags = parse_feature_flags(warp_internal) + bridge = parse_features_bridge(warp_internal) + default_features = parse_default_features(warp_internal) + release_flags = parse_flag_list_const(warp_internal, "RELEASE_FLAGS") + preview_flags = parse_flag_list_const(warp_internal, "PREVIEW_FLAGS") + dogfood_flags = parse_flag_list_const(warp_internal, "DOGFOOD_FLAGS") + + statuses: dict[str, str] = {} + for flag in flags: + info = bridge.get(flag) + is_ga = flag in release_flags + if info and not info["debug_only"] and info["cargo_feature"] in default_features: + is_ga = True + if is_ga: + statuses[flag] = "ga" + elif flag in preview_flags: + statuses[flag] = "preview" + elif flag in dogfood_flags: + statuses[flag] = "dogfood" + else: + statuses[flag] = "other" + return statuses + +# --------------------------------------------------------------------------- +# Extraction: CLI command tree (warp-internal) +# --------------------------------------------------------------------------- + +def _extract_enum_block(content: str, enum_name: str) -> str | None: + """Return the body of `pub enum { ... }` using brace matching.""" + match = re.search(rf"pub enum {enum_name}\s*\{{", content) + if not match: + return None + start = match.end() + depth = 1 + i = start + while i < len(content) and depth > 0: + if content[i] == "{": + depth += 1 + elif content[i] == "}": + depth -= 1 + i += 1 + return content[start:i - 1] + + +def _parse_enum_variants(enum_body: str) -> list[dict]: + """Parse top-level variants of a clap enum body. + + Returns [{"name", "hidden", "subcommand", "referenced_type"}]. + Tracks brace/paren depth so struct-variant fields aren't mistaken for + variants, and reads `hide = true` / `#[command(subcommand)]` from the + attributes stacked above each variant. + """ + variants = [] + depth = 0 + pending_attrs: list[str] = [] + for raw_line in enum_body.splitlines(): + line = raw_line.strip() + if depth == 0: + if line.startswith("#["): + pending_attrs.append(line) + elif line.startswith("///") or line.startswith("//") or not line: + pass + else: + match = re.match(r"^([A-Z]\w*)\s*(\(|\{|,|$)", line) + if match: + name = match.group(1) + attrs = " ".join(pending_attrs) + ref_match = re.search(r"\(\s*(?:crate::)?([\w:]+)\s*\)", line) + variants.append({ + "name": name, + "hidden": "hide = true" in attrs, + "subcommand": "subcommand" in attrs, + "referenced_type": ref_match.group(1) if ref_match else None, + }) + pending_attrs = [] + depth += raw_line.count("{") - raw_line.count("}") + depth += raw_line.count("(") - raw_line.count(")") + depth = max(depth, 0) + return variants + + +def _resolve_subcommand_enum(module_content: str, referenced_type: str | None) -> str | None: + """Find the enum body holding a variant's subcommands within a module file. + + Handles both direct enum references (AgentCommand) and the struct-wrapper + pattern (ScheduleCommand struct containing `Option`). + """ + if referenced_type: + type_name = referenced_type.split("::")[-1] + body = _extract_enum_block(module_content, type_name) + if body is not None: + return body + # Struct wrapper: look for any Subcommand-derived enum in the module. + for match in re.finditer(r"#\[derive\([^)]*Subcommand[^)]*\)\]", module_content): + tail = module_content[match.end():] + enum_match = re.search(r"pub enum (\w+)", tail[:300]) + if enum_match: + return _extract_enum_block(module_content, enum_match.group(1)) + return None + + +def parse_cli_commands(warp_internal: Path) -> list[dict]: + """Parse the full `oz` CLI command tree (top-level + one level of subcommands). + + Returns [{"command": "oz agent", "hidden": bool, "source_file": str, + "subcommands": [{"command": "oz agent run", "hidden": bool}]}] + """ + src_candidates = [ + warp_internal / "crates" / "warp_cli" / "src", + warp_internal / "warp_cli" / "src", + ] + src_dir = next((c for c in src_candidates if c.exists()), None) + if src_dir is None: + print("Warning: warp_cli/src not found in warp-internal", file=sys.stderr) + return [] + + lib_rs = src_dir / "lib.rs" + if not lib_rs.exists(): + print(f"Warning: {lib_rs} not found", file=sys.stderr) + return [] + + content = lib_rs.read_text() + enum_body = _extract_enum_block(content, "CliCommand") + if enum_body is None: + print("Warning: CliCommand enum not found in warp_cli/src/lib.rs", file=sys.stderr) + return [] + + commands = [] + for variant in _parse_enum_variants(enum_body): + cmd_name = kebab_case(variant["name"]) + entry = { + "command": f"oz {cmd_name}", + "hidden": variant["hidden"], + "source_file": None, + "subcommands": [], + } + ref = variant["referenced_type"] + if ref and "::" in ref: + module = ref.split("::")[0] + module_file = src_dir / f"{module}.rs" + if module_file.exists(): + entry["source_file"] = f"warp_cli/src/{module}.rs" + module_content = module_file.read_text() + sub_body = _resolve_subcommand_enum(module_content, ref) + if sub_body is not None: + for sub in _parse_enum_variants(sub_body): + entry["subcommands"].append({ + "command": f"oz {cmd_name} {kebab_case(sub['name'])}", + "hidden": sub["hidden"] or variant["hidden"], + }) + commands.append(entry) + return commands + +# --------------------------------------------------------------------------- +# Extraction: public API routes (warp-server) +# --------------------------------------------------------------------------- + +_GO_FUNC_RE = re.compile(r"^func (\w+)\(([^)]*)\)", re.MULTILINE) +_GO_GROUP_ASSIGN_RE = re.compile(r"(\w+)\s*:?=\s*(\w+)\.Group\(\s*\"([^\"]*)\"") +_GO_ROUTE_RE = re.compile(r"(\w+)\.(GET|POST|PUT|DELETE|PATCH)\(\s*\"([^\"]*)\"") +_GO_REGISTER_CALL_RE = re.compile( + r"(Register\w+)\(\s*(\w+)(?:\.Group\(\s*\"([^\"]*)\"\s*\))?\s*," +) + + +def _parse_go_functions(content: str) -> dict[str, dict]: + """Split a Go file into {func_name: {"params": str, "body": str}}.""" + functions = {} + matches = list(_GO_FUNC_RE.finditer(content)) + for i, match in enumerate(matches): + start = match.end() + end = matches[i + 1].start() if i + 1 < len(matches) else len(content) + functions[match.group(1)] = { + "params": match.group(2), + "body": content[start:end], + } + return functions + + +def parse_public_api_routes(warp_server: Path) -> list[dict]: + """Extract public API routes from router/handlers/public_api/*.go. + + Routes are registered via nested gin groups, e.g.: + + group := router.Group("/api/v1") (public_api.go) + RegisterAgentMessagingRoutes(group.Group("/agent"), ...) + messages := group.Group("/messages") (agent_messaging.go) + messages.POST("", SendMessageHandler(...)) -> POST /api/v1/agent/messages + + This walks group-variable assignments per registration function and + resolves caller-passed prefixes via Register* call sites, starting from + RegisterPublicAPIRoutes. Gin `:param` segments are normalized to + OpenAPI-style `{param}`. + """ + api_dir = warp_server / "router" / "handlers" / "public_api" + if not api_dir.exists(): + print(f"Warning: {api_dir} not found", file=sys.stderr) + return [] + + functions: dict[str, dict] = {} + for go_file in sorted(api_dir.glob("*.go")): + if go_file.name.endswith("_test.go"): + continue + for name, fn in _parse_go_functions(go_file.read_text()).items(): + fn["file"] = f"router/handlers/public_api/{go_file.name}" + functions[name] = fn + + def analyze(fn: dict) -> dict: + """Resolve a function body to routes/calls relative to its params.""" + params = fn["params"] + group_param = None + router_param = None + for param_match in re.finditer(r"(\w+)(?:\s*,\s*\w+)*\s+\*gin\.(RouterGroup|Engine)", params): + if param_match.group(2) == "RouterGroup" and group_param is None: + group_param = param_match.group(1) + elif param_match.group(2) == "Engine" and router_param is None: + router_param = param_match.group(1) + + # var name -> (base, prefix); base is "PARAM" (caller group) or + # "ROUTER" (engine root) + var_bases: dict[str, tuple] = {} + if group_param: + var_bases[group_param] = ("PARAM", "") + if router_param: + var_bases[router_param] = ("ROUTER", "") + + routes = [] + calls = [] + events = [] + for assign in _GO_GROUP_ASSIGN_RE.finditer(fn["body"]): + events.append(("assign", assign.start(), assign.groups())) + for route in _GO_ROUTE_RE.finditer(fn["body"]): + events.append(("route", route.start(), route.groups())) + for call in _GO_REGISTER_CALL_RE.finditer(fn["body"]): + events.append(("call", call.start(), call.groups())) + events.sort(key=lambda e: e[1]) + + for kind, _pos, groups in events: + if kind == "assign": + target, parent, prefix = groups + base = var_bases.get(parent) + if base is not None: + var_bases[target] = (base[0], base[1] + prefix) + elif kind == "route": + var, method, path = groups + base = var_bases.get(var) + if base is not None: + routes.append((base[0], method, base[1] + path)) + else: # call + callee, arg_var, arg_prefix = groups + base = var_bases.get(arg_var) + if base is not None: + calls.append((callee, base[0], base[1] + (arg_prefix or ""))) + return {"routes": routes, "calls": calls, "file": fn["file"]} + + analyzed = {name: analyze(fn) for name, fn in functions.items()} + + # Resolve absolute route prefixes by walking the call graph from + # RegisterPublicAPIRoutes. ROUTER-based paths are absolute already. + resolved: list[dict] = [] + visited: set[tuple] = set() + emitted_fns: set[str] = set() + + def emit(fn_name: str, param_prefix: str): + info = analyzed.get(fn_name) + if info is None: + return + key = (fn_name, param_prefix) + if key in visited: + return + visited.add(key) + emitted_fns.add(fn_name) + for base, method, path in info["routes"]: + full = (param_prefix + path) if base == "PARAM" else path + resolved.append({"method": method, "path": full, "file": info["file"]}) + for callee, base, prefix in info["calls"]: + callee_prefix = (param_prefix + prefix) if base == "PARAM" else prefix + emit(callee, callee_prefix) + + if "RegisterPublicAPIRoutes" in analyzed: + emit("RegisterPublicAPIRoutes", "") + # Any registration function not reachable from the entry point is assumed + # to hang off the /api/v1 group (conservative default so routes are never + # silently dropped). + for fn_name in sorted(analyzed): + if fn_name.startswith("Register") and fn_name not in emitted_fns: + emit(fn_name, "/api/v1") + + routes = [] + seen = set() + for route in resolved: + path = re.sub(r":(\w+)", r"{\1}", route["path"]) + path = re.sub(r"/{2,}", "/", path) or "/" + key = (route["method"], path) + if key in seen: + continue + seen.add(key) + routes.append({ + "method": route["method"], + "path": path, + "route": f"{route['method']} {path}", + "file": route["file"], + }) + routes.sort(key=lambda r: (r["path"], r["method"])) + return routes + +# --------------------------------------------------------------------------- +# Extraction: slash commands (warp-internal) +# --------------------------------------------------------------------------- + +def parse_slash_commands(warp_internal: Path) -> list[str]: + """Parse static slash command names from the registry.""" + registry_dir = ( + warp_internal / "app" / "src" / "search" / "slash_command_menu" / "static_commands" + ) + if not registry_dir.exists(): + print(f"Warning: {registry_dir} not found", file=sys.stderr) + return [] + + names: set[str] = set() + for rs_file in sorted(registry_dir.glob("*.rs")): + if rs_file.name.endswith("_tests.rs"): + continue + for match in re.finditer(r'name:\s*"(/[a-z0-9][a-z0-9-]*)"', rs_file.read_text()): + names.add(match.group(1)) + return sorted(names) + +# --------------------------------------------------------------------------- +# Extraction: docs changelog entries +# --------------------------------------------------------------------------- + +_CHANGELOG_HEADER_RE = re.compile(r"^### (\d{4}\.\d{2}\.\d{2})", re.MULTILINE) +_CHANGELOG_TRACKED_SECTIONS = ("new features", "improvements") + + +def parse_changelog_entries(repo_root: Path) -> list[dict]: + """Parse release entries from src/content/docs/changelog/.mdx. + + Returns [{"version": "2026.06.03", "file": str, "items": + [{"category": "new features", "text": str}]}] sorted newest first. + Only "New features" and "Improvements" bullets are tracked — those are the + sections that may represent undocumented feature launches. + """ + changelog_dir = repo_root / "src" / "content" / "docs" / "changelog" + if not changelog_dir.exists(): + return [] + + entries = [] + for mdx in sorted(changelog_dir.glob("*.mdx")): + if not re.fullmatch(r"\d{4}", mdx.stem): + continue + content = mdx.read_text(encoding="utf-8") + headers = list(_CHANGELOG_HEADER_RE.finditer(content)) + for i, header in enumerate(headers): + end = headers[i + 1].start() if i + 1 < len(headers) else len(content) + body = content[header.end():end] + items = [] + current_section = None + for line in body.splitlines(): + stripped = line.strip() + section_match = re.match(r"^\*\*(.+?)\*\*$", stripped) + if section_match: + current_section = section_match.group(1).strip().lower() + continue + if current_section in _CHANGELOG_TRACKED_SECTIONS and stripped.startswith("* "): + items.append({ + "category": current_section, + "text": stripped[2:].strip(), + }) + entries.append({ + "version": header.group(1), + "file": str(mdx.relative_to(repo_root)), + "items": items, + }) + entries.sort(key=lambda e: e["version"], reverse=True) + return entries + +# --------------------------------------------------------------------------- +# Audit 1: Feature flag coverage +# --------------------------------------------------------------------------- def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, docs_text: dict[str, str], + flag_statuses: dict[str, str] | None = None, weak_coverage: bool = False) -> list[dict]: """Audit feature flag coverage in docs. - By default, a flag is treated as covered if its mapped doc exists (the - surface map maintainer has verified the mapping). When ``weak_coverage`` - is True, also verify that the target doc actually mentions feature - keywords — useful for catching pages that have been renamed or trimmed - so the flag is no longer documented in prose. + GA flags must be mapped in the surface map (with an existing target page) + or appear in docs prose. Preview flags produce low-severity "docs needed + soon" findings when uncovered. Dogfood/other flags are skipped (tracked by + the snapshot diff instead). """ - flags = parse_feature_flags(warp_internal) - default_features = parse_default_features(warp_internal) + if flag_statuses is None: + flag_statuses = compute_flag_statuses(warp_internal) ignore_flags = surface_map.get("ignore_flags", set()) feature_to_doc = surface_map.get("feature_to_doc", {}) - - # Mapped paths in feature_surface_map.md are repo-root relative - # (e.g., "src/content/docs/..."), so resolve against the repo root. repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent findings = [] - for flag in flags: - # Skip ignored flags + for flag, status in flag_statuses.items(): if flag in ignore_flags: continue - - # Determine if GA (in default features) - snake = re.sub(r"([a-z])([A-Z])", r"\1_\2", flag).lower() - is_ga = snake in default_features - - # Skip non-GA features (they're behind flags and may not need docs yet) - if not is_ga: + if status not in ("ga", "preview"): continue # Check if mapped in surface map @@ -311,9 +774,7 @@ def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, # maintainer has confirmed the page covers this flag. continue # Optional weak-coverage check: verify the target page - # actually mentions feature keywords. Skip the lowercase - # concatenated / snake_case variants since they rarely - # match human-written prose. + # actually mentions feature keywords. try: doc_content = resolved.read_text(encoding="utf-8").lower() except Exception: @@ -323,6 +784,7 @@ def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, if check_terms and not any(t in doc_content for t in check_terms): findings.append({ "flag": flag, + "status": status, "search_terms": terms, "severity": "low", "suggested_doc_path": doc_path, @@ -338,9 +800,22 @@ def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, terms = camel_to_search_terms(flag) matches = search_docs_for_terms(docs_text, terms) if matches: + findings.append({ + "flag": flag, + "status": status, + "search_terms": terms, + "severity": "low", + "suggested_doc_path": doc_path, + "matched_docs": matches[:3], + "reason": ( + f"Mapped doc {doc_path} does not exist but docs mention the " + "feature — update the surface map entry to the new location" + ), + }) continue findings.append({ "flag": flag, + "status": status, "search_terms": terms, "severity": "high", "suggested_doc_path": doc_path, @@ -351,14 +826,36 @@ def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, # Not in surface map — search docs for mentions terms = camel_to_search_terms(flag) matches = search_docs_for_terms(docs_text, terms) - if not matches: + if matches: + # Mentioned somewhere but unmapped. The fuzzy match may be a false + # negative (generic fragments like "slash command" match anything), + # so surface it as low-severity map work instead of passing silently. findings.append({ "flag": flag, + "status": status, "search_terms": terms, - "severity": "medium", + "severity": "low", "suggested_doc_path": None, - "reason": "GA feature with no doc mentions found", + "matched_docs": matches[:3], + "reason": ( + f"{'GA' if status == 'ga' else 'Preview'} flag is unmapped; docs " + "may mention it (fuzzy match) — verify coverage and add a surface " + "map entry (or move to the ignore list)" + ), }) + continue + findings.append({ + "flag": flag, + "status": status, + "search_terms": terms, + "severity": "medium" if status == "ga" else "low", + "suggested_doc_path": None, + "reason": ( + "GA feature with no doc mentions found" + if status == "ga" + else "Preview feature with no doc mentions found — docs needed soon" + ), + }) return findings @@ -366,72 +863,9 @@ def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, # Audit 2: CLI command coverage # --------------------------------------------------------------------------- -def parse_cli_commands(warp_internal: Path) -> list[dict]: - """Parse CLI subcommands from warp_cli/src/lib.rs.""" - candidates = [ - warp_internal / "crates" / "warp_cli" / "src" / "lib.rs", - warp_internal / "warp_cli" / "src" / "lib.rs", - ] - lib_rs = next((c for c in candidates if c.exists()), None) - if lib_rs is None: - print(f"Warning: warp_cli/src/lib.rs not found. Tried: {[str(c) for c in candidates]}", - file=sys.stderr) - return [] - - content = lib_rs.read_text() - commands = [] - - # Find CliCommand enum variants - in_enum = False - for line in content.splitlines(): - stripped = line.strip() - if "enum CliCommand" in stripped: - in_enum = True - continue - if in_enum: - if stripped == "}": - break - # Match lines like "Agent(crate::agent::AgentCommand)," - match = re.match(r"^([A-Z]\w+)(?:\(|,|\s*$)", stripped) - if match: - name = match.group(1) - # Convert PascalCase to lowercase command name - cmd_name = re.sub(r"([a-z])([A-Z])", r"\1-\2", name).lower() - # Find source file - source_match = re.search(rf"crate::(\w+)::", stripped) - source_file = f"warp_cli/src/{source_match.group(1)}.rs" if source_match else None - commands.append({ - "name": name, - "command": f"oz {cmd_name}", - "source_file": source_file, - }) - - return commands - - -def parse_subcommands_from_file(warp_internal: Path, filename: str) -> list[str]: - """Parse subcommand names from a CLI command file (e.g., agent.rs).""" - candidates = [ - warp_internal / "crates" / "warp_cli" / "src" / filename, - warp_internal / "warp_cli" / "src" / filename, - ] - filepath = next((c for c in candidates if c.exists()), None) - if filepath is None: - return [] - - content = filepath.read_text() - subcommands = [] - - # Find enum variants that represent subcommands - for match in re.finditer(r"///\s*(.+?)\n\s*(?:#\[.*?\]\s*\n\s*)*([A-Z]\w+)", content): - subcommands.append(match.group(2)) - - return subcommands - - def audit_cli(warp_internal: Path, docs_root: Path, surface_map: dict, docs_text: dict[str, str]) -> list[dict]: - """Audit CLI command coverage in docs.""" + """Audit CLI command and subcommand coverage in docs.""" commands = parse_cli_commands(warp_internal) cli_to_doc = surface_map.get("cli_to_doc", {}) repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent @@ -446,35 +880,47 @@ def audit_cli(warp_internal: Path, docs_root: Path, surface_map: dict, except Exception: pass - findings = [] - for cmd in commands: - cmd_str = cmd["command"] - - # Check surface map + def is_covered(cmd_str: str, search_phrase: str) -> bool: if cmd_str in cli_to_doc: doc_path = cli_to_doc[cmd_str] # `internal` is a sentinel for hidden/internal commands that # intentionally have no public docs (matches API audit semantics). if doc_path == "internal": - continue + return True if resolve_doc_path(doc_path, repo_root) is not None: - continue # Mapped and exists + return True + return any(search_phrase in content for content in cli_docs_text.values()) - # Search CLI docs for the command name - cmd_name = cmd_str.split()[-1] # e.g., "agent" from "oz agent" - found = False - for content in cli_docs_text.values(): - if cmd_name in content: - found = True - break - - if not found: + findings = [] + for cmd in commands: + if cmd["hidden"]: + continue + cmd_str = cmd["command"] + # e.g. "agent" from "oz agent" + top_phrase = cmd_str.split(" ", 1)[1] + if not is_covered(cmd_str, top_phrase): findings.append({ "command": cmd_str, "source_file": cmd.get("source_file"), "severity": "high", "reason": f"CLI command '{cmd_str}' not mentioned in CLI reference docs", }) + continue # Subcommand findings would be redundant noise. + for sub in cmd["subcommands"]: + if sub["hidden"]: + continue + sub_str = sub["command"] + sub_phrase = sub_str.split(" ", 1)[1] # e.g. "agent run-cloud" + if not is_covered(sub_str, sub_phrase): + findings.append({ + "command": sub_str, + "source_file": cmd.get("source_file"), + "severity": "medium", + "reason": ( + f"CLI subcommand '{sub_str}' not mentioned in CLI " + "reference docs" + ), + }) return findings @@ -482,40 +928,16 @@ def audit_cli(warp_internal: Path, docs_root: Path, surface_map: dict, # Audit 3: API endpoint coverage # --------------------------------------------------------------------------- -def parse_api_routes(warp_server: Path) -> list[dict]: - """Parse API route definitions from router.go.""" - router_go = warp_server / "router" / "router.go" - if not router_go.exists(): - return [] - - content = router_go.read_text() - routes = [] - - # Match patterns like: - # r.GET("/api/v1/agent/runs", ...) - # r.POST("/api/v1/agent/run", ...) - for match in re.finditer( - r'\.\s*(GET|POST|PUT|DELETE|PATCH)\s*\(\s*"(/[^"]+)"', - content, - ): - method = match.group(1) - path = match.group(2) - # Skip internal/debug endpoints - if "/internal/" in path or "/debug/" in path: - continue - routes.append({ - "method": method, - "path": path, - "route": f"{method} {path}", - }) - - return routes - - def audit_api(warp_server: Path, docs_root: Path, surface_map: dict, docs_text: dict[str, str]) -> list[dict]: - """Audit API endpoint coverage in docs.""" - routes = parse_api_routes(warp_server) + """Audit public API endpoint coverage in the OpenAPI spec and API docs. + + The public docs API reference (docs.warp.dev/api) renders + developers/agent-api-openapi.yaml, so a route missing from the spec is a + docs gap. Use the warp-server `update-open-api-spec` skill / docs + `sync-openapi-spec` skill to fix spec drift rather than hand-editing. + """ + routes = parse_public_api_routes(warp_server) api_to_doc = surface_map.get("api_to_doc", {}) # Read API docs @@ -546,62 +968,109 @@ def audit_api(warp_server: Path, docs_root: Path, surface_map: dict, for route in routes: route_str = route["route"] - # Check surface map - if route_str in api_to_doc: + # Surface-map entries use the path relative to /api/v1 (e.g. + # "POST /agent/run"); also accept the full path for compatibility. + rel_path = route["path"] + if rel_path.startswith("/api/v1"): + rel_path = rel_path[len("/api/v1"):] or "/" + rel_route_str = f"{route['method']} {rel_path}" + if route_str in api_to_doc or rel_route_str in api_to_doc: continue - # Search API docs and OpenAPI spec for the path - path_lower = route["path"].lower() + # Search the OpenAPI spec and API docs for the path found = False - for content in api_docs_text.values(): - if path_lower in content: + for candidate in {route["path"].lower(), rel_path.lower()}: + if candidate in openapi_text: + found = True + break + if any(candidate in content for content in api_docs_text.values()): found = True break - if not found and path_lower in openapi_text: - found = True if not found: - # Determine handler file - handler_file = None - handlers_dir = warp_server / "router" / "handlers" - if handlers_dir.exists(): - # Try to match based on path segments - path_parts = [p for p in route["path"].split("/") if p and p != "api" and p != "v1"] - if path_parts: - for f in handlers_dir.iterdir(): - if f.suffix == ".go" and path_parts[0] in f.name: - handler_file = f"router/handlers/{f.name}" - break - findings.append({ - "route": route_str, - "handler_file": handler_file, + "route": rel_route_str, + "handler_file": route.get("file"), "severity": "medium", - "reason": f"API endpoint '{route_str}' not documented in API reference", + "reason": ( + f"Public API endpoint '{rel_route_str}' is missing from the " + "OpenAPI spec (developers/agent-api-openapi.yaml) — run the " + "sync-openapi-spec skill, or map it as internal in the " + "surface map" + ), }) return findings # --------------------------------------------------------------------------- -# Audit 4: Docs staleness +# Audit 4: Slash command coverage +# --------------------------------------------------------------------------- + +def _slash_mention_re(name: str) -> re.Pattern: + # Boundary-aware: "/new" must not match "issues/new"; require the char + # before "/" to be a non-word char and the name to end at a word boundary. + return re.compile(r"(? list[dict]: + """Audit static slash command coverage in docs.""" + names = parse_slash_commands(warp_internal) + slash_to_doc = surface_map.get("slash_to_doc", {}) + repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent + + findings = [] + for name in names: + if name in slash_to_doc: + doc_path = slash_to_doc[name] + if doc_path == "internal": + continue + if resolve_doc_path(doc_path, repo_root) is not None: + continue + pattern = _slash_mention_re(name) + if any(pattern.search(content) for content in docs_text.values()): + continue + findings.append({ + "command": name, + "severity": "medium", + "reason": ( + f"Slash command '{name}' is not mentioned in any docs page — " + "document it (slash-commands page) or map it as internal" + ), + }) + return findings + +# --------------------------------------------------------------------------- +# Audit 5: Docs staleness # --------------------------------------------------------------------------- def audit_staleness(warp_internal: Path, docs_root: Path, docs_text: dict[str, str], stale_terms_path: Path = STALE_TERMS_PATH) -> list[dict]: - """Check for docs referencing features that no longer exist in code.""" - # Get current feature flags for comparison - current_flags = set(parse_feature_flags(warp_internal)) - current_flags_lower = {f.lower() for f in current_flags} + """Check existing docs for stale terminology. - # Load stale terms from external reference file + Code spans are stripped first (CLI examples like `oz agent run` are + legitimate command syntax, not terminology) and terms match on word + boundaries only. Broader terminology enforcement is owned by the + style_lint skill; this audit only flags terms tied to renamed/removed + features. + """ stale_terms = parse_stale_terms(stale_terms_path) + term_patterns = [ + (term, reason, re.compile(r"\b" + re.escape(term) + r"\b")) + for term, reason in stale_terms + ] findings = [] for doc_path, content in docs_text.items(): + # Historical changelog entries are records of what shipped at the + # time — old feature names there are correct, not stale. + if "/changelog/" in doc_path or doc_path.startswith("changelog/"): + continue + prose = strip_code_spans(content) stale_found = [] - for term, reason in stale_terms: - if term in content: + for term, reason, pattern in term_patterns: + if pattern.search(prose): stale_found.append({"term": term, "reason": reason}) if stale_found: @@ -614,28 +1083,324 @@ def audit_staleness(warp_internal: Path, docs_root: Path, return findings +# --------------------------------------------------------------------------- +# Audit 6: Surface map hygiene +# --------------------------------------------------------------------------- + +def audit_map_hygiene(surface_map: dict, flag_statuses: dict[str, str], + cli_commands: list[dict], api_routes: list[dict], + slash_commands: list[str], docs_root: Path) -> list[dict]: + """Flag surface-map entries that reference code surfaces that no longer exist. + + Dead entries usually mean a feature was renamed or removed — verify the + target doc page is still accurate, then prune or update the entry. + """ + findings = [] + repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent + known_flags = set(flag_statuses) + + for flag in sorted(surface_map.get("feature_to_doc", {})): + if flag not in known_flags: + findings.append({ + "entry": flag, + "section": "Feature flags", + "severity": "low", + "reason": ( + f"Map entry '{flag}' does not match any FeatureFlag in code " + "(flag removed or renamed) — verify the doc page is still " + "accurate, then prune or update the entry" + ), + }) + for flag in sorted(surface_map.get("ignore_flags", set())): + if flag not in known_flags: + findings.append({ + "entry": flag, + "section": "Flags to ignore", + "severity": "low", + "reason": ( + f"Ignore-list entry '{flag}' does not match any FeatureFlag " + "in code — prune it" + ), + }) + + known_cli = set() + for cmd in cli_commands: + known_cli.add(cmd["command"]) + for sub in cmd["subcommands"]: + known_cli.add(sub["command"]) + for cmd in sorted(surface_map.get("cli_to_doc", {})): + if cmd not in known_cli: + findings.append({ + "entry": cmd, + "section": "CLI commands", + "severity": "low", + "reason": ( + f"Map entry '{cmd}' does not match any CLI command in code — " + "verify and prune or update" + ), + }) + + known_slash = set(slash_commands) + for name in sorted(surface_map.get("slash_to_doc", {})): + if name not in known_slash: + findings.append({ + "entry": name, + "section": "Slash commands", + "severity": "low", + "reason": ( + f"Map entry '{name}' does not match any static slash command " + "in code — verify and prune or update" + ), + }) + + # Mapped doc targets that no longer exist (any section). + for section, mapping in ( + ("Feature flags", surface_map.get("feature_to_doc", {})), + ("CLI commands", surface_map.get("cli_to_doc", {})), + ("API endpoints", surface_map.get("api_to_doc", {})), + ("Slash commands", surface_map.get("slash_to_doc", {})), + ): + for key, doc_path in sorted(mapping.items()): + if doc_path == "internal": + continue + if resolve_doc_path(doc_path, repo_root) is None: + findings.append({ + "entry": key, + "section": section, + "severity": "medium", + "reason": ( + f"Mapped doc {doc_path} does not exist — the page was " + "moved or deleted; update the map (and redirects)" + ), + }) + + return findings + +# --------------------------------------------------------------------------- +# Snapshot + change detection +# --------------------------------------------------------------------------- + +def build_snapshot(flag_statuses: dict[str, str], cli_commands: list[dict], + api_routes: list[dict], slash_commands: list[str], + changelog_entries: list[dict]) -> dict: + """Assemble the surface snapshot (deterministic ordering for clean diffs).""" + cli_flat = [] + for cmd in cli_commands: + cli_flat.append({"command": cmd["command"], "hidden": cmd["hidden"]}) + for sub in cmd["subcommands"]: + cli_flat.append({"command": sub["command"], "hidden": sub["hidden"]}) + cli_flat.sort(key=lambda c: c["command"]) + + return { + "schema_version": SNAPSHOT_SCHEMA_VERSION, + "flags": dict(sorted(flag_statuses.items())), + "cli_commands": cli_flat, + "api_routes": sorted(r["route"] for r in api_routes), + "slash_commands": sorted(slash_commands), + "changelog_last_version": ( + changelog_entries[0]["version"] if changelog_entries else None + ), + } + + +def load_snapshot(path: Path) -> dict | None: + if not path.exists(): + return None + try: + return json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + print(f"Warning: failed to parse snapshot {path}: {exc}", file=sys.stderr) + return None + + +def diff_snapshots(old: dict, new: dict) -> list[dict]: + """Compare two snapshots and report added/removed/promoted surfaces.""" + findings = [] + + old_flags = old.get("flags", {}) + new_flags = new.get("flags", {}) + for flag in sorted(set(new_flags) - set(old_flags)): + status = new_flags[flag] + severity = {"ga": "high", "preview": "medium"}.get(status, "low") + findings.append({ + "change": "flag_added", + "surface": flag, + "detail": f"status: {status}", + "severity": severity, + "reason": ( + f"New feature flag '{flag}' ({status}) — " + + ("needs docs and a surface map entry" + if status in ("ga", "preview") + else "track it; no docs needed until promotion") + ), + }) + for flag in sorted(set(old_flags) - set(new_flags)): + findings.append({ + "change": "flag_removed", + "surface": flag, + "detail": f"was: {old_flags[flag]}", + "severity": "medium", + "reason": ( + f"Feature flag '{flag}' was removed — the feature either " + "stabilized (flag cleanup) or was killed. Verify docs cover the " + "final behavior and prune/keep the surface map entry accordingly" + ), + }) + for flag in sorted(set(old_flags) & set(new_flags)): + if old_flags[flag] == new_flags[flag]: + continue + promoted_to_user_facing = new_flags[flag] in ("ga", "preview") + findings.append({ + "change": "flag_status_changed", + "surface": flag, + "detail": f"{old_flags[flag]} -> {new_flags[flag]}", + "severity": "high" if new_flags[flag] == "ga" else ( + "medium" if promoted_to_user_facing else "low"), + "reason": ( + f"Feature flag '{flag}' moved {old_flags[flag]} -> {new_flags[flag]}" + + (" — verify docs exist and the surface map is updated" + if promoted_to_user_facing else "") + ), + }) + + old_cli = {c["command"] for c in old.get("cli_commands", []) if not c.get("hidden")} + new_cli = {c["command"] for c in new.get("cli_commands", []) if not c.get("hidden")} + for cmd in sorted(new_cli - old_cli): + findings.append({ + "change": "cli_added", + "surface": cmd, + "severity": "medium", + "reason": f"New CLI command '{cmd}' — document it in the CLI reference", + }) + for cmd in sorted(old_cli - new_cli): + findings.append({ + "change": "cli_removed", + "surface": cmd, + "severity": "medium", + "reason": ( + f"CLI command '{cmd}' was removed or hidden — update the CLI " + "reference docs and surface map" + ), + }) + + old_api = set(old.get("api_routes", [])) + new_api = set(new.get("api_routes", [])) + for route in sorted(new_api - old_api): + findings.append({ + "change": "api_added", + "surface": route, + "severity": "medium", + "reason": ( + f"New public API route '{route}' — add it to the OpenAPI spec " + "(sync-openapi-spec skill) or map it as internal" + ), + }) + for route in sorted(old_api - new_api): + findings.append({ + "change": "api_removed", + "surface": route, + "severity": "medium", + "reason": ( + f"Public API route '{route}' was removed — verify the OpenAPI " + "spec and API docs no longer document it" + ), + }) + + old_slash = set(old.get("slash_commands", [])) + new_slash = set(new.get("slash_commands", [])) + for name in sorted(new_slash - old_slash): + findings.append({ + "change": "slash_added", + "surface": name, + "severity": "medium", + "reason": ( + f"New slash command '{name}' — add it to the slash-commands docs " + "page or map it as internal" + ), + }) + for name in sorted(old_slash - new_slash): + findings.append({ + "change": "slash_removed", + "surface": name, + "severity": "medium", + "reason": ( + f"Slash command '{name}' was removed — update the slash-commands " + "docs page and surface map" + ), + }) + + return findings + + +def changelog_review_findings(changelog_entries: list[dict], + last_seen_version: str | None) -> list[dict]: + """Emit verification findings for changelog entries newer than the snapshot. + + The weekly human-curated changelog is the best signal for launches that no + static code parse can see (server-side features, Oz web app, experiment + rollouts). Each bullet should be verified for real docs coverage — a + changelog mention alone is not documentation. + """ + findings = [] + for entry in changelog_entries: + if last_seen_version and entry["version"] <= last_seen_version: + continue + for item in entry["items"]: + findings.append({ + "version": entry["version"], + "category": item["category"], + "text": item["text"], + "severity": "low", + "reason": ( + "New changelog item since last audit — verify the feature " + "has real docs coverage (not just the changelog mention)" + ), + }) + return findings + # --------------------------------------------------------------------------- # Report generation # --------------------------------------------------------------------------- -def generate_report(features: list, cli: list, api: list, staleness: list) -> dict: +REPORT_CATEGORIES = [ + ("undocumented_features", "UNDOCUMENTED FEATURES", + lambda i: i.get("flag", "")), + ("undocumented_cli_commands", "UNDOCUMENTED CLI COMMANDS", + lambda i: i.get("command", "")), + ("undocumented_api_endpoints", "UNDOCUMENTED API ENDPOINTS", + lambda i: i.get("route", "")), + ("undocumented_slash_commands", "UNDOCUMENTED SLASH COMMANDS", + lambda i: i.get("command", "")), + ("surface_changes", "SURFACE CHANGES SINCE SNAPSHOT", + lambda i: f"{i.get('change', '')}: {i.get('surface', '')}"), + ("changelog_review", "CHANGELOG ITEMS TO VERIFY", + lambda i: f"{i.get('version', '')} [{i.get('category', '')}] {i.get('text', '')[:100]}"), + ("map_hygiene", "SURFACE MAP HYGIENE", + lambda i: f"{i.get('section', '')}: {i.get('entry', '')}"), + ("potentially_stale_docs", "POTENTIALLY STALE DOCS", + lambda i: i.get("doc_path", "")), +] + + +def generate_report(findings_by_category: dict[str, list], audits_run: list[str], + audits_skipped: list[dict], mode: str) -> dict: """Assemble the full audit report.""" - total = len(features) + len(cli) + len(api) + len(staleness) - return { + total = sum(len(v) for v in findings_by_category.values()) + report = { "summary": { + "mode": mode, "total_gaps": total, + "audits_run": audits_run, + "audits_skipped": audits_skipped, "by_category": { - "undocumented_features": len(features), - "undocumented_cli_commands": len(cli), - "undocumented_api_endpoints": len(api), - "potentially_stale_docs": len(staleness), + key: len(findings_by_category.get(key, [])) + for key, _, _ in REPORT_CATEGORIES }, }, - "undocumented_features": features, - "undocumented_cli_commands": cli, - "undocumented_api_endpoints": api, - "potentially_stale_docs": staleness, } + for key, _, _ in REPORT_CATEGORIES: + report[key] = findings_by_category.get(key, []) + return report def print_report(report: dict) -> None: @@ -644,63 +1409,48 @@ def print_report(report: dict) -> None: print("=" * 60) print("MISSING DOCS AUDIT REPORT") print("=" * 60) + print(f"Mode: {summary['mode']}") + print(f"Audits run: {', '.join(summary['audits_run']) or 'none'}") + if summary["audits_skipped"]: + print("!! AUDITS SKIPPED (results are incomplete):") + for skipped in summary["audits_skipped"]: + print(f" - {skipped['audit']}: {skipped['reason']}") print(f"Total gaps found: {summary['total_gaps']}") for category, count in summary["by_category"].items(): - print(f" {category}: {count}") + if count: + print(f" {category}: {count}") print() severity_order = {"high": 0, "medium": 1, "low": 2} - if report["undocumented_features"]: + for key, title, describe in REPORT_CATEGORIES: + items = report.get(key, []) + if not items: + continue print("-" * 60) - print(f"UNDOCUMENTED FEATURES ({len(report['undocumented_features'])})") + print(f"{title} ({len(items)})") print("-" * 60) - items = sorted(report["undocumented_features"], - key=lambda x: severity_order.get(x.get("severity", "low"), 3)) - for item in items: + for item in sorted(items, key=lambda x: severity_order.get(x.get("severity", "low"), 3)): sev = item.get("severity", "?").upper() - print(f"\n [{sev}] {item['flag']}") - print(f" Reason: {item['reason']}") + print(f"\n [{sev}] {describe(item)}") + if item.get("reason"): + print(f" Reason: {item['reason']}") if item.get("suggested_doc_path"): print(f" Suggested: {item['suggested_doc_path']}") - print(f" Search terms: {', '.join(item['search_terms'][:3])}") - - if report["undocumented_cli_commands"]: - print() - print("-" * 60) - print(f"UNDOCUMENTED CLI COMMANDS ({len(report['undocumented_cli_commands'])})") - print("-" * 60) - for item in report["undocumented_cli_commands"]: - sev = item.get("severity", "?").upper() - print(f"\n [{sev}] {item['command']}") - print(f" Reason: {item['reason']}") + if item.get("matched_docs"): + print(f" Mentioned in: {', '.join(item['matched_docs'])}") + if item.get("search_terms"): + print(f" Search terms: {', '.join(item['search_terms'][:3])}") if item.get("source_file"): print(f" Source: {item['source_file']}") - - if report["undocumented_api_endpoints"]: - print() - print("-" * 60) - print(f"UNDOCUMENTED API ENDPOINTS ({len(report['undocumented_api_endpoints'])})") - print("-" * 60) - for item in report["undocumented_api_endpoints"]: - sev = item.get("severity", "?").upper() - print(f"\n [{sev}] {item['route']}") - print(f" Reason: {item['reason']}") if item.get("handler_file"): print(f" Handler: {item['handler_file']}") - - if report["potentially_stale_docs"]: - print() - print("-" * 60) - print(f"POTENTIALLY STALE DOCS ({len(report['potentially_stale_docs'])})") - print("-" * 60) - for item in report["potentially_stale_docs"]: - sev = item.get("severity", "?").upper() - print(f"\n [{sev}] {item['doc_path']}") + if item.get("detail"): + print(f" Detail: {item['detail']}") for t in item.get("stale_terms", []): print(f" - \"{t['term']}\": {t['reason']}") + print() - print() print("=" * 60) # --------------------------------------------------------------------------- @@ -713,11 +1463,11 @@ def main(): ) parser.add_argument( "--warp-internal", - help="Path to warp-internal repo (auto-detected if not provided)", + help="Path to warp-internal repo (auto-detected as a sibling of the docs repo)", ) parser.add_argument( "--warp-server", - help="Path to warp-server repo (auto-detected if not provided)", + help="Path to warp-server repo (auto-detected as a sibling of the docs repo)", ) parser.add_argument( "--output", "-o", @@ -725,7 +1475,7 @@ def main(): ) parser.add_argument( "--category", - choices=["features", "cli", "api", "staleness"], + choices=["features", "cli", "api", "slash", "staleness", "map"], help="Run only a specific audit category", ) parser.add_argument( @@ -739,9 +1489,31 @@ def main(): help="Also flag features whose mapped doc exists but doesn't mention " "feature keywords (noisy; produces low-severity findings)", ) + parser.add_argument( + "--diff", + action="store_true", + help="Compare current surfaces against the committed snapshot and report " + "added/removed/promoted surfaces plus new changelog items", + ) + parser.add_argument( + "--update-snapshot", + action="store_true", + help="Regenerate the surface snapshot from current code (commit it with " + "the docs PR). Requires a full run (no --category)", + ) + parser.add_argument( + "--snapshot", + default=str(DEFAULT_SNAPSHOT_PATH), + help=f"Path to the surface snapshot (default: {DEFAULT_SNAPSHOT_PATH})", + ) args = parser.parse_args() - # Find repos + if args.update_snapshot and args.category: + print("Error: --update-snapshot requires a full run (drop --category)", + file=sys.stderr) + sys.exit(1) + + # Find repos. # SKILL_DIR is at /.agents/skills/missing_docs (or legacy /.warp/skills/...) repo_root = SKILL_DIR.parent.parent.parent # Astro Starlight docs live at src/content/docs @@ -757,8 +1529,8 @@ def main(): # repo_root carries the developers/ openapi spec etc. DOCS_REPO_ROOT[0] = repo_root - warp_internal = find_repo("warp-internal", args.warp_internal, docs_root) - warp_server = find_repo("warp-server", args.warp_server, docs_root) + warp_internal = find_repo("warp-internal", args.warp_internal, repo_root) + warp_server = find_repo("warp-server", args.warp_server, repo_root) # Parse surface map surface_map = parse_surface_map(SURFACE_MAP_PATH) @@ -768,57 +1540,137 @@ def main(): docs_text = read_all_docs_text(docs_root) print(f" Found {len(docs_text)} markdown files", file=sys.stderr) - # Run audits - features_findings = [] - cli_findings = [] - api_findings = [] - staleness_findings = [] + findings: dict[str, list] = {} + audits_run: list[str] = [] + audits_skipped: list[dict] = [] + + needs_internal = args.category in (None, "features", "cli", "slash", "staleness", "map") \ + or args.diff or args.update_snapshot + needs_server = args.category in (None, "api", "map") \ + or args.diff or args.update_snapshot - if warp_internal: + flag_statuses: dict[str, str] = {} + cli_commands: list[dict] = [] + slash_commands: list[str] = [] + api_routes: list[dict] = [] + + if warp_internal and needs_internal: print(f"Using warp-internal: {warp_internal}", file=sys.stderr) + flag_statuses = compute_flag_statuses(warp_internal) + cli_commands = parse_cli_commands(warp_internal) + slash_commands = parse_slash_commands(warp_internal) + if args.category in (None, "features"): print("Running feature flag coverage audit...", file=sys.stderr) - features_findings = audit_features( + findings["undocumented_features"] = audit_features( warp_internal, docs_root, surface_map, docs_text, - weak_coverage=args.weak_coverage, + flag_statuses=flag_statuses, weak_coverage=args.weak_coverage, ) + audits_run.append("features") if args.category in (None, "cli"): print("Running CLI command coverage audit...", file=sys.stderr) - cli_findings = audit_cli(warp_internal, docs_root, surface_map, docs_text) + findings["undocumented_cli_commands"] = audit_cli( + warp_internal, docs_root, surface_map, docs_text) + audits_run.append("cli") + + if args.category in (None, "slash"): + print("Running slash command coverage audit...", file=sys.stderr) + findings["undocumented_slash_commands"] = audit_slash_commands( + warp_internal, docs_root, surface_map, docs_text) + audits_run.append("slash") if args.category in (None, "staleness"): print("Running docs staleness audit...", file=sys.stderr) - staleness_findings = audit_staleness(warp_internal, docs_root, docs_text) - else: - print("Warning: warp-internal not found, skipping feature/CLI/staleness audits", - file=sys.stderr) + findings["potentially_stale_docs"] = audit_staleness( + warp_internal, docs_root, docs_text) + audits_run.append("staleness") + elif needs_internal: + for audit in ("features", "cli", "slash", "staleness"): + if args.category in (None, audit): + audits_skipped.append({ + "audit": audit, + "reason": "warp-internal repo not found (pass --warp-internal)", + }) - if warp_server: + if warp_server and needs_server: print(f"Using warp-server: {warp_server}", file=sys.stderr) + api_routes = parse_public_api_routes(warp_server) if args.category in (None, "api"): print("Running API endpoint coverage audit...", file=sys.stderr) - api_findings = audit_api(warp_server, docs_root, surface_map, docs_text) - else: - print("Warning: warp-server not found, skipping API audit", file=sys.stderr) + findings["undocumented_api_endpoints"] = audit_api( + warp_server, docs_root, surface_map, docs_text) + audits_run.append("api") + elif needs_server: + if args.category in (None, "api"): + audits_skipped.append({ + "audit": "api", + "reason": "warp-server repo not found (pass --warp-server)", + }) + + if args.category in (None, "map"): + if warp_internal and warp_server: + print("Running surface map hygiene audit...", file=sys.stderr) + findings["map_hygiene"] = audit_map_hygiene( + surface_map, flag_statuses, cli_commands, api_routes, + slash_commands, docs_root) + audits_run.append("map") + else: + audits_skipped.append({ + "audit": "map", + "reason": "requires both warp-internal and warp-server", + }) + + # Change detection (diff + snapshot update) + changelog_entries = parse_changelog_entries(repo_root) + snapshot_path = Path(args.snapshot) + if args.diff or args.update_snapshot: + if warp_internal and warp_server: + current_snapshot = build_snapshot( + flag_statuses, cli_commands, api_routes, slash_commands, + changelog_entries) + if args.diff: + previous = load_snapshot(snapshot_path) + if previous is None: + audits_skipped.append({ + "audit": "diff", + "reason": ( + f"snapshot {snapshot_path} not found or unreadable — " + "run --update-snapshot first" + ), + }) + else: + print("Running surface change detection (diff)...", file=sys.stderr) + findings["surface_changes"] = diff_snapshots(previous, current_snapshot) + findings["changelog_review"] = changelog_review_findings( + changelog_entries, previous.get("changelog_last_version")) + audits_run.append("diff") + if args.update_snapshot: + snapshot_path.parent.mkdir(parents=True, exist_ok=True) + snapshot_path.write_text( + json.dumps(current_snapshot, indent=2, sort_keys=False) + "\n", + encoding="utf-8", + ) + print(f"Snapshot updated: {snapshot_path}", file=sys.stderr) + else: + audits_skipped.append({ + "audit": "diff" if args.diff else "update-snapshot", + "reason": "requires both warp-internal and warp-server", + }) # Filter by severity if args.severity: severity_order = {"high": 0, "medium": 1, "low": 2} min_severity = severity_order[args.severity] - features_findings = [f for f in features_findings - if severity_order.get(f.get("severity"), 3) <= min_severity] - cli_findings = [f for f in cli_findings - if severity_order.get(f.get("severity"), 3) <= min_severity] - api_findings = [f for f in api_findings - if severity_order.get(f.get("severity"), 3) <= min_severity] - staleness_findings = [f for f in staleness_findings - if severity_order.get(f.get("severity"), 3) <= min_severity] - - # Generate report - report = generate_report(features_findings, cli_findings, api_findings, staleness_findings) - - # Output + for key in list(findings): + findings[key] = [ + f for f in findings[key] + if severity_order.get(f.get("severity"), 3) <= min_severity + ] + + mode = "diff" if args.diff else "audit" + report = generate_report(findings, audits_run, audits_skipped, mode) + print_report(report) if args.output: @@ -826,6 +1678,14 @@ def main(): output_path.write_text(json.dumps(report, indent=2)) print(f"\nJSON report saved to {output_path}", file=sys.stderr) + if audits_skipped: + print( + "Error: one or more audits were skipped — this run is INCOMPLETE " + "and must not be treated as a clean audit.", + file=sys.stderr, + ) + sys.exit(2) + if __name__ == "__main__": main() From 9e35135694d9ff1752ec1c9f23f120c6937d7cfd Mon Sep 17 00:00:00 2001 From: hongyi-chen Date: Thu, 11 Jun 2026 06:22:58 +0000 Subject: [PATCH 2/4] Expand missing_docs audit: settings, web app, tools, skills, structure, stale refs - Settings audit: parse the define_setting! toml_path registry (~200 settings, flag-status aware, object-typed settings handled) and check coverage in the all-settings reference; reverse check catches documented settings that were renamed/removed in code (e.g. agents.oz.* -> agents.warp_agent.*) - Stale doc references: validate documented keybinding actions (scope:action) still exist anywhere in warp-internal source - Docs structure audit: flag pages missing from src/sidebar.ts (with an allowlist section in the surface map) - CLI: recursive subcommand parsing (oz run message send, oz environment image list, ...) plus per-module --flag tracking in the snapshot - API: positional RouterGroup argument resolution at Register* call sites (fixes oauth route prefixes) and param-name-insensitive OpenAPI matching - Snapshot v2: settings, web app routes (AgentsApp.tsx), server-side agent tools (ToolName consts + Create*NativeTool registrations), bundled + channel-gated skills, CLI flags; graceful one-time note when diffing against a v1 snapshot - Changelog cross-check now also tracks 'Oz updates' bullets - Extraction sanity guards: implausibly low parse counts (broken parser after a code-layout change) skip dependent audits and exit 2 instead of silently under-reporting; map hygiene and reverse checks gated on healthy extraction - Feature flag enum parsing is brace-safe (survives future struct variants) - SKILL.md documents the 9 coverage audits, snapshot-only surfaces, and adjacent-skill ownership (validate_ui_refs, sync-error-docs, style_lint, weekly-404-monitor) Co-Authored-By: Oz --- .agents/skills/missing_docs/SKILL.md | 106 +- .../references/feature_surface_map.md | 17 +- .../references/surface_snapshot.json | 482 +++++- .../skills/missing_docs/scripts/audit_docs.py | 1298 ++++++++++++++--- 4 files changed, 1673 insertions(+), 230 deletions(-) diff --git a/.agents/skills/missing_docs/SKILL.md b/.agents/skills/missing_docs/SKILL.md index fb6246aa..3fe1106a 100644 --- a/.agents/skills/missing_docs/SKILL.md +++ b/.agents/skills/missing_docs/SKILL.md @@ -23,10 +23,12 @@ The audit compares docs against code, so both source repos must be available: `/workspace/warp-server`), or passed explicitly via `--warp-internal PATH` / `--warp-server PATH`. -The script FAILS LOUD when a repo is missing: it exits with code 2 and lists the -skipped audits in the report's `audits_skipped` field. Never treat an exit-2 run -as a clean audit — fix the repo paths and re-run. Exit 0 means all requested -audits ran (findings may still exist). +The script FAILS LOUD when a repo is missing OR when an extraction sanity guard +trips (a parser returning implausibly few surfaces means the source layout +changed and the parser needs fixing): it exits with code 2 and lists the skipped +audits in the report's `audits_skipped` field (`extraction:*` entries identify +broken parsers). Never treat an exit-2 run as a clean audit — fix the problem +and re-run. Exit 0 means all requested audits ran (findings may still exist). ## Workflows @@ -39,7 +41,7 @@ python3 .agents/skills/missing_docs/scripts/audit_docs.py ``` Options: -- `--category features|cli|api|slash|staleness|map` — run a single audit category +- `--category features|cli|api|slash|settings|structure|staleness|map` — run a single audit category - `--severity high|medium|low` — filter by minimum severity - `--weak-coverage` — also flag GA features whose mapped doc exists but doesn't mention feature keywords (low-severity, noisy) - `--output report.json` — save JSON report to file @@ -51,41 +53,71 @@ The script resolves doc paths from the docs repo root and accepts `.md` and `.md interchangeably (and `README.md` ↔ `index.mdx`), so surface-map entries can use the canonical filename even when the on-disk extension differs. -The script performs 6 coverage audits: +The script performs these coverage audits: 1. **Feature flag coverage** — classifies every `FeatureFlag` by rollout status using the cargo-feature→flag bridge in warp-internal `app/src/features.rs` plus `RELEASE_FLAGS`/`PREVIEW_FLAGS`/`DOGFOOD_FLAGS` in `crates/warp_features/src/lib.rs`. GA flags must be mapped in the surface map or covered in docs; Preview flags produce low-severity "docs needed soon" findings; dogfood/other flags are tracked by the snapshot only. -2. **CLI command coverage** — parses the full `oz` command tree (top-level commands and - subcommands, skipping `hide = true`) from `crates/warp_cli/src/` and checks the CLI - reference docs. +2. **CLI command coverage** — parses the full `oz` command tree from + `crates/warp_cli/src/` (recursive subcommands like `oz run message send`, skipping + `hide = true`) and checks the CLI reference docs. Per-module `--long` flags are + additionally tracked in the snapshot for change detection. 3. **API endpoint coverage** — extracts public routes from warp-server - `router/handlers/public_api/*.go` (nested gin groups resolved) and checks them - against `developers/agent-api-openapi.yaml` and the API reference docs. For spec - drift, run the docs `sync-openapi-spec` skill (or warp-server's - `update-open-api-spec`) instead of hand-editing the YAML. + `router/handlers/public_api/*.go` (nested gin groups resolved, caller-passed group + prefixes matched positionally) and checks them against + `developers/agent-api-openapi.yaml` (param-name-insensitive: `{runId}` matches + `{run_id}`) and the API reference docs. For spec drift, run the docs + `sync-openapi-spec` skill (or warp-server's `update-open-api-spec`) instead of + hand-editing the YAML. 4. **Slash command coverage** — parses the static registry in warp-internal `app/src/search/slash_command_menu/static_commands/` and checks each `/command` is mentioned in docs. -5. **Docs staleness** — flags renamed/removed-feature terminology in prose (code +5. **Settings coverage** — parses every `toml_path: "section.key"` setting + registration in warp-internal (the same registry the JSON-schema generator uses) + and checks the all-settings reference page documents it. Private and + dogfood/other-flagged settings are exempt; object-typed settings documented as + their own `[section]` count as covered. +6. **Docs staleness** — flags renamed/removed-feature terminology in prose (code spans stripped; historical changelog pages excluded). Broader terminology and style enforcement is owned by the `style_lint` skill — delegate pure wording issues there. -6. **Surface map hygiene** — flags map entries whose flag/command no longer exists in - code, and mapped doc targets that no longer exist. Verify the doc page is still - accurate, then prune or update the entry. +7. **Stale doc references** — reverse checks: settings keys documented in + all-settings.mdx that no longer exist in code (catches renames like + `agents.oz.*` → `agents.warp_agent.*`), and keybinding actions (`scope:action`) + on the keyboard-shortcuts page that no longer exist anywhere in warp-internal. +8. **Docs structure** — pages on disk that are missing from `src/sidebar.ts` + (built but unreachable through navigation). Intentionally unlisted pages go in + the surface map's "Unlisted docs pages" section. +9. **Surface map hygiene** — flags map entries whose flag/command/route/setting no + longer exists in code, and mapped doc targets that no longer exist. Verify the + doc page is still accurate, then prune or update the entry. + +Snapshot-only surfaces (no standing coverage audit, but added/removed/changed items +are reported by `--diff`): Oz web app routes (`AgentsApp.tsx`), server-side agent +tools (multi_agent tool registries), bundled + channel-gated skills +(`resources/bundled/skills`, `resources/channel-gated-skills`), and per-module CLI +flags. Present the report to the user, grouped by category and sorted by severity. +Adjacent checks owned by other skills (do not duplicate them here): +- UI menu paths and Command Palette names → `validate_ui_refs` +- Platform error-code pages → `sync-error-docs` +- Broken links and 404s/redirects → `check_for_broken_links` / `weekly-404-monitor` +- Terminology/style sweeps → `style_lint` + ### Phase 2: Change detection (diff mode) The snapshot at `references/surface_snapshot.json` records all extracted surfaces -(flags + rollout status, CLI commands, API routes, slash commands) plus the last-seen -docs-changelog version. It makes change detection possible: a feature flag that is -deleted after stabilizing (per warp-internal's remove-feature-flag policy) would -otherwise vanish from the audit's universe silently. +(flags + rollout status, CLI commands and per-module flags, API routes, slash +commands, settings + status, Oz web app routes, server-side agent tools, bundled +skills) plus the last-seen docs-changelog version. It makes change detection +possible: a feature flag that is deleted after stabilizing (per warp-internal's +remove-feature-flag policy) would otherwise vanish from the audit's universe +silently. When a new surface type is introduced, diffing against an older snapshot +emits a one-time "surface type newly tracked" note instead of false positives. ```bash python3 .agents/skills/missing_docs/scripts/audit_docs.py --diff @@ -94,12 +126,15 @@ python3 .agents/skills/missing_docs/scripts/audit_docs.py --diff Diff mode reports, since the snapshot was last updated: - **Added / removed / promoted surfaces** — e.g. a new GA flag (high), a flag promoted dogfood→ga (high), a removed flag ("feature stabilized or killed — verify docs and - map entry"), new CLI/API/slash surfaces. -- **Changelog items to verify** — "New features" and "Improvements" bullets from - `src/content/docs/changelog/.mdx` entries newer than the snapshot's last-seen - version. This is the best signal for launches no static code parse can see - (server-side features, Oz web app, experiment rollouts). A changelog mention is NOT - documentation — verify each item has real doc coverage. + map entry"), new/removed CLI commands and `--flags`, API routes, slash commands, + settings (with status promotions), Oz web app routes, server-side agent tools, and + bundled skills. +- **Changelog items to verify** — "New features", "Improvements", and "Oz updates" + bullets from `src/content/docs/changelog/.mdx` entries newer than the + snapshot's last-seen version. This is the best signal for launches no static code + parse can see (server-side features, Oz web app, experiment rollouts). A changelog + mention is NOT documentation — verify each item has real doc coverage. ("Bug fixes" + bullets are deliberately untracked to keep weekly triage volume manageable.) After triaging and addressing diff findings, refresh the snapshot and commit it with your PR so the next run diffs against the new baseline: @@ -150,10 +185,12 @@ with the product. Each run: --diff --output /tmp/docs_audit.json ``` 2. **Triage**: work through `surface_changes` and `changelog_review` first (what - changed since last run), then standing coverage findings (high → medium → low). - For each item decide: draft/update a doc page, update the OpenAPI spec via - `sync-openapi-spec`, add a surface-map entry (documented elsewhere), or add an - ignore/`internal` entry with a comment (internal-only). + changed since last run), then standing coverage findings (high → medium → low) + across all categories: features, CLI, API, slash commands, settings, stale doc + references, unlisted pages, map hygiene, staleness. For each item decide: + draft/update a doc page, update the OpenAPI spec via `sync-openapi-spec`, add a + surface-map entry (documented elsewhere), or add an ignore/`internal`/allowlist + entry with a comment (internal-only or intentionally unlisted). 3. **Draft**: follow Phase 3 for every item that needs docs. 4. **Update references**: apply surface-map edits, then regenerate the snapshot: ```bash @@ -197,9 +234,10 @@ The user can trigger any subset: ## References - `references/feature_surface_map.md` — curated mapping of flags/commands/routes/slash - commands to doc pages, ignore list for internal flags, and the `internal` sentinel - for surfaces that intentionally have no public docs. Update it with every docs PR - that ships a feature. + commands/settings to doc pages, ignore list for internal flags, allowlist for + intentionally unlisted pages, and the `internal` sentinel for surfaces that + intentionally have no public docs. Update it with every docs PR that ships a + feature. - `references/surface_snapshot.json` — generated snapshot of all code surfaces used by `--diff`. Regenerate with `--update-snapshot`; never hand-edit. - `references/stale_terms.md` — renamed/removed-feature terms to flag during staleness diff --git a/.agents/skills/missing_docs/references/feature_surface_map.md b/.agents/skills/missing_docs/references/feature_surface_map.md index e0aa3ba1..1414fcf9 100644 --- a/.agents/skills/missing_docs/references/feature_surface_map.md +++ b/.agents/skills/missing_docs/references/feature_surface_map.md @@ -1,7 +1,8 @@ # Feature Surface Map -Curated mapping of feature flags, CLI commands, API endpoints, and slash commands -to their expected documentation pages. +Curated mapping of feature flags, CLI commands, API endpoints, slash commands, +and settings to their expected documentation pages, plus an allowlist for +intentionally unlisted docs pages. The audit script reads this file to reduce false positives — entries here are verified rather than flagged. @@ -221,6 +222,18 @@ POST /harness-support/upload-snapshot -> internal # Gated by the dogfood-only LocalDockerSandbox flag — not user-facing yet. /docker-sandbox -> internal +## Settings -> doc pages + +# Settings are matched automatically against the all-settings reference +# (terminal/settings/all-settings.mdx) by section + key; add entries here only +# for exceptions: settings documented on another page (`section.key -> path`) +# or intentionally undocumented (`section.key -> internal`). + +## Unlisted docs pages to ignore + +# Pages intentionally absent from src/sidebar.ts (one slug per line, e.g. +# `guides/some-page`). Everything else on disk must be reachable via the sidebar. + ## Flags to ignore (internal-only, not user-facing) # These flags are internal implementation details and don't need documentation diff --git a/.agents/skills/missing_docs/references/surface_snapshot.json b/.agents/skills/missing_docs/references/surface_snapshot.json index 1dcccdc4..71d9abb3 100644 --- a/.agents/skills/missing_docs/references/surface_snapshot.json +++ b/.agents/skills/missing_docs/references/surface_snapshot.json @@ -1,5 +1,5 @@ { - "schema_version": 1, + "schema_version": 2, "flags": { "AIBlockOverflowMenu": "other", "AIContextMenuCode": "ga", @@ -304,6 +304,10 @@ "command": "oz agent profile", "hidden": false }, + { + "command": "oz agent profile list", + "hidden": false + }, { "command": "oz agent run", "hidden": false @@ -372,6 +376,10 @@ "command": "oz environment image", "hidden": false }, + { + "command": "oz environment image list", + "hidden": false + }, { "command": "oz environment list", "hidden": false @@ -476,6 +484,10 @@ "command": "oz run conversation", "hidden": false }, + { + "command": "oz run conversation get", + "hidden": false + }, { "command": "oz run get", "hidden": false @@ -488,6 +500,26 @@ "command": "oz run message", "hidden": false }, + { + "command": "oz run message list", + "hidden": false + }, + { + "command": "oz run message mark-delivered", + "hidden": false + }, + { + "command": "oz run message read", + "hidden": false + }, + { + "command": "oz run message send", + "hidden": false + }, + { + "command": "oz run message watch", + "hidden": false + }, { "command": "oz schedule", "hidden": false @@ -545,6 +577,136 @@ "hidden": false } ], + "cli_flags": { + "agent": [ + "--add-secret", + "--add-skill", + "--agent", + "--attach", + "--base-model", + "--computer-use", + "--conversation", + "--cwd", + "--description", + "--environment", + "--host", + "--mcp", + "--mcp-startup-timeout", + "--name", + "--no-computer-use", + "--no-snapshot", + "--open", + "--profile", + "--prompt", + "--remove-all-secrets", + "--remove-all-skills", + "--remove-base-model", + "--remove-description", + "--remove-environment", + "--remove-secret", + "--remove-skill", + "--repo", + "--saved-prompt", + "--secret", + "--skill", + "--snapshot-script-timeout", + "--snapshot-upload-timeout", + "--sort-by", + "--sort-order", + "--strict-mcp-startup" + ], + "api_key": [ + "--agent", + "--expires-at", + "--expires-in", + "--no-expiration", + "--sort-by", + "--sort-order" + ], + "artifact": [ + "--conversation-id", + "--description", + "--out", + "--run-id" + ], + "environment": [ + "--description", + "--docker-image", + "--environment", + "--name", + "--no-environment", + "--remove-description", + "--remove-environment", + "--repo", + "--setup-command" + ], + "federate": [ + "--audience", + "--duration", + "--run-id", + "--subject-template" + ], + "integration": [ + "--host", + "--mcp", + "--prompt", + "--remove-mcp" + ], + "model": [ + "--model" + ], + "schedule": [ + "--cron", + "--host", + "--mcp", + "--name", + "--prompt", + "--remove-mcp", + "--remove-skill", + "--skill" + ], + "secret": [ + "--access-key-id", + "--base-url", + "--bedrock-api-key", + "--description", + "--region", + "--secret-access-key", + "--session-token", + "--type", + "--value", + "--value-file" + ], + "task": [ + "--ancestor-run", + "--artifact-type", + "--body", + "--conversation", + "--created-after", + "--created-before", + "--creator", + "--cursor", + "--environment", + "--execution-location", + "--limit", + "--model", + "--name", + "--query", + "--schedule", + "--sender-run-id", + "--since", + "--since-sequence", + "--skill", + "--sort-by", + "--sort-order", + "--source", + "--state", + "--subject", + "--to", + "--unread", + "--updated-after" + ] + }, "api_routes": [ "DELETE /api/v1/agent/identities/{uid}", "DELETE /api/v1/agent/schedules/{id}", @@ -579,8 +741,8 @@ "GET /api/v1/memory_stores/{uid}/agents", "GET /api/v1/memory_stores/{uid}/memories", "GET /api/v1/memory_stores/{uid}/memories/{memoryUid}/versions", - "GET /oauth/authorize", - "GET /oauth/jwks.json", + "GET /api/v1/oauth/authorize", + "GET /api/v1/oauth/jwks.json", "PATCH /api/v1/agent/runs/{runId}/event-sequence", "POST /api/v1/agent/events/{run_id}", "POST /api/v1/agent/handoff/upload-snapshot", @@ -610,9 +772,9 @@ "POST /api/v1/harness-support/upload-snapshot", "POST /api/v1/memory_stores", "POST /api/v1/memory_stores/{uid}/memories", - "POST /oauth/device/auth", - "POST /oauth/session", - "POST /oauth/token", + "POST /api/v1/oauth/device/auth", + "POST /api/v1/oauth/session", + "POST /api/v1/oauth/token", "PUT /api/v1/agent/identities/{uid}", "PUT /api/v1/agent/schedules/{id}", "PUT /api/v1/memory_stores/{uid}", @@ -666,5 +828,313 @@ "/skills", "/usage" ], + "settings": { + "accessibility.accessibility_verbosity": "always_on", + "account.is_settings_sync_enabled": "always_on", + "agents.cloud_conversation_storage_enabled": "always_on", + "agents.knowledge.rules_enabled": "always_on", + "agents.knowledge.warp_drive_context_enabled": "always_on", + "agents.mcp_servers.file_based_mcp_enabled": "always_on", + "agents.profiles.agent_mode_coding_file_read_allowlist": "always_on", + "agents.profiles.agent_mode_coding_permissions": "always_on", + "agents.profiles.agent_mode_command_execution_allowlist": "always_on", + "agents.profiles.agent_mode_command_execution_denylist": "always_on", + "agents.profiles.agent_mode_execute_readonly_commands": "always_on", + "agents.third_party.auto_dismiss_composer_after_submit": "always_on", + "agents.third_party.auto_open_composer_on_cli_agent_start": "always_on", + "agents.third_party.auto_toggle_composer": "always_on", + "agents.third_party.cli_agent_toolbar_chip_selection_setting": "always_on", + "agents.third_party.cli_agent_toolbar_enabled_commands": "always_on", + "agents.third_party.should_render_cli_agent_toolbar": "always_on", + "agents.third_party.submit_on_ctrl_enter": "always_on", + "agents.voice.voice_input_enabled": "always_on", + "agents.voice.voice_input_toggle_key": "always_on", + "agents.warp_agent.active_ai.agent_mode_query_suggestions_enabled": "always_on", + "agents.warp_agent.active_ai.code_suggestions_enabled": "always_on", + "agents.warp_agent.active_ai.enabled": "always_on", + "agents.warp_agent.active_ai.git_operations_autogen_enabled": "always_on", + "agents.warp_agent.active_ai.intelligent_autosuggestions_enabled": "always_on", + "agents.warp_agent.active_ai.natural_language_autosuggestions_enabled": "other", + "agents.warp_agent.active_ai.rule_suggestions_enabled": "ga", + "agents.warp_agent.active_ai.shared_block_title_generation_enabled": "always_on", + "agents.warp_agent.input.agent_toolbar_chip_selection_setting": "always_on", + "agents.warp_agent.input.ai_auto_detection_enabled": "always_on", + "agents.warp_agent.input.ai_command_denylist": "always_on", + "agents.warp_agent.input.include_agent_commands_in_history": "always_on", + "agents.warp_agent.input.nld_in_terminal_enabled": "always_on", + "agents.warp_agent.input.show_agent_tips": "always_on", + "agents.warp_agent.input.show_model_selectors_in_prompt": "always_on", + "agents.warp_agent.is_any_ai_enabled": "always_on", + "agents.warp_agent.other.agent_attribution_enabled": "always_on", + "agents.warp_agent.other.auto_handoff_on_sleep_enabled": "always_on", + "agents.warp_agent.other.cloud_agent_computer_use_enabled": "always_on", + "agents.warp_agent.other.default_prompt_submission_mode": "ga", + "agents.warp_agent.other.open_conversation_layout_preference": "always_on", + "agents.warp_agent.other.orchestration_message_display_mode": "always_on", + "agents.warp_agent.other.should_force_disable_ampersand_handoff": "always_on", + "agents.warp_agent.other.should_force_disable_cloud_handoff": "always_on", + "agents.warp_agent.other.should_render_use_agent_toolbar_for_user_commands": "always_on", + "agents.warp_agent.other.should_show_oz_updates_in_zero_state": "always_on", + "agents.warp_agent.other.show_agent_notifications": "always_on", + "agents.warp_agent.other.show_conversation_history": "always_on", + "agents.warp_agent.other.thinking_display_mode": "always_on", + "appearance.blocks.should_show_bootstrap_block": "always_on", + "appearance.blocks.should_show_in_band_command_blocks": "always_on", + "appearance.blocks.should_show_ssh_block": "always_on", + "appearance.blocks.show_block_dividers": "always_on", + "appearance.blocks.show_jump_to_bottom_of_block_button": "always_on", + "appearance.cursor.cursor_blink": "always_on", + "appearance.cursor.cursor_display_type": "always_on", + "appearance.full_screen_apps.alt_screen_padding": "always_on", + "appearance.icon.app_icon": "always_on", + "appearance.input.input_mode": "always_on", + "appearance.panes.focus_pane_on_hover": "always_on", + "appearance.panes.should_dim_inactive_panes": "always_on", + "appearance.spacing": "always_on", + "appearance.tabs.directory_tab_colors": "ga", + "appearance.tabs.header_toolbar_chip_selection": "always_on", + "appearance.tabs.preserve_active_tab_color": "always_on", + "appearance.tabs.show_indicators_button": "always_on", + "appearance.tabs.tab_close_button_position": "always_on", + "appearance.tabs.workspace_decoration_visibility": "always_on", + "appearance.text.ai_font_name": "always_on", + "appearance.text.enforce_minimum_contrast": "always_on", + "appearance.text.font_name": "always_on", + "appearance.text.font_size": "always_on", + "appearance.text.font_weight": "always_on", + "appearance.text.ligature_rendering_enabled": "always_on", + "appearance.text.line_height_ratio": "always_on", + "appearance.text.match_ai_font": "always_on", + "appearance.text.match_notebook_to_monospace_font_size": "always_on", + "appearance.text.notebook_font_size": "always_on", + "appearance.text.use_thin_strokes": "always_on", + "appearance.themes.selected_system_themes": "always_on", + "appearance.themes.system_theme": "always_on", + "appearance.themes.theme": "always_on", + "appearance.vertical_tabs.compact_subtitle": "always_on", + "appearance.vertical_tabs.display_granularity": "always_on", + "appearance.vertical_tabs.enabled": "always_on", + "appearance.vertical_tabs.hide_title_bar_search_bar": "always_on", + "appearance.vertical_tabs.primary_info": "always_on", + "appearance.vertical_tabs.show_details_on_hover": "always_on", + "appearance.vertical_tabs.show_diff_stats": "always_on", + "appearance.vertical_tabs.show_panel_in_restored_windows": "always_on", + "appearance.vertical_tabs.show_pr_link": "always_on", + "appearance.vertical_tabs.tab_item_mode": "always_on", + "appearance.vertical_tabs.use_latest_prompt_as_title": "always_on", + "appearance.vertical_tabs.view_mode": "always_on", + "appearance.window.left_panel_visibility_across_tabs": "always_on", + "appearance.window.new_windows_num_columns": "always_on", + "appearance.window.new_windows_num_rows": "always_on", + "appearance.window.open_windows_at_custom_size": "always_on", + "appearance.window.override_blur": "always_on", + "appearance.window.override_blur_texture": "always_on", + "appearance.window.override_opacity": "always_on", + "appearance.window.zoom_level": "always_on", + "cloud_platform.third_party_api_keys.aws_bedrock_auth_refresh_command": "always_on", + "cloud_platform.third_party_api_keys.aws_bedrock_auto_login": "always_on", + "cloud_platform.third_party_api_keys.aws_bedrock_credentials_enabled": "always_on", + "cloud_platform.third_party_api_keys.aws_bedrock_profile": "always_on", + "cloud_platform.third_party_api_keys.can_use_warp_credits_with_byok": "always_on", + "code.editor.auto_open_code_review_pane_on_first_agent_change": "always_on", + "code.editor.open_code_panels_file_editor": "always_on", + "code.editor.open_file_editor": "always_on", + "code.editor.open_file_layout": "always_on", + "code.editor.prefer_markdown_viewer": "always_on", + "code.editor.prefer_tabbed_editor_view": "always_on", + "code.editor.show_code_review_button": "always_on", + "code.editor.show_code_review_diff_stats": "always_on", + "code.editor.show_global_search": "always_on", + "code.editor.show_hidden_files": "always_on", + "code.editor.show_project_explorer": "always_on", + "code.editor.use_warp_as_default_editor": "always_on", + "code.indexing.agent_mode_codebase_context": "always_on", + "code.indexing.agent_mode_codebase_context_auto_indexing": "always_on", + "experimental.async_find_enabled": "always_on", + "general.default_session_mode": "always_on", + "general.default_tab_config_path": "always_on", + "general.link_tooltip": "always_on", + "general.login_item": "always_on", + "general.mouse_scroll_multiplier": "always_on", + "general.new_tab_placement": "always_on", + "general.preserve_input_focus_on_block_selection": "always_on", + "general.quit_on_last_window_closed": "always_on", + "general.restore_session": "always_on", + "general.should_confirm_close_session": "always_on", + "general.show_changelog_after_update": "always_on", + "general.show_warning_before_quitting": "always_on", + "general.snackbar_enabled": "always_on", + "general.undo_close.enabled": "always_on", + "general.undo_close.grace_period": "always_on", + "general.user_native_preference": "always_on", + "global_hotkey.dedicated_window.enabled": "always_on", + "global_hotkey.dedicated_window.settings": "always_on", + "global_hotkey.toggle_all_windows.enabled": "always_on", + "global_hotkey.toggle_all_windows.keybinding": "always_on", + "keys.ctrl_tab_behavior_setting": "always_on", + "notifications.preferences": "always_on", + "notifications.toast_duration_secs": "always_on", + "privacy.crash_reporting_enabled": "always_on", + "privacy.custom_secret_regex_list": "always_on", + "privacy.secret_redaction.enabled": "always_on", + "privacy.secret_redaction.hide_secrets_in_block_list": "always_on", + "privacy.secret_redaction.secret_display_mode_setting": "always_on", + "privacy.telemetry_enabled": "always_on", + "session.new_session_shell_override": "always_on", + "session.startup_shell_override": "always_on", + "session.working_directory_config": "private", + "system.force_x11": "always_on", + "system.linux_selection_clipboard": "always_on", + "system.prefer_low_power_gpu": "always_on", + "system.preferred_graphics_backend": "always_on", + "terminal.copy_on_select": "always_on", + "terminal.focus_reporting_enabled": "always_on", + "terminal.input.alias_expansion_enabled": "always_on", + "terminal.input.at_context_menu_in_terminal_mode": "always_on", + "terminal.input.autosuggestions.enabled": "always_on", + "terminal.input.autosuggestions.keybinding_hint": "always_on", + "terminal.input.autosuggestions.show_ignore_button": "always_on", + "terminal.input.classic_completions_mode": "always_on", + "terminal.input.command_corrections": "always_on", + "terminal.input.completions_open_while_typing": "always_on", + "terminal.input.enable_slash_commands_in_terminal": "always_on", + "terminal.input.error_underlining_enabled": "always_on", + "terminal.input.extra_meta_keys": "always_on", + "terminal.input.honor_ps1": "always_on", + "terminal.input.input_box_type_setting": "always_on", + "terminal.input.middle_click_paste_enabled": "always_on", + "terminal.input.outline_codebase_symbols_for_at_context_menu": "always_on", + "terminal.input.show_hint_text": "always_on", + "terminal.input.show_terminal_input_message_bar": "always_on", + "terminal.input.syntax_highlighting": "always_on", + "terminal.maximum_grid_size": "always_on", + "terminal.mouse_reporting_enabled": "always_on", + "terminal.osc52_clipboard_access": "always_on", + "terminal.scroll_reporting_enabled": "always_on", + "terminal.show_terminal_zero_state_block": "always_on", + "terminal.smart_select.enabled": "always_on", + "terminal.smart_select.word_char_allowlist": "always_on", + "terminal.use_audible_bell": "always_on", + "text_editing.autocomplete_symbols": "always_on", + "text_editing.code_editor_line_number_mode": "always_on", + "text_editing.vim_mode_enabled": "always_on", + "text_editing.vim_status_bar": "always_on", + "text_editing.vim_unnamed_system_clipboard": "always_on", + "warp_drive.enabled": "always_on", + "warp_drive.sorting_choice": "always_on", + "warpify.ssh.enable_legacy_ssh_wrapper": "always_on", + "warpify.ssh.enable_ssh_warpification": "always_on", + "warpify.ssh.ssh_extension_install_mode": "always_on", + "warpify.ssh.ssh_hosts_denylist": "always_on", + "warpify.ssh.use_ssh_tmux_wrapper": "always_on", + "warpify.subshells.added_subshell_commands": "always_on", + "warpify.subshells.subshell_commands_denylist": "always_on", + "workflows.show_global_workflows_in_universal_search": "always_on" + }, + "web_routes": [ + ":agentId", + ":envId", + ":runId", + ":scheduleId", + ":secretId", + "agents", + "design", + "environments", + "integrations", + "login", + "login/callback", + "logout", + "memory", + "memory/:storeId", + "runs", + "schedules", + "secrets", + "settings", + "skills", + "welcome/*" + ], + "server_tools": [ + "add_todos", + "address_review_comments", + "answer", + "answer_query", + "apply_patch", + "ask_advice", + "ask_user_question", + "call_mcp_tool", + "chain_of_thought", + "codebase_semantic_search", + "computer_use", + "create_file", + "create_orchestration_config", + "create_plan", + "create_todo_list", + "edit_files", + "edit_plans", + "exa_web_search", + "fetch_web_pages", + "file_glob", + "finish", + "finish_advice", + "finish_computer_use", + "finish_research", + "finish_task", + "finish_warp_documentation_search", + "get_artifacts_for_pull_request_description", + "grep", + "init_project", + "insert_code_review_comments", + "list_messages_from_agents", + "list_relevant_mcp_context", + "mark_todo_as_done", + "notify_user", + "open_code_review", + "read_executed_shell_command_output", + "read_files", + "read_mcp_resource", + "read_messages_from_agents", + "read_output", + "read_plans", + "read_skill", + "read_todos", + "remove_todos", + "report_pr", + "report_screenshot", + "request_computer_use", + "research", + "ripgrep", + "route", + "run_agents", + "run_shell_command", + "search_conversation_history", + "search_warp_documentation", + "search_warp_documentation_index", + "send_message_to_agent", + "skip", + "start_agent", + "suggest_prompt", + "suggest_unit_tests", + "transfer_control_to_user", + "upload_artifact", + "wait_for_events", + "write_block", + "write_line", + "write_raw_bytes" + ], + "bundled_skills": { + "add-mcp-server": "bundled", + "change-keybinding": "bundled", + "claude-api": "bundled", + "create-skill": "bundled", + "create-tab-config": "bundled", + "modify-settings": "bundled", + "oz-platform": "bundled", + "pr-comments": "bundled", + "tab-configs": "bundled", + "test-warp-ui": "dogfood", + "triage-vulnerabilities": "dogfood", + "update-tab-config": "bundled", + "verify-ui-change-in-cloud": "dogfood" + }, "changelog_last_version": "2026.06.03" } diff --git a/.agents/skills/missing_docs/scripts/audit_docs.py b/.agents/skills/missing_docs/scripts/audit_docs.py index b4b21f16..928cd458 100755 --- a/.agents/skills/missing_docs/scripts/audit_docs.py +++ b/.agents/skills/missing_docs/scripts/audit_docs.py @@ -6,6 +6,19 @@ warp-server to identify gaps, and (in --diff mode) detects surface changes since the last committed snapshot. Produces a structured JSON report. +Audited surfaces: + - Feature flags (rollout status via the app/src/features.rs cargo bridge) + - CLI commands, subcommands (recursive), and per-module long flags + - Public API routes (router/handlers/public_api gin groups vs OpenAPI spec) + - Slash commands (static registry) + - Settings (define_setting! toml_path registry vs all-settings.mdx) + - Oz web app routes (AgentsApp.tsx) [snapshot/diff only] + - Server-side agent tools (multi_agent ToolName constants) [snapshot/diff only] + - Bundled + channel-gated skills [snapshot/diff only] + - Docs structure (pages missing from the sidebar) + - Stale doc references (documented settings/keybinding actions removed from code) + - Docs staleness terminology and surface-map hygiene + Usage: python3 .agents/skills/missing_docs/scripts/audit_docs.py python3 .agents/skills/missing_docs/scripts/audit_docs.py --category features @@ -16,8 +29,10 @@ Exit codes: 0 — all requested audits ran (findings may still exist; check the report) 1 — fatal setup error (docs directory not found, bad arguments) - 2 — one or more audits were SKIPPED (missing repo paths). Never treat a - run that exits 2 as a clean audit. + 2 — one or more audits were SKIPPED (missing repo paths) or an extraction + sanity guard tripped (a parser returned implausibly few surfaces, + meaning the code layout changed). Never treat an exit-2 run as a + clean audit. """ import argparse @@ -33,6 +48,9 @@ SKIP_DIRECTORIES = {"_book", "node_modules", ".git", ".docs"} +# Directories pruned when walking Rust/Go source trees. +SOURCE_SKIP_DIRECTORIES = {"target", "node_modules", ".git", "vendor", "dist", "build"} + # Mutable holder for the docs repo root, set by main() DOCS_REPO_ROOT: list = [None] @@ -43,7 +61,18 @@ STALE_TERMS_PATH = SKILL_DIR / "references" / "stale_terms.md" DEFAULT_SNAPSHOT_PATH = SKILL_DIR / "references" / "surface_snapshot.json" -SNAPSHOT_SCHEMA_VERSION = 1 +SNAPSHOT_SCHEMA_VERSION = 2 + +# Extraction sanity floors: if a parser returns fewer surfaces than this, the +# code layout probably changed and the parser is broken. The audit fails loud +# (exit 2) instead of silently under-reporting. +EXTRACTION_FLOORS = { + "feature flags": 50, + "CLI commands": 5, + "slash commands": 10, + "API routes": 10, + "settings": 100, +} # --------------------------------------------------------------------------- # Surface map parser @@ -56,7 +85,9 @@ def parse_surface_map(path: Path) -> dict: "cli_to_doc": {}, "api_to_doc": {}, "slash_to_doc": {}, + "settings_to_doc": {}, "ignore_flags": set(), + "unlisted_ignore": set(), } if not path.exists(): return result @@ -73,13 +104,20 @@ def parse_surface_map(path: Path) -> dict: current_section = "api" elif line.startswith("## Slash commands"): current_section = "slash" + elif line.startswith("## Settings"): + current_section = "settings" elif line.startswith("## Flags to ignore"): current_section = "ignore" + elif line.startswith("## Unlisted docs pages"): + current_section = "unlisted" continue if current_section == "ignore": result["ignore_flags"].add(line) continue + if current_section == "unlisted": + result["unlisted_ignore"].add(line) + continue if " -> " in line: key, doc_path = line.split(" -> ", 1) @@ -93,6 +131,8 @@ def parse_surface_map(path: Path) -> dict: result["api_to_doc"][key] = doc_path elif current_section == "slash": result["slash_to_doc"][key] = doc_path + elif current_section == "settings": + result["settings_to_doc"][key] = doc_path return result @@ -113,7 +153,7 @@ def parse_stale_terms(path: Path) -> list[tuple[str, str]]: return terms # --------------------------------------------------------------------------- -# Helpers +# Generic helpers # --------------------------------------------------------------------------- def find_repo(name: str, explicit_path: str | None, repo_root: Path) -> Path | None: @@ -145,6 +185,19 @@ def find_markdown_files(docs_root: Path) -> list[Path]: return sorted(files) +def iter_source_files(roots: list[Path], suffix: str): + """Yield source files under the given roots, pruning build directories.""" + for root_dir in roots: + if not root_dir.exists(): + continue + for root, dirs, filenames in os.walk(root_dir): + dirs[:] = [d for d in dirs if d not in SOURCE_SKIP_DIRECTORIES + and not d.startswith(".")] + for f in sorted(filenames): + if f.endswith(suffix): + yield Path(root) / f + + def read_all_docs_text(docs_root: Path) -> dict[str, str]: """Read all doc files into a dict of {relative_path: content} (lowercased).""" result = {} @@ -237,6 +290,113 @@ def kebab_case(name: str) -> str: """PascalCase -> kebab-case: RunCloud -> run-cloud, MCP -> mcp.""" return re.sub(r"([a-z0-9])([A-Z])", r"\1-\2", name).lower() +# --------------------------------------------------------------------------- +# Rust parsing helpers +# --------------------------------------------------------------------------- + +def _extract_enum_block(content: str, enum_name: str) -> str | None: + """Return the body of `[pub] enum { ... }` using brace matching.""" + match = re.search(rf"(?:pub\s+)?enum {enum_name}\s*\{{", content) + if not match: + return None + start = match.end() + depth = 1 + i = start + while i < len(content) and depth > 0: + if content[i] == "{": + depth += 1 + elif content[i] == "}": + depth -= 1 + i += 1 + return content[start:i - 1] + + +def _parse_enum_variants(enum_body: str) -> list[dict]: + """Parse top-level variants of a Rust enum body. + + Returns [{"name", "hidden", "subcommand", "referenced_type"}]. + Tracks brace/paren depth so struct-variant fields aren't mistaken for + variants, and reads `hide = true` / `#[command(subcommand)]` from the + attributes stacked above each variant. + """ + variants = [] + depth = 0 + pending_attrs: list[str] = [] + for raw_line in enum_body.splitlines(): + line = raw_line.strip() + if depth == 0: + if line.startswith("#["): + pending_attrs.append(line) + elif line.startswith("///") or line.startswith("//") or not line: + pass + else: + match = re.match(r"^([A-Z]\w*)\s*(\(|\{|,|$)", line) + if match: + name = match.group(1) + attrs = " ".join(pending_attrs) + ref_match = re.search(r"\(\s*(?:crate::)?([\w:]+)\s*\)", line) + variants.append({ + "name": name, + "hidden": "hide = true" in attrs, + "subcommand": "subcommand" in attrs, + "referenced_type": ref_match.group(1) if ref_match else None, + }) + pending_attrs = [] + depth += raw_line.count("{") - raw_line.count("}") + depth += raw_line.count("(") - raw_line.count(")") + depth = max(depth, 0) + return variants + + +def _enclosing_brace_block(content: str, idx: int) -> str: + """Return the innermost `{...}` block containing the given index. + + Heuristic brace matching (does not understand string literals), good + enough for the macro-invocation blocks it is used on. + """ + depth = 0 + start = None + i = idx + while i >= 0: + c = content[i] + if c == "}": + depth += 1 + elif c == "{": + if depth == 0: + start = i + break + depth -= 1 + i -= 1 + if start is None: + return content[max(0, idx - 500):idx + 500] + depth = 0 + j = start + while j < len(content): + if content[j] == "{": + depth += 1 + elif content[j] == "}": + depth -= 1 + if depth == 0: + return content[start:j + 1] + j += 1 + return content[start:] + + +def _iter_attr_blocks(content: str, names: tuple[str, ...]): + """Yield full `#[name(...)]` attribute blocks, paren-matched.""" + pattern = re.compile(r"#\[(" + "|".join(names) + r")\(") + for match in pattern.finditer(content): + start = match.end() + depth = 1 + i = start + while i < len(content) and depth > 0: + if content[i] == "(": + depth += 1 + elif content[i] == ")": + depth -= 1 + i += 1 + yield content[match.start():i] + # --------------------------------------------------------------------------- # Extraction: feature flags (warp-internal) # --------------------------------------------------------------------------- @@ -252,29 +412,17 @@ def _features_lib_rs(warp_internal: Path) -> Path | None: def parse_feature_flags(warp_internal: Path) -> list[str]: - """Parse FeatureFlag enum variants from the features lib.""" + """Parse FeatureFlag enum variants from the features lib (brace-safe).""" features_rs = _features_lib_rs(warp_internal) if features_rs is None: print("Warning: FeatureFlag enum source not found in warp-internal", file=sys.stderr) return [] - content = features_rs.read_text() - in_enum = False - flags = [] - for line in content.splitlines(): - stripped = line.strip() - if "enum FeatureFlag" in stripped: - in_enum = True - continue - if in_enum: - if stripped == "}": - break - if stripped.startswith("//") or stripped.startswith("#[") or not stripped: - continue - match = re.match(r"^([A-Z]\w+)", stripped) - if match: - flags.append(match.group(1)) - return flags + enum_body = _extract_enum_block(features_rs.read_text(), "FeatureFlag") + if enum_body is None: + print("Warning: FeatureFlag enum not found", file=sys.stderr) + return [] + return [v["name"] for v in _parse_enum_variants(enum_body)] def parse_flag_list_const(warp_internal: Path, const_name: str) -> set[str]: @@ -383,63 +531,9 @@ def compute_flag_statuses(warp_internal: Path) -> dict[str, str]: return statuses # --------------------------------------------------------------------------- -# Extraction: CLI command tree (warp-internal) +# Extraction: CLI command tree + flags (warp-internal) # --------------------------------------------------------------------------- -def _extract_enum_block(content: str, enum_name: str) -> str | None: - """Return the body of `pub enum { ... }` using brace matching.""" - match = re.search(rf"pub enum {enum_name}\s*\{{", content) - if not match: - return None - start = match.end() - depth = 1 - i = start - while i < len(content) and depth > 0: - if content[i] == "{": - depth += 1 - elif content[i] == "}": - depth -= 1 - i += 1 - return content[start:i - 1] - - -def _parse_enum_variants(enum_body: str) -> list[dict]: - """Parse top-level variants of a clap enum body. - - Returns [{"name", "hidden", "subcommand", "referenced_type"}]. - Tracks brace/paren depth so struct-variant fields aren't mistaken for - variants, and reads `hide = true` / `#[command(subcommand)]` from the - attributes stacked above each variant. - """ - variants = [] - depth = 0 - pending_attrs: list[str] = [] - for raw_line in enum_body.splitlines(): - line = raw_line.strip() - if depth == 0: - if line.startswith("#["): - pending_attrs.append(line) - elif line.startswith("///") or line.startswith("//") or not line: - pass - else: - match = re.match(r"^([A-Z]\w*)\s*(\(|\{|,|$)", line) - if match: - name = match.group(1) - attrs = " ".join(pending_attrs) - ref_match = re.search(r"\(\s*(?:crate::)?([\w:]+)\s*\)", line) - variants.append({ - "name": name, - "hidden": "hide = true" in attrs, - "subcommand": "subcommand" in attrs, - "referenced_type": ref_match.group(1) if ref_match else None, - }) - pending_attrs = [] - depth += raw_line.count("{") - raw_line.count("}") - depth += raw_line.count("(") - raw_line.count(")") - depth = max(depth, 0) - return variants - - def _resolve_subcommand_enum(module_content: str, referenced_type: str | None) -> str | None: """Find the enum body holding a variant's subcommands within a module file. @@ -460,17 +554,47 @@ def _resolve_subcommand_enum(module_content: str, referenced_type: str | None) - return None +def _collect_subcommands(src_dir: Path, module_content: str, enum_body: str, + prefix: str, parent_hidden: bool, depth: int) -> list[dict]: + """Recursively collect subcommands (e.g. `oz environment image list`).""" + subs = [] + for sub in _parse_enum_variants(enum_body): + command = f"{prefix} {kebab_case(sub['name'])}" + hidden = sub["hidden"] or parent_hidden + subs.append({"command": command, "hidden": hidden}) + if depth >= 3 or not sub["subcommand"]: + continue + ref = sub["referenced_type"] + target_content = module_content + if ref and "::" in ref: + module = ref.split("::")[0] + module_file = src_dir / f"{module}.rs" + if not module_file.exists(): + continue + target_content = module_file.read_text() + nested_body = _resolve_subcommand_enum(target_content, ref) + if nested_body is not None and nested_body != enum_body: + subs.extend(_collect_subcommands( + src_dir, target_content, nested_body, command, hidden, depth + 1)) + return subs + + +def _cli_src_dir(warp_internal: Path) -> Path | None: + candidates = [ + warp_internal / "crates" / "warp_cli" / "src", + warp_internal / "warp_cli" / "src", + ] + return next((c for c in candidates if c.exists()), None) + + def parse_cli_commands(warp_internal: Path) -> list[dict]: - """Parse the full `oz` CLI command tree (top-level + one level of subcommands). + """Parse the full `oz` CLI command tree (recursive subcommands). Returns [{"command": "oz agent", "hidden": bool, "source_file": str, + "module": str|None, "subcommands": [{"command": "oz agent run", "hidden": bool}]}] """ - src_candidates = [ - warp_internal / "crates" / "warp_cli" / "src", - warp_internal / "warp_cli" / "src", - ] - src_dir = next((c for c in src_candidates if c.exists()), None) + src_dir = _cli_src_dir(warp_internal) if src_dir is None: print("Warning: warp_cli/src not found in warp-internal", file=sys.stderr) return [] @@ -493,6 +617,7 @@ def parse_cli_commands(warp_internal: Path) -> list[dict]: "command": f"oz {cmd_name}", "hidden": variant["hidden"], "source_file": None, + "module": None, "subcommands": [], } ref = variant["referenced_type"] @@ -501,17 +626,48 @@ def parse_cli_commands(warp_internal: Path) -> list[dict]: module_file = src_dir / f"{module}.rs" if module_file.exists(): entry["source_file"] = f"warp_cli/src/{module}.rs" + entry["module"] = module module_content = module_file.read_text() sub_body = _resolve_subcommand_enum(module_content, ref) if sub_body is not None: - for sub in _parse_enum_variants(sub_body): - entry["subcommands"].append({ - "command": f"oz {cmd_name} {kebab_case(sub['name'])}", - "hidden": sub["hidden"] or variant["hidden"], - }) + entry["subcommands"] = _collect_subcommands( + src_dir, module_content, sub_body, + entry["command"], variant["hidden"], depth=1) commands.append(entry) return commands + +def parse_cli_flags(warp_internal: Path, cli_commands: list[dict]) -> dict[str, list[str]]: + """Extract visible `--long` flags per CLI module for change tracking. + + Attribution of flags to specific subcommands would require full clap + resolution; per-module sets are stable and sufficient to detect that a + flag was added or removed (the drift agent then reads the module to see + which command it belongs to). + """ + src_dir = _cli_src_dir(warp_internal) + if src_dir is None: + return {} + + flags_by_module: dict[str, list[str]] = {} + modules = sorted({c["module"] for c in cli_commands if c.get("module") and not c["hidden"]}) + for module in modules: + module_file = src_dir / f"{module}.rs" + if not module_file.exists(): + continue + content = module_file.read_text() + flags: set[str] = set() + for attr in _iter_attr_blocks(content, ("arg", "clap", "command")): + if "hide = true" in attr: + continue + for m in re.finditer(r'long(?:_flag)?\s*=\s*"([a-z0-9][a-z0-9-]*)"', attr): + flags.add(f"--{m.group(1)}") + for m in re.finditer(r'long(?:_flag)?\("([a-z0-9][a-z0-9-]*)"\)', attr): + flags.add(f"--{m.group(1)}") + if flags: + flags_by_module[module] = sorted(flags) + return flags_by_module + # --------------------------------------------------------------------------- # Extraction: public API routes (warp-server) # --------------------------------------------------------------------------- @@ -519,9 +675,6 @@ def parse_cli_commands(warp_internal: Path) -> list[dict]: _GO_FUNC_RE = re.compile(r"^func (\w+)\(([^)]*)\)", re.MULTILINE) _GO_GROUP_ASSIGN_RE = re.compile(r"(\w+)\s*:?=\s*(\w+)\.Group\(\s*\"([^\"]*)\"") _GO_ROUTE_RE = re.compile(r"(\w+)\.(GET|POST|PUT|DELETE|PATCH)\(\s*\"([^\"]*)\"") -_GO_REGISTER_CALL_RE = re.compile( - r"(Register\w+)\(\s*(\w+)(?:\.Group\(\s*\"([^\"]*)\"\s*\))?\s*," -) def _parse_go_functions(content: str) -> dict[str, dict]: @@ -538,6 +691,54 @@ def _parse_go_functions(content: str) -> dict[str, dict]: return functions +def _split_top_level_args(s: str) -> list[str]: + """Split a Go argument list on top-level commas.""" + args = [] + depth = 0 + cur: list[str] = [] + for ch in s: + if ch in "([{": + depth += 1 + elif ch in ")]}": + depth -= 1 + if ch == "," and depth == 0: + args.append("".join(cur).strip()) + cur = [] + else: + cur.append(ch) + tail = "".join(cur).strip() + if tail: + args.append(tail) + return args + + +def _iter_register_calls(body: str): + """Yield (callee, start_pos, args) for Register*(...) calls, paren-matched.""" + for match in re.finditer(r"\b(Register\w+)\(", body): + start = match.end() + depth = 1 + i = start + while i < len(body) and depth > 0: + if body[i] == "(": + depth += 1 + elif body[i] == ")": + depth -= 1 + i += 1 + yield match.group(1), match.start(), _split_top_level_args(body[start:i - 1]) + + +def _go_param_positions(params: str) -> tuple[int | None, int | None]: + """Return (router_group_param_index, engine_param_index) for a Go param list.""" + group_idx = None + engine_idx = None + for idx, param in enumerate(_split_top_level_args(params)): + if "*gin.RouterGroup" in param and group_idx is None: + group_idx = idx + elif "*gin.Engine" in param and engine_idx is None: + engine_idx = idx + return group_idx, engine_idx + + def parse_public_api_routes(warp_server: Path) -> list[dict]: """Extract public API routes from router/handlers/public_api/*.go. @@ -550,8 +751,10 @@ def parse_public_api_routes(warp_server: Path) -> list[dict]: This walks group-variable assignments per registration function and resolves caller-passed prefixes via Register* call sites, starting from - RegisterPublicAPIRoutes. Gin `:param` segments are normalized to - OpenAPI-style `{param}`. + RegisterPublicAPIRoutes. The RouterGroup argument is matched positionally + against the callee's parameter list (so `RegisterOAuthRoutes(router, + group, ...)` resolves `group`, not `router`). Gin `:param` segments are + normalized to OpenAPI-style `{param}`. """ api_dir = warp_server / "router" / "handlers" / "public_api" if not api_dir.exists(): @@ -568,14 +771,16 @@ def parse_public_api_routes(warp_server: Path) -> list[dict]: def analyze(fn: dict) -> dict: """Resolve a function body to routes/calls relative to its params.""" - params = fn["params"] - group_param = None - router_param = None - for param_match in re.finditer(r"(\w+)(?:\s*,\s*\w+)*\s+\*gin\.(RouterGroup|Engine)", params): - if param_match.group(2) == "RouterGroup" and group_param is None: - group_param = param_match.group(1) - elif param_match.group(2) == "Engine" and router_param is None: - router_param = param_match.group(1) + group_idx, engine_idx = _go_param_positions(fn["params"]) + param_names = _split_top_level_args(fn["params"]) + + def param_name(idx): + if idx is None or idx >= len(param_names): + return None + return param_names[idx].split()[0] if param_names[idx].split() else None + + group_param = param_name(group_idx) + router_param = param_name(engine_idx) # var name -> (base, prefix); base is "PARAM" (caller group) or # "ROUTER" (engine root) @@ -585,34 +790,49 @@ def analyze(fn: dict) -> dict: if router_param: var_bases[router_param] = ("ROUTER", "") - routes = [] - calls = [] + def resolve_expr(expr: str): + m = re.fullmatch(r"(\w+)", expr) + if m: + return var_bases.get(m.group(1)) + m = re.fullmatch(r"(\w+)\.Group\(\s*\"([^\"]*)\"\s*\)", expr) + if m: + base = var_bases.get(m.group(1)) + if base is not None: + return (base[0], base[1] + m.group(2)) + return None + events = [] for assign in _GO_GROUP_ASSIGN_RE.finditer(fn["body"]): events.append(("assign", assign.start(), assign.groups())) for route in _GO_ROUTE_RE.finditer(fn["body"]): events.append(("route", route.start(), route.groups())) - for call in _GO_REGISTER_CALL_RE.finditer(fn["body"]): - events.append(("call", call.start(), call.groups())) + for callee, pos, args in _iter_register_calls(fn["body"]): + events.append(("call", pos, (callee, args))) events.sort(key=lambda e: e[1]) - for kind, _pos, groups in events: + routes = [] + calls = [] + for kind, _pos, payload in events: if kind == "assign": - target, parent, prefix = groups + target, parent, prefix = payload base = var_bases.get(parent) if base is not None: var_bases[target] = (base[0], base[1] + prefix) elif kind == "route": - var, method, path = groups + var, method, path = payload base = var_bases.get(var) if base is not None: routes.append((base[0], method, base[1] + path)) else: # call - callee, arg_var, arg_prefix = groups - base = var_bases.get(arg_var) - if base is not None: - calls.append((callee, base[0], base[1] + (arg_prefix or ""))) - return {"routes": routes, "calls": calls, "file": fn["file"]} + callee, args = payload + resolved_args = [resolve_expr(a) for a in args] + calls.append((callee, resolved_args)) + return { + "routes": routes, + "calls": calls, + "file": fn["file"], + "group_param_index": group_idx, + } analyzed = {name: analyze(fn) for name, fn in functions.items()} @@ -634,7 +854,21 @@ def emit(fn_name: str, param_prefix: str): for base, method, path in info["routes"]: full = (param_prefix + path) if base == "PARAM" else path resolved.append({"method": method, "path": full, "file": info["file"]}) - for callee, base, prefix in info["calls"]: + for callee, args in info["calls"]: + callee_info = analyzed.get(callee) + if callee_info is None: + continue + # Pick the argument that maps to the callee's RouterGroup param; + # fall back to the first resolvable argument. + idx = callee_info.get("group_param_index") + arg = None + if idx is not None and idx < len(args): + arg = args[idx] + if arg is None: + arg = next((a for a in args if a is not None), None) + if arg is None: + continue + base, prefix = arg callee_prefix = (param_prefix + prefix) if base == "PARAM" else prefix emit(callee, callee_prefix) @@ -665,6 +899,19 @@ def emit(fn_name: str, param_prefix: str): routes.sort(key=lambda r: (r["path"], r["method"])) return routes + +def _normalize_path_params(path: str) -> str: + """`/agent/runs/{runId}` -> `/agent/runs/{}` (param-name-insensitive).""" + return re.sub(r"\{[^}]+\}", "{}", path) + + +def parse_openapi_paths(openapi_text: str) -> set[str]: + """Extract normalized path keys from the OpenAPI YAML text.""" + paths = set() + for match in re.finditer(r"(?m)^\s{2}(/[^\s:]+):", openapi_text): + paths.add(_normalize_path_params(match.group(1))) + return paths + # --------------------------------------------------------------------------- # Extraction: slash commands (warp-internal) # --------------------------------------------------------------------------- @@ -687,11 +934,213 @@ def parse_slash_commands(warp_internal: Path) -> list[str]: return sorted(names) # --------------------------------------------------------------------------- -# Extraction: docs changelog entries +# Extraction: settings (warp-internal) +# --------------------------------------------------------------------------- + +_SETTING_TOML_PATH_RE = re.compile(r'toml_path:\s*"([^"]+)"') + + +def _is_test_rs(path: Path) -> bool: + return path.name.endswith("_tests.rs") or path.name == "tests.rs" or "/tests/" in str(path) + + +def parse_settings(warp_internal: Path) -> dict[str, dict]: + """Parse user-facing settings from `define_setting!`-style registrations. + + Every settings.toml-backed setting declares `toml_path: "section.key"` + in its registration block, alongside `private:` and (optionally) + `feature_flag:`. This is the same metadata the JSON-schema generator + (app/src/bin/generate_settings_schema.rs) consumes via inventory. + + Returns {toml_path: {"private": bool, "feature_flag": str|None}}. + """ + settings: dict[str, dict] = {} + roots = [warp_internal / "app" / "src", warp_internal / "crates"] + for rs_file in iter_source_files(roots, ".rs"): + if _is_test_rs(rs_file): + continue + # The macro definition file contains `toml_path:` in doc examples. + if rs_file.name == "macros.rs" and rs_file.parent.name == "src" \ + and rs_file.parent.parent.name == "settings": + continue + try: + content = rs_file.read_text(encoding="utf-8") + except Exception: + continue + if "toml_path:" not in content: + continue + for match in _SETTING_TOML_PATH_RE.finditer(content): + toml_path = match.group(1) + block = _enclosing_brace_block(content, match.start()) + flag_match = re.search( + r"feature_flag:\s*(?:Some\()?\s*(?:[\w:]*::)?FeatureFlag::(\w+)", block) + entry = { + "private": re.search(r"private:\s*true", block) is not None, + "feature_flag": flag_match.group(1) if flag_match else None, + } + existing = settings.get(toml_path) + if existing: + # Same setting registered per-platform: private if any + # registration is private; keep the first flag seen. + entry["private"] = entry["private"] or existing["private"] + entry["feature_flag"] = existing["feature_flag"] or entry["feature_flag"] + settings[toml_path] = entry + return settings + + +def setting_status(info: dict, flag_statuses: dict[str, str]) -> str: + """Classify a setting: private | always_on | ga | preview | dogfood | other | unknown_flag.""" + if info["private"]: + return "private" + flag = info["feature_flag"] + if flag is None: + return "always_on" + return flag_statuses.get(flag, "unknown_flag") + + +def parse_settings_doc(docs_root: Path) -> tuple[dict[str, set[str]], Path | None]: + """Parse all-settings.mdx into {toml_section: {keys}}. + + The reference page lists `**Section**: `[a.b]`` headers followed by + `* `key` — ...` bullets. + """ + page = docs_root / "terminal" / "settings" / "all-settings.mdx" + sections: dict[str, set[str]] = {} + if not page.exists(): + return sections, None + current = "" + for line in page.read_text(encoding="utf-8").splitlines(): + stripped = line.strip() + header = re.match(r"\*\*Section\*\*:\s*`\[([^\]]+)\]`", stripped) + if header: + current = header.group(1) + sections.setdefault(current, set()) + continue + bullet = re.match(r"^\*\s+`([A-Za-z0-9_]+)`", stripped) + if bullet: + sections.setdefault(current, set()).add(bullet.group(1)) + return sections, page + +# --------------------------------------------------------------------------- +# Extraction: snapshot-only surfaces (web app, tools, bundled skills) # --------------------------------------------------------------------------- +def parse_webapp_routes(warp_server: Path) -> list[str]: + """Parse Oz web app route paths from the agents package router.""" + app_tsx = warp_server / "client" / "packages" / "agents" / "src" / "AgentsApp.tsx" + if not app_tsx.exists(): + print(f"Warning: {app_tsx} not found", file=sys.stderr) + return [] + paths = set(re.findall(r'path="([^"]+)"', app_tsx.read_text(encoding="utf-8"))) + paths.discard("*") + return sorted(paths) + + +def parse_server_tools(warp_server: Path) -> list[str]: + """Parse agent tool names from the multi_agent tool registries. + + Two definition styles exist: + - `xToolName = "tool_name"` constants + - `native_tools.Create*NativeTool[...]("tool_name", ...)` registrations + (the canonical registry in native_tools/shared/shared_tools.go) + """ + base = warp_server / "logic" / "ai" / "multi_agent" + if not base.exists(): + print(f"Warning: {base} not found", file=sys.stderr) + return [] + names: set[str] = set() + native_tool_re = re.compile( + r'Create\w*NativeTool\[[^\]]*\]\(\s*"([a-z0-9_]+)"', re.DOTALL) + for go_file in iter_source_files([base], ".go"): + if go_file.name.endswith("_test.go"): + continue + try: + content = go_file.read_text(encoding="utf-8") + except Exception: + continue + for match in re.finditer(r'ToolName\s*=\s*"([a-z0-9_]+)"', content): + names.add(match.group(1)) + for match in native_tool_re.finditer(content): + names.add(match.group(1)) + return sorted(names) + + +def parse_bundled_skills(warp_internal: Path) -> dict[str, str]: + """List bundled skills shipped with the client, keyed by channel gating. + + resources/bundled/skills/ ships on all channels ("bundled"); + resources/channel-gated-skills// ships per channel. + """ + skills: dict[str, str] = {} + bundled = warp_internal / "resources" / "bundled" / "skills" + if bundled.exists(): + for entry in sorted(bundled.iterdir()): + if entry.is_dir(): + skills[entry.name] = "bundled" + gated = warp_internal / "resources" / "channel-gated-skills" + if gated.exists(): + for channel_dir in sorted(gated.iterdir()): + if not channel_dir.is_dir(): + continue + for entry in sorted(channel_dir.iterdir()): + if entry.is_dir(): + skills[entry.name] = channel_dir.name + return skills + +# --------------------------------------------------------------------------- +# Extraction: docs sidebar + changelog +# --------------------------------------------------------------------------- + +def parse_sidebar_slugs(repo_root: Path) -> set[str] | None: + """Parse referenced page slugs from src/sidebar.ts. + + Entries appear either as bare string items ('terminal/blocks/find'), + `slug: '...'` objects, or topic `link: '/...'` values. + Returns None when the sidebar file cannot be found (callers should skip + the structure audit rather than reporting everything unlisted). + """ + sidebar = repo_root / "src" / "sidebar.ts" + if not sidebar.exists(): + return None + slugs: set[str] = set() + for line in sidebar.read_text(encoding="utf-8").splitlines(): + stripped = line.strip().rstrip(",") + bare = re.fullmatch(r"'([a-z0-9][a-z0-9/-]*)'", stripped) + if bare: + slugs.add(bare.group(1)) + continue + for match in re.finditer(r"slug:\s*'([^']+)'", line): + slugs.add(match.group(1).strip("/")) + for match in re.finditer(r"link:\s*'([^']*)'", line): + slugs.add(match.group(1).strip("/")) + return slugs + + +def page_slug(md_file: Path, docs_root: Path) -> str: + """Compute the Starlight slug for a docs page (frontmatter override aware).""" + try: + head = md_file.read_text(encoding="utf-8")[:2000] + override = re.search(r"(?m)^slug:\s*['\"]?([^'\"\n]+)['\"]?\s*$", head) + if override: + return override.group(1).strip().strip("/") + except Exception: + pass + rel = md_file.relative_to(docs_root) + slug = str(rel) + for ext in (".mdx", ".md"): + if slug.endswith(ext): + slug = slug[: -len(ext)] + if slug.endswith("/index"): + slug = slug[: -len("/index")] + elif slug == "index": + slug = "" + return slug + + _CHANGELOG_HEADER_RE = re.compile(r"^### (\d{4}\.\d{2}\.\d{2})", re.MULTILINE) -_CHANGELOG_TRACKED_SECTIONS = ("new features", "improvements") +# "Bug fixes" is deliberately untracked: fix bullets rarely create doc surface +# and would double the weekly triage volume. +_CHANGELOG_TRACKED_SECTIONS = ("new features", "improvements", "oz updates") def parse_changelog_entries(repo_root: Path) -> list[dict]: @@ -699,8 +1148,8 @@ def parse_changelog_entries(repo_root: Path) -> list[dict]: Returns [{"version": "2026.06.03", "file": str, "items": [{"category": "new features", "text": str}]}] sorted newest first. - Only "New features" and "Improvements" bullets are tracked — those are the - sections that may represent undocumented feature launches. + "New features", "Improvements", and "Oz updates" bullets are tracked — + the sections that may represent undocumented feature launches. """ changelog_dir = repo_root / "src" / "content" / "docs" / "changelog" if not changelog_dir.exists(): @@ -864,9 +1313,10 @@ def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, # --------------------------------------------------------------------------- def audit_cli(warp_internal: Path, docs_root: Path, surface_map: dict, - docs_text: dict[str, str]) -> list[dict]: + docs_text: dict[str, str], + cli_commands: list[dict] | None = None) -> list[dict]: """Audit CLI command and subcommand coverage in docs.""" - commands = parse_cli_commands(warp_internal) + commands = cli_commands if cli_commands is not None else parse_cli_commands(warp_internal) cli_to_doc = surface_map.get("cli_to_doc", {}) repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent @@ -929,15 +1379,17 @@ def is_covered(cmd_str: str, search_phrase: str) -> bool: # --------------------------------------------------------------------------- def audit_api(warp_server: Path, docs_root: Path, surface_map: dict, - docs_text: dict[str, str]) -> list[dict]: + docs_text: dict[str, str], + api_routes: list[dict] | None = None) -> list[dict]: """Audit public API endpoint coverage in the OpenAPI spec and API docs. The public docs API reference (docs.warp.dev/api) renders developers/agent-api-openapi.yaml, so a route missing from the spec is a - docs gap. Use the warp-server `update-open-api-spec` skill / docs - `sync-openapi-spec` skill to fix spec drift rather than hand-editing. + docs gap. Spec matching is param-name-insensitive ({runId} == {run_id}). + Use the warp-server `update-open-api-spec` skill / docs `sync-openapi-spec` + skill to fix spec drift rather than hand-editing. """ - routes = parse_public_api_routes(warp_server) + routes = api_routes if api_routes is not None else parse_public_api_routes(warp_server) api_to_doc = surface_map.get("api_to_doc", {}) # Read API docs @@ -963,6 +1415,7 @@ def audit_api(warp_server: Path, docs_root: Path, surface_map: dict, openapi_text = openapi_path.read_text(encoding="utf-8").lower() except Exception: pass + spec_paths = parse_openapi_paths(openapi_text) findings = [] for route in routes: @@ -977,15 +1430,20 @@ def audit_api(warp_server: Path, docs_root: Path, surface_map: dict, if route_str in api_to_doc or rel_route_str in api_to_doc: continue - # Search the OpenAPI spec and API docs for the path - found = False - for candidate in {route["path"].lower(), rel_path.lower()}: - if candidate in openapi_text: - found = True - break - if any(candidate in content for content in api_docs_text.values()): - found = True - break + # Match against the spec's path keys (param-name-insensitive), then + # fall back to substring search in API docs prose. + found = ( + _normalize_path_params(rel_path) in spec_paths + or _normalize_path_params(route["path"]) in spec_paths + ) + if not found: + for candidate in {route["path"].lower(), rel_path.lower()}: + if candidate in openapi_text: + found = True + break + if any(candidate in content for content in api_docs_text.values()): + found = True + break if not found: findings.append({ @@ -1013,9 +1471,10 @@ def _slash_mention_re(name: str) -> re.Pattern: def audit_slash_commands(warp_internal: Path, docs_root: Path, surface_map: dict, - docs_text: dict[str, str]) -> list[dict]: + docs_text: dict[str, str], + slash_commands: list[str] | None = None) -> list[dict]: """Audit static slash command coverage in docs.""" - names = parse_slash_commands(warp_internal) + names = slash_commands if slash_commands is not None else parse_slash_commands(warp_internal) slash_to_doc = surface_map.get("slash_to_doc", {}) repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent @@ -1041,7 +1500,150 @@ def audit_slash_commands(warp_internal: Path, docs_root: Path, surface_map: dict return findings # --------------------------------------------------------------------------- -# Audit 5: Docs staleness +# Audit 5: Settings coverage +# --------------------------------------------------------------------------- + +def audit_settings(docs_root: Path, surface_map: dict, + settings: dict[str, dict], + flag_statuses: dict[str, str]) -> list[dict]: + """Audit settings.toml coverage in the all-settings reference page. + + Private settings are skipped; settings gated by dogfood/other flags are + tracked by the snapshot instead. Settings gated by a flag the parser + cannot resolve are flagged conservatively. + """ + settings_to_doc = surface_map.get("settings_to_doc", {}) + repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent + doc_sections, doc_page = parse_settings_doc(docs_root) + if doc_page is None: + return [{ + "setting": "(all)", + "severity": "high", + "reason": ( + "all-settings.mdx not found — the settings reference page moved; " + "update parse_settings_doc() in the audit script" + ), + }] + + findings = [] + for toml_path in sorted(settings): + status = setting_status(settings[toml_path], flag_statuses) + if status in ("private", "dogfood", "other"): + continue + + if toml_path in settings_to_doc: + target = settings_to_doc[toml_path] + if target == "internal": + continue + if resolve_doc_path(target, repo_root) is not None: + continue + + hierarchy, _, key = toml_path.rpartition(".") + if key in doc_sections.get(hierarchy, set()): + continue + # Object-typed settings are documented as their own section (e.g. the + # `notifications.preferences` setting appears as a + # `**Section**: [notifications.preferences]` block of field bullets). + if toml_path in doc_sections or any( + section.startswith(toml_path + ".") for section in doc_sections): + continue + + findings.append({ + "setting": toml_path, + "status": status, + "severity": "medium", + "suggested_doc_path": "src/content/docs/terminal/settings/all-settings.mdx", + "reason": ( + f"Setting '{toml_path}' ({status}) is not documented in the " + "all-settings reference — add it under the " + f"`[{hierarchy or 'top-level'}]` section, or map it as internal" + ), + }) + return findings + +# --------------------------------------------------------------------------- +# Audit 6: Stale doc references (docs pointing at removed code surfaces) +# --------------------------------------------------------------------------- + +def audit_stale_doc_references(warp_internal: Path, docs_root: Path, + settings: dict[str, dict]) -> list[dict]: + """Find doc references to code surfaces that no longer exist. + + - Settings keys documented in all-settings.mdx but absent from the code + settings registry (renamed/removed settings). + - Keybinding action names (`scope:action`) documented on the keyboard + shortcuts page but absent from warp-internal source. + """ + findings = [] + + # Documented settings that no longer exist in code. + doc_sections, doc_page = parse_settings_doc(docs_root) + if doc_page is not None and settings: + known = set() + for toml_path in settings: + hierarchy, _, key = toml_path.rpartition(".") + known.add((hierarchy, key)) + + def is_object_setting_section(section: str) -> bool: + # Fields of object-typed settings (e.g. keys under the + # `[notifications.preferences]` section, where the code setting is + # `notifications.preferences` itself) cannot be validated + # statically — skip them. + return any( + section == code_path or section.startswith(code_path + ".") + for code_path in settings + ) + + for section, keys in sorted(doc_sections.items()): + if is_object_setting_section(section): + continue + for key in sorted(keys): + if (section, key) not in known: + findings.append({ + "kind": "setting", + "reference": f"{section}.{key}" if section else key, + "doc_page": "src/content/docs/terminal/settings/all-settings.mdx", + "severity": "low", + "reason": ( + "Documented setting not found in the code settings " + "registry — it was renamed or removed; update the " + "all-settings page" + ), + }) + + # Documented keybinding actions that no longer exist in code. + shortcuts_page = docs_root / "getting-started" / "keyboard-shortcuts.mdx" + if shortcuts_page.exists(): + text = shortcuts_page.read_text(encoding="utf-8") + actions = sorted(set(re.findall(r"`([a-z0-9_]+:[a-z0-9_]+)`", text))) + remaining = set(actions) + if remaining: + roots = [warp_internal / "app" / "src", warp_internal / "crates"] + for rs_file in iter_source_files(roots, ".rs"): + if not remaining: + break + try: + content = rs_file.read_text(encoding="utf-8") + except Exception: + continue + remaining = {a for a in remaining if a not in content} + for action in sorted(remaining): + findings.append({ + "kind": "keybinding_action", + "reference": action, + "doc_page": "src/content/docs/getting-started/keyboard-shortcuts.mdx", + "severity": "low", + "reason": ( + "Documented keybinding action not found anywhere in " + "warp-internal source — it was renamed or removed; update " + "the keyboard shortcuts page" + ), + }) + + return findings + +# --------------------------------------------------------------------------- +# Audit 7: Docs staleness (terminology) # --------------------------------------------------------------------------- def audit_staleness(warp_internal: Path, docs_root: Path, @@ -1084,12 +1686,53 @@ def audit_staleness(warp_internal: Path, docs_root: Path, return findings # --------------------------------------------------------------------------- -# Audit 6: Surface map hygiene +# Audit 8: Docs structure (pages missing from the sidebar) +# --------------------------------------------------------------------------- + +def audit_unlisted_pages(repo_root: Path, docs_root: Path, surface_map: dict) -> list[dict]: + """Find docs pages that exist on disk but are not referenced in the sidebar. + + Unlisted pages are built but unreachable through navigation — usually a + forgotten `src/sidebar.ts` entry after adding a page. Intentionally + unlisted pages belong in the surface map's "Unlisted docs pages" section. + """ + slugs = parse_sidebar_slugs(repo_root) + if slugs is None: + return [{ + "page": "(all)", + "severity": "high", + "reason": ( + "src/sidebar.ts not found — sidebar definition moved; update " + "parse_sidebar_slugs() in the audit script" + ), + }] + allowlist = surface_map.get("unlisted_ignore", set()) + + findings = [] + for md_file in find_markdown_files(docs_root): + slug = page_slug(md_file, docs_root) + if slug in slugs or slug in allowlist: + continue + findings.append({ + "page": slug or "(root index)", + "file": str(md_file.relative_to(repo_root)), + "severity": "low", + "reason": ( + "Docs page is not referenced in src/sidebar.ts — add it to the " + "sidebar (and astro.config.mjs topic if new) or allow-list it " + "in the surface map's 'Unlisted docs pages' section" + ), + }) + return findings + +# --------------------------------------------------------------------------- +# Audit 9: Surface map hygiene # --------------------------------------------------------------------------- def audit_map_hygiene(surface_map: dict, flag_statuses: dict[str, str], cli_commands: list[dict], api_routes: list[dict], - slash_commands: list[str], docs_root: Path) -> list[dict]: + slash_commands: list[str], settings: dict[str, dict], + docs_root: Path) -> list[dict]: """Flag surface-map entries that reference code surfaces that no longer exist. Dead entries usually mean a feature was renamed or removed — verify the @@ -1140,6 +1783,25 @@ def audit_map_hygiene(surface_map: dict, flag_statuses: dict[str, str], ), }) + known_api = set() + for route in api_routes: + known_api.add(route["route"]) + rel_path = route["path"] + if rel_path.startswith("/api/v1"): + rel_path = rel_path[len("/api/v1"):] or "/" + known_api.add(f"{route['method']} {rel_path}") + for key in sorted(surface_map.get("api_to_doc", {})): + if key not in known_api: + findings.append({ + "entry": key, + "section": "API endpoints", + "severity": "low", + "reason": ( + f"Map entry '{key}' does not match any public API route in " + "code — verify and prune or update" + ), + }) + known_slash = set(slash_commands) for name in sorted(surface_map.get("slash_to_doc", {})): if name not in known_slash: @@ -1153,12 +1815,25 @@ def audit_map_hygiene(surface_map: dict, flag_statuses: dict[str, str], ), }) + for key in sorted(surface_map.get("settings_to_doc", {})): + if key not in settings: + findings.append({ + "entry": key, + "section": "Settings", + "severity": "low", + "reason": ( + f"Map entry '{key}' does not match any setting in code — " + "verify and prune or update" + ), + }) + # Mapped doc targets that no longer exist (any section). for section, mapping in ( ("Feature flags", surface_map.get("feature_to_doc", {})), ("CLI commands", surface_map.get("cli_to_doc", {})), ("API endpoints", surface_map.get("api_to_doc", {})), ("Slash commands", surface_map.get("slash_to_doc", {})), + ("Settings", surface_map.get("settings_to_doc", {})), ): for key, doc_path in sorted(mapping.items()): if doc_path == "internal": @@ -1181,7 +1856,10 @@ def audit_map_hygiene(surface_map: dict, flag_statuses: dict[str, str], # --------------------------------------------------------------------------- def build_snapshot(flag_statuses: dict[str, str], cli_commands: list[dict], - api_routes: list[dict], slash_commands: list[str], + cli_flags: dict[str, list[str]], api_routes: list[dict], + slash_commands: list[str], settings: dict[str, dict], + web_routes: list[str], server_tools: list[str], + bundled_skills: dict[str, str], changelog_entries: list[dict]) -> dict: """Assemble the surface snapshot (deterministic ordering for clean diffs).""" cli_flat = [] @@ -1191,12 +1869,22 @@ def build_snapshot(flag_statuses: dict[str, str], cli_commands: list[dict], cli_flat.append({"command": sub["command"], "hidden": sub["hidden"]}) cli_flat.sort(key=lambda c: c["command"]) + settings_status = { + path: setting_status(info, flag_statuses) + for path, info in settings.items() + } + return { "schema_version": SNAPSHOT_SCHEMA_VERSION, "flags": dict(sorted(flag_statuses.items())), "cli_commands": cli_flat, + "cli_flags": {k: sorted(v) for k, v in sorted(cli_flags.items())}, "api_routes": sorted(r["route"] for r in api_routes), "slash_commands": sorted(slash_commands), + "settings": dict(sorted(settings_status.items())), + "web_routes": sorted(web_routes), + "server_tools": sorted(server_tools), + "bundled_skills": dict(sorted(bundled_skills.items())), "changelog_last_version": ( changelog_entries[0]["version"] if changelog_entries else None ), @@ -1213,6 +1901,38 @@ def load_snapshot(path: Path) -> dict | None: return None +def _diff_sets(findings: list, old: dict, new: dict, field: str, label: str, + added_reason: str, removed_reason: str, severity: str = "medium"): + """Generic added/removed diff for a snapshot list field.""" + if field not in old: + findings.append({ + "change": "surface_type_added", + "surface": field, + "severity": "low", + "reason": ( + f"The snapshot now tracks {label} — baseline established this " + "run; future runs will diff it (regenerate with --update-snapshot)" + ), + }) + return + old_set = set(old.get(field) or []) + new_set = set(new.get(field) or []) + for item in sorted(new_set - old_set): + findings.append({ + "change": f"{field}_added", + "surface": item, + "severity": severity, + "reason": added_reason.format(item=item), + }) + for item in sorted(old_set - new_set): + findings.append({ + "change": f"{field}_removed", + "surface": item, + "severity": severity, + "reason": removed_reason.format(item=item), + }) + + def diff_snapshots(old: dict, new: dict) -> list[dict]: """Compare two snapshots and report added/removed/promoted surfaces.""" findings = [] @@ -1283,51 +2003,175 @@ def diff_snapshots(old: dict, new: dict) -> list[dict]: ), }) - old_api = set(old.get("api_routes", [])) - new_api = set(new.get("api_routes", [])) - for route in sorted(new_api - old_api): + # Per-module CLI flag changes. + if "cli_flags" not in old: findings.append({ - "change": "api_added", - "surface": route, - "severity": "medium", + "change": "surface_type_added", + "surface": "cli_flags", + "severity": "low", "reason": ( - f"New public API route '{route}' — add it to the OpenAPI spec " - "(sync-openapi-spec skill) or map it as internal" - ), - }) - for route in sorted(old_api - new_api): - findings.append({ - "change": "api_removed", - "surface": route, - "severity": "medium", - "reason": ( - f"Public API route '{route}' was removed — verify the OpenAPI " - "spec and API docs no longer document it" + "The snapshot now tracks CLI --flags per module — baseline " + "established this run; future runs will diff it" ), }) + else: + old_flags_by_module = old.get("cli_flags") or {} + new_flags_by_module = new.get("cli_flags") or {} + for module in sorted(set(old_flags_by_module) | set(new_flags_by_module)): + old_set = set(old_flags_by_module.get(module, [])) + new_set = set(new_flags_by_module.get(module, [])) + for flag in sorted(new_set - old_set): + findings.append({ + "change": "cli_flag_added", + "surface": f"{module}: {flag}", + "severity": "low", + "reason": ( + f"New CLI flag '{flag}' in warp_cli/src/{module}.rs — " + "verify the CLI reference documents it" + ), + }) + for flag in sorted(old_set - new_set): + findings.append({ + "change": "cli_flag_removed", + "surface": f"{module}: {flag}", + "severity": "low", + "reason": ( + f"CLI flag '{flag}' removed from warp_cli/src/{module}.rs — " + "verify the CLI reference no longer documents it" + ), + }) - old_slash = set(old.get("slash_commands", [])) - new_slash = set(new.get("slash_commands", [])) - for name in sorted(new_slash - old_slash): + _diff_sets( + findings, old, new, "api_routes", "public API routes", + "New public API route '{item}' — add it to the OpenAPI spec " + "(sync-openapi-spec skill) or map it as internal", + "Public API route '{item}' was removed — verify the OpenAPI spec and " + "API docs no longer document it", + ) + _diff_sets( + findings, old, new, "slash_commands", "slash commands", + "New slash command '{item}' — add it to the slash-commands docs page " + "or map it as internal", + "Slash command '{item}' was removed — update the slash-commands docs " + "page and surface map", + ) + _diff_sets( + findings, old, new, "web_routes", "Oz web app routes", + "New Oz web app route '{item}' — verify the Oz web app docs cover the " + "new page", + "Oz web app route '{item}' was removed — verify the Oz web app docs " + "no longer reference it", + ) + _diff_sets( + findings, old, new, "server_tools", "server-side agent tools", + "New agent tool '{item}' — verify docs cover the new agent capability", + "Agent tool '{item}' was removed — verify docs no longer describe it", + severity="low", + ) + + # Settings (dict field: path -> status). + if "settings" not in old: findings.append({ - "change": "slash_added", - "surface": name, - "severity": "medium", + "change": "surface_type_added", + "surface": "settings", + "severity": "low", "reason": ( - f"New slash command '{name}' — add it to the slash-commands docs " - "page or map it as internal" + "The snapshot now tracks settings — baseline established this " + "run; future runs will diff it" ), }) - for name in sorted(old_slash - new_slash): + else: + old_settings = old.get("settings") or {} + new_settings = new.get("settings") or {} + for path in sorted(set(new_settings) - set(old_settings)): + status = new_settings[path] + user_facing = status in ("always_on", "ga", "preview") + findings.append({ + "change": "setting_added", + "surface": path, + "detail": f"status: {status}", + "severity": "medium" if user_facing else "low", + "reason": ( + f"New setting '{path}' ({status}) — " + + ("document it in the all-settings reference" + if user_facing else "track it; document on promotion") + ), + }) + for path in sorted(set(old_settings) - set(new_settings)): + findings.append({ + "change": "setting_removed", + "surface": path, + "detail": f"was: {old_settings[path]}", + "severity": "medium", + "reason": ( + f"Setting '{path}' was removed or renamed — update the " + "all-settings reference" + ), + }) + for path in sorted(set(old_settings) & set(new_settings)): + if old_settings[path] == new_settings[path]: + continue + now_user_facing = new_settings[path] in ("always_on", "ga", "preview") + findings.append({ + "change": "setting_status_changed", + "surface": path, + "detail": f"{old_settings[path]} -> {new_settings[path]}", + "severity": "medium" if now_user_facing else "low", + "reason": ( + f"Setting '{path}' moved {old_settings[path]} -> " + f"{new_settings[path]}" + + (" — verify the all-settings reference documents it" + if now_user_facing else "") + ), + }) + + # Bundled skills (dict field: name -> channel). + if "bundled_skills" not in old: findings.append({ - "change": "slash_removed", - "surface": name, - "severity": "medium", + "change": "surface_type_added", + "surface": "bundled_skills", + "severity": "low", "reason": ( - f"Slash command '{name}' was removed — update the slash-commands " - "docs page and surface map" + "The snapshot now tracks bundled skills — baseline established " + "this run; future runs will diff it" ), }) + else: + old_skills = old.get("bundled_skills") or {} + new_skills = new.get("bundled_skills") or {} + for name in sorted(set(new_skills) - set(old_skills)): + findings.append({ + "change": "bundled_skill_added", + "surface": name, + "detail": f"channel: {new_skills[name]}", + "severity": "medium" if new_skills[name] == "bundled" else "low", + "reason": ( + f"New bundled skill '{name}' ({new_skills[name]}) — verify " + "the skills docs cover it" + ), + }) + for name in sorted(set(old_skills) - set(new_skills)): + findings.append({ + "change": "bundled_skill_removed", + "surface": name, + "severity": "low", + "reason": ( + f"Bundled skill '{name}' was removed — verify docs no " + "longer reference it" + ), + }) + for name in sorted(set(old_skills) & set(new_skills)): + if old_skills[name] != new_skills[name]: + findings.append({ + "change": "bundled_skill_channel_changed", + "surface": name, + "detail": f"{old_skills[name]} -> {new_skills[name]}", + "severity": "medium" if new_skills[name] == "bundled" else "low", + "reason": ( + f"Bundled skill '{name}' moved channel " + f"{old_skills[name]} -> {new_skills[name]} — verify docs" + ), + }) return findings @@ -1371,12 +2215,18 @@ def changelog_review_findings(changelog_entries: list[dict], lambda i: i.get("route", "")), ("undocumented_slash_commands", "UNDOCUMENTED SLASH COMMANDS", lambda i: i.get("command", "")), + ("undocumented_settings", "UNDOCUMENTED SETTINGS", + lambda i: i.get("setting", "")), ("surface_changes", "SURFACE CHANGES SINCE SNAPSHOT", lambda i: f"{i.get('change', '')}: {i.get('surface', '')}"), ("changelog_review", "CHANGELOG ITEMS TO VERIFY", lambda i: f"{i.get('version', '')} [{i.get('category', '')}] {i.get('text', '')[:100]}"), ("map_hygiene", "SURFACE MAP HYGIENE", lambda i: f"{i.get('section', '')}: {i.get('entry', '')}"), + ("stale_doc_references", "STALE DOC REFERENCES", + lambda i: f"{i.get('kind', '')}: {i.get('reference', '')}"), + ("unlisted_pages", "PAGES MISSING FROM SIDEBAR", + lambda i: i.get("page", "")), ("potentially_stale_docs", "POTENTIALLY STALE DOCS", lambda i: i.get("doc_path", "")), ] @@ -1445,6 +2295,10 @@ def print_report(report: dict) -> None: print(f" Source: {item['source_file']}") if item.get("handler_file"): print(f" Handler: {item['handler_file']}") + if item.get("doc_page"): + print(f" Doc page: {item['doc_page']}") + if item.get("file"): + print(f" File: {item['file']}") if item.get("detail"): print(f" Detail: {item['detail']}") for t in item.get("stale_terms", []): @@ -1475,7 +2329,8 @@ def main(): ) parser.add_argument( "--category", - choices=["features", "cli", "api", "slash", "staleness", "map"], + choices=["features", "cli", "api", "slash", "settings", "structure", + "staleness", "map"], help="Run only a specific audit category", ) parser.add_argument( @@ -1543,24 +2398,56 @@ def main(): findings: dict[str, list] = {} audits_run: list[str] = [] audits_skipped: list[dict] = [] + extraction_ok = True - needs_internal = args.category in (None, "features", "cli", "slash", "staleness", "map") \ + def guard(label: str, count: int) -> bool: + nonlocal extraction_ok + floor = EXTRACTION_FLOORS.get(label, 1) + if count < floor: + extraction_ok = False + audits_skipped.append({ + "audit": f"extraction:{label}", + "reason": ( + f"only {count} {label} extracted (expected >= {floor}) — " + "the source layout likely changed; fix the parser in " + "audit_docs.py before trusting any results" + ), + }) + return False + return True + + internal_categories = ("features", "cli", "slash", "settings", "staleness", "map") + needs_internal = args.category in (None, *internal_categories) \ or args.diff or args.update_snapshot needs_server = args.category in (None, "api", "map") \ or args.diff or args.update_snapshot flag_statuses: dict[str, str] = {} cli_commands: list[dict] = [] + cli_flags: dict[str, list[str]] = {} slash_commands: list[str] = [] + settings: dict[str, dict] = {} api_routes: list[dict] = [] + web_routes: list[str] = [] + server_tools: list[str] = [] + bundled_skills: dict[str, str] = {} if warp_internal and needs_internal: print(f"Using warp-internal: {warp_internal}", file=sys.stderr) flag_statuses = compute_flag_statuses(warp_internal) cli_commands = parse_cli_commands(warp_internal) + cli_flags = parse_cli_flags(warp_internal, cli_commands) slash_commands = parse_slash_commands(warp_internal) + print("Parsing settings registry...", file=sys.stderr) + settings = parse_settings(warp_internal) + bundled_skills = parse_bundled_skills(warp_internal) + + flags_ok = guard("feature flags", len(flag_statuses)) + cli_ok = guard("CLI commands", len(cli_commands)) + slash_ok = guard("slash commands", len(slash_commands)) + settings_ok = guard("settings", len(settings)) - if args.category in (None, "features"): + if args.category in (None, "features") and flags_ok: print("Running feature flag coverage audit...", file=sys.stderr) findings["undocumented_features"] = audit_features( warp_internal, docs_root, surface_map, docs_text, @@ -1568,25 +2455,39 @@ def main(): ) audits_run.append("features") - if args.category in (None, "cli"): + if args.category in (None, "cli") and cli_ok: print("Running CLI command coverage audit...", file=sys.stderr) findings["undocumented_cli_commands"] = audit_cli( - warp_internal, docs_root, surface_map, docs_text) + warp_internal, docs_root, surface_map, docs_text, + cli_commands=cli_commands) audits_run.append("cli") - if args.category in (None, "slash"): + if args.category in (None, "slash") and slash_ok: print("Running slash command coverage audit...", file=sys.stderr) findings["undocumented_slash_commands"] = audit_slash_commands( - warp_internal, docs_root, surface_map, docs_text) + warp_internal, docs_root, surface_map, docs_text, + slash_commands=slash_commands) audits_run.append("slash") + if args.category in (None, "settings") and settings_ok and flags_ok: + print("Running settings coverage audit...", file=sys.stderr) + findings["undocumented_settings"] = audit_settings( + docs_root, surface_map, settings, flag_statuses) + audits_run.append("settings") + if args.category in (None, "staleness"): print("Running docs staleness audit...", file=sys.stderr) findings["potentially_stale_docs"] = audit_staleness( warp_internal, docs_root, docs_text) + # The reverse checks compare docs against extracted code surfaces, + # so they are only meaningful when extraction is healthy. + if flags_ok and settings_ok: + print("Running stale doc reference audit...", file=sys.stderr) + findings["stale_doc_references"] = audit_stale_doc_references( + warp_internal, docs_root, settings) audits_run.append("staleness") elif needs_internal: - for audit in ("features", "cli", "slash", "staleness"): + for audit in ("features", "cli", "slash", "settings", "staleness"): if args.category in (None, audit): audits_skipped.append({ "audit": audit, @@ -1596,10 +2497,14 @@ def main(): if warp_server and needs_server: print(f"Using warp-server: {warp_server}", file=sys.stderr) api_routes = parse_public_api_routes(warp_server) - if args.category in (None, "api"): + web_routes = parse_webapp_routes(warp_server) + server_tools = parse_server_tools(warp_server) + api_ok = guard("API routes", len(api_routes)) + if args.category in (None, "api") and api_ok: print("Running API endpoint coverage audit...", file=sys.stderr) findings["undocumented_api_endpoints"] = audit_api( - warp_server, docs_root, surface_map, docs_text) + warp_server, docs_root, surface_map, docs_text, + api_routes=api_routes) audits_run.append("api") elif needs_server: if args.category in (None, "api"): @@ -1608,27 +2513,39 @@ def main(): "reason": "warp-server repo not found (pass --warp-server)", }) + # Docs structure audit needs only the docs repo. + if args.category in (None, "structure"): + print("Running docs structure audit (sidebar coverage)...", file=sys.stderr) + findings["unlisted_pages"] = audit_unlisted_pages( + repo_root, docs_root, surface_map) + audits_run.append("structure") + if args.category in (None, "map"): - if warp_internal and warp_server: + if warp_internal and warp_server and extraction_ok: print("Running surface map hygiene audit...", file=sys.stderr) findings["map_hygiene"] = audit_map_hygiene( surface_map, flag_statuses, cli_commands, api_routes, - slash_commands, docs_root) + slash_commands, settings, docs_root) audits_run.append("map") else: audits_skipped.append({ "audit": "map", - "reason": "requires both warp-internal and warp-server", + "reason": ( + "requires both warp-internal and warp-server with healthy " + "extraction (dead-entry checks against empty extraction " + "would flag everything)" + ), }) # Change detection (diff + snapshot update) changelog_entries = parse_changelog_entries(repo_root) snapshot_path = Path(args.snapshot) if args.diff or args.update_snapshot: - if warp_internal and warp_server: + if warp_internal and warp_server and extraction_ok: current_snapshot = build_snapshot( - flag_statuses, cli_commands, api_routes, slash_commands, - changelog_entries) + flag_statuses, cli_commands, cli_flags, api_routes, + slash_commands, settings, web_routes, server_tools, + bundled_skills, changelog_entries) if args.diff: previous = load_snapshot(snapshot_path) if previous is None: @@ -1655,7 +2572,12 @@ def main(): else: audits_skipped.append({ "audit": "diff" if args.diff else "update-snapshot", - "reason": "requires both warp-internal and warp-server", + "reason": ( + "requires both warp-internal and warp-server with healthy " + "extraction (see extraction:* skips above)" + if not extraction_ok + else "requires both warp-internal and warp-server" + ), }) # Filter by severity From 3ea28b65d2864ebaa9254a497b42d7f5b3fccc08 Mon Sep 17 00:00:00 2001 From: hongyi-chen Date: Thu, 11 Jun 2026 16:45:08 +0000 Subject: [PATCH 3/4] Add completeness accounting and map integrity checks; reclassify GA flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Triple-check that every feature is encapsulated in the mapping: - Built-in completeness accounting on every full run: partitions every extracted surface item (277 flags, 74 CLI commands, 71 API routes, 47 slash commands, 201 settings) into exactly one accountability bucket (mapped / ignored / doc-covered / visible finding / snapshot-tracked) and exits 2 with integrity:accounting if anything escapes — an unaccounted item can only mean the audit logic regressed - Map integrity checks in hygiene: entries in both the mapping and the ignore list (ignore silently wins) and duplicate keys within a section are now medium findings - Ignore-list review against computed statuses found ~20 flags filed under 'Non-GA' that have since GA'd: reclassified 15 user-facing ones to real mappings (session sharing trio, AgentHarness, SshRemoteServer, ArtifactCommand, OzIdentityFederation, image-context pair, OzPlatformSkills, WorkflowAliases, ShellSelector, KittyImages, UndoClosedPanes, RevertDiffHunk), surfaced FullScreenZenMode as a visible undocumented-feature finding, and retitled the section so placement no longer asserts rollout status - Re-baselined the snapshot after live drift the diff caught on today's checkouts (SuperGrok dogfood->ga, new /rename-conversation slash command — both now standing coverage findings) - SKILL.md: documented the accounting contract and the end-to-end 'how every change path is caught' chain (new/promoted/removed surfaces, no-code-change launches via changelog net, parser rot via extraction guards, map rot via hygiene) Co-Authored-By: Oz --- .agents/skills/missing_docs/SKILL.md | 45 +++ .../references/surface_snapshot.json | 3 +- .../skills/missing_docs/scripts/audit_docs.py | 279 +++++++++++++++++- 3 files changed, 313 insertions(+), 14 deletions(-) diff --git a/.agents/skills/missing_docs/SKILL.md b/.agents/skills/missing_docs/SKILL.md index 3fe1106a..aedec623 100644 --- a/.agents/skills/missing_docs/SKILL.md +++ b/.agents/skills/missing_docs/SKILL.md @@ -108,6 +108,51 @@ Adjacent checks owned by other skills (do not duplicate them here): - Broken links and 404s/redirects → `check_for_broken_links` / `weekly-404-monitor` - Terminology/style sweeps → `style_lint` +### Completeness accounting (the no-slip guarantee) + +Every full run computes a completeness accounting and embeds it in the report +(`summary.accounting` in JSON, a `COMPLETENESS ACCOUNTING` block in the printed +output). It partitions every extracted surface item into exactly one +accountability bucket and proves totality: +- **Feature flags**: every GA/Preview flag is `mapped` (surface map verified), + `ignored` (curated internal list), or a visible `finding`; every dogfood/other + flag is `tracked_non_ga` (snapshot diff fires on promotion or removal). +- **CLI commands**: `mapped`, `doc_covered`, `finding`, `parent_flagged` + (suppressed because the parent command is already flagged), or `hidden`. +- **API routes**: `mapped`, `spec_covered`, `docs_covered`, or `finding`. +- **Slash commands**: `mapped`, `doc_covered`, or `finding`. +- **Settings**: `private`, `tracked_non_ga`, `mapped`, `doc_covered`, or `finding`. + +If any item escapes every bucket, the run reports `integrity:accounting` in +`audits_skipped` and exits 2 — an unaccounted item means the audit logic itself +regressed, never that the item is fine. Map hygiene additionally rejects +integrity bugs in the surface map: entries that are both mapped and ignored +(the ignore silently wins) and duplicate keys within a section. + +How every change path is caught, end to end: +1. **New surface item appears** (flag, command, route, slash, setting, web + route, tool, bundled skill) → the snapshot `--diff` reports it AND, once + GA/user-facing, the coverage audit produces a standing finding until it is + documented + mapped or ignored with a comment. +2. **Item is promoted** (dogfood→preview→ga, setting status change, skill + channel change) → `--diff` status-change finding + coverage finding appears. +3. **Item is removed/renamed** → `--diff` removal finding + map hygiene flags + the dead map entry + stale-doc-reference checks flag docs still naming it. +4. **Launch with no client-code change** (server-side experiment flips to 100%, + Oz web app backend feature) → the changelog cross-check is the net: every + "New features"/"Improvements"/"Oz updates" bullet newer than the snapshot + becomes a verification finding. +5. **The audit itself rots** (source layout moves, parser breaks) → extraction + sanity guards trip, dependent audits skip, exit 2. +6. **The map rots** (dead entries, conflicts, duplicates, missing doc targets, + unmapped-but-mentioned features) → map hygiene + fallback-transparency + findings keep pressure until fixed. + +The mapping is updated through three enforced paths: Phase 3 step 8 makes the +map+snapshot update a mandatory part of drafting; the drift-watch triage step +requires a mapping/ignore/allowlist decision for every finding; and map hygiene +findings force pruning when code moves underneath the map. + ### Phase 2: Change detection (diff mode) The snapshot at `references/surface_snapshot.json` records all extracted surfaces diff --git a/.agents/skills/missing_docs/references/surface_snapshot.json b/.agents/skills/missing_docs/references/surface_snapshot.json index 71d9abb3..46d4a0cd 100644 --- a/.agents/skills/missing_docs/references/surface_snapshot.json +++ b/.agents/skills/missing_docs/references/surface_snapshot.json @@ -247,7 +247,7 @@ "SummarizationCancellationConfirmation": "ga", "SummarizationConversationCommand": "ga", "SummarizationViaMessageReplacement": "dogfood", - "SuperGrok": "dogfood", + "SuperGrok": "ga", "SyncAmbientPlans": "ga", "TabCloseButtonOnLeft": "ga", "TabConfigs": "ga", @@ -822,6 +822,7 @@ "/prompts", "/queue", "/remote-control", + "/rename-conversation", "/rename-tab", "/rewind", "/set-tab-color", diff --git a/.agents/skills/missing_docs/scripts/audit_docs.py b/.agents/skills/missing_docs/scripts/audit_docs.py index 928cd458..b7149d09 100755 --- a/.agents/skills/missing_docs/scripts/audit_docs.py +++ b/.agents/skills/missing_docs/scripts/audit_docs.py @@ -79,7 +79,11 @@ # --------------------------------------------------------------------------- def parse_surface_map(path: Path) -> dict: - """Parse the feature_surface_map.md into structured data.""" + """Parse the feature_surface_map.md into structured data. + + Duplicate keys within a section are recorded in `duplicates` so map + hygiene can flag them (the last occurrence silently wins otherwise). + """ result = { "feature_to_doc": {}, "cli_to_doc": {}, @@ -88,6 +92,7 @@ def parse_surface_map(path: Path) -> dict: "settings_to_doc": {}, "ignore_flags": set(), "unlisted_ignore": set(), + "duplicates": [], } if not path.exists(): return result @@ -113,9 +118,13 @@ def parse_surface_map(path: Path) -> dict: continue if current_section == "ignore": + if line in result["ignore_flags"]: + result["duplicates"].append(("Flags to ignore", line)) result["ignore_flags"].add(line) continue if current_section == "unlisted": + if line in result["unlisted_ignore"]: + result["duplicates"].append(("Unlisted docs pages", line)) result["unlisted_ignore"].add(line) continue @@ -123,16 +132,18 @@ def parse_surface_map(path: Path) -> dict: key, doc_path = line.split(" -> ", 1) key = key.strip() doc_path = doc_path.strip() - if current_section == "features": - result["feature_to_doc"][key] = doc_path - elif current_section == "cli": - result["cli_to_doc"][key] = doc_path - elif current_section == "api": - result["api_to_doc"][key] = doc_path - elif current_section == "slash": - result["slash_to_doc"][key] = doc_path - elif current_section == "settings": - result["settings_to_doc"][key] = doc_path + section_targets = { + "features": ("Feature flags", result["feature_to_doc"]), + "cli": ("CLI commands", result["cli_to_doc"]), + "api": ("API endpoints", result["api_to_doc"]), + "slash": ("Slash commands", result["slash_to_doc"]), + "settings": ("Settings", result["settings_to_doc"]), + } + if current_section in section_targets: + section_name, mapping = section_targets[current_section] + if key in mapping: + result["duplicates"].append((section_name, key)) + mapping[key] = doc_path return result @@ -1742,6 +1753,31 @@ def audit_map_hygiene(surface_map: dict, flag_statuses: dict[str, str], repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent known_flags = set(flag_statuses) + # Map integrity: a flag must not be both mapped and ignored (the audit + # checks the ignore list first, so the mapping would silently lose). + for flag in sorted(set(surface_map.get("feature_to_doc", {})) + & surface_map.get("ignore_flags", set())): + findings.append({ + "entry": flag, + "section": "Feature flags + Flags to ignore", + "severity": "medium", + "reason": ( + f"'{flag}' appears in BOTH the feature mapping and the ignore " + "list — the ignore entry wins silently; remove one" + ), + }) + # Map integrity: duplicate keys within a section (last occurrence wins). + for section_name, key in surface_map.get("duplicates", []): + findings.append({ + "entry": key, + "section": section_name, + "severity": "medium", + "reason": ( + f"Duplicate entry '{key}' in the {section_name} section — the " + "last occurrence silently wins; remove the extra line" + ), + }) + for flag in sorted(surface_map.get("feature_to_doc", {})): if flag not in known_flags: findings.append({ @@ -2202,6 +2238,183 @@ def changelog_review_findings(changelog_entries: list[dict], }) return findings +# --------------------------------------------------------------------------- +# Completeness accounting +# --------------------------------------------------------------------------- + +def compute_accounting(docs_root: Path, surface_map: dict, findings: dict, + flag_statuses: dict[str, str], cli_commands: list[dict], + api_routes: list[dict], slash_commands: list[str], + settings: dict[str, dict], + docs_text: dict[str, str]) -> dict: + """Partition every extracted surface item into exactly one accountability + bucket and prove totality. + + Every item must be mapped, ignored, covered by docs, a visible finding, or + snapshot-tracked (non-GA). `unaccounted` lists anything that escapes all + buckets — it must be empty; a non-empty list means the audit logic + regressed and the run is treated as incomplete (exit 2). + """ + repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent + acc: dict = {} + unaccounted: dict[str, list[str]] = {} + + # Feature flags --------------------------------------------------------- + mapped = set(surface_map.get("feature_to_doc", {})) + ignored = surface_map.get("ignore_flags", set()) + flag_findings = {f.get("flag") for f in findings.get("undocumented_features", [])} + fb = {"total": len(flag_statuses), "ga_preview": 0, "ignored": 0, + "mapped": 0, "finding": 0, "tracked_non_ga": 0} + missing = [] + for flag, status in flag_statuses.items(): + if status not in ("ga", "preview"): + fb["tracked_non_ga"] += 1 + continue + fb["ga_preview"] += 1 + if flag in ignored: + fb["ignored"] += 1 + elif flag in mapped: + fb["mapped"] += 1 + elif flag in flag_findings: + fb["finding"] += 1 + else: + missing.append(flag) + if missing: + unaccounted["feature_flags"] = missing + acc["feature_flags"] = fb + + # CLI commands ----------------------------------------------------------- + cli_map = surface_map.get("cli_to_doc", {}) + cli_findings = {f.get("command") for f in findings.get("undocumented_cli_commands", [])} + cli_text = {} + cli_docs_dir = docs_root / "reference" / "cli" + if cli_docs_dir.exists(): + for f in find_markdown_files(cli_docs_dir): + try: + cli_text[str(f)] = f.read_text(encoding="utf-8").lower() + except Exception: + pass + cb = {"total": 0, "hidden": 0, "mapped": 0, "doc_covered": 0, + "finding": 0, "parent_flagged": 0} + missing = [] + for cmd in cli_commands: + entries = [(cmd["command"], cmd["hidden"], None)] + [ + (s["command"], s["hidden"], cmd["command"]) for s in cmd["subcommands"]] + for name, hidden, parent in entries: + cb["total"] += 1 + if hidden: + cb["hidden"] += 1 + elif name in cli_map: + cb["mapped"] += 1 + elif any(name.split(" ", 1)[1] in t for t in cli_text.values()): + cb["doc_covered"] += 1 + elif name in cli_findings: + cb["finding"] += 1 + elif parent in cli_findings: + cb["parent_flagged"] += 1 + else: + missing.append(name) + if missing: + unaccounted["cli_commands"] = missing + acc["cli_commands"] = cb + + # API routes ------------------------------------------------------------- + api_map = surface_map.get("api_to_doc", {}) + api_findings = {f.get("route") for f in findings.get("undocumented_api_endpoints", [])} + openapi_candidates = [ + repo_root / "developers" / "agent-api-openapi.yaml", + docs_root / "developers" / "agent-api-openapi.yaml", + ] + openapi_path = next((c for c in openapi_candidates if c.exists()), openapi_candidates[0]) + openapi_text = "" + if openapi_path.exists(): + try: + openapi_text = openapi_path.read_text(encoding="utf-8").lower() + except Exception: + pass + spec_paths = parse_openapi_paths(openapi_text) + api_docs_text = {} + api_docs_dir = docs_root / "reference" / "api-and-sdk" + if api_docs_dir.exists(): + for f in find_markdown_files(api_docs_dir): + try: + api_docs_text[str(f)] = f.read_text(encoding="utf-8").lower() + except Exception: + pass + ab = {"total": len(api_routes), "mapped": 0, "spec_covered": 0, + "docs_covered": 0, "finding": 0} + missing = [] + for route in api_routes: + rel = route["path"] + if rel.startswith("/api/v1"): + rel = rel[len("/api/v1"):] or "/" + rel_str = f"{route['method']} {rel}" + if route["route"] in api_map or rel_str in api_map: + ab["mapped"] += 1 + elif (_normalize_path_params(rel) in spec_paths + or _normalize_path_params(route["path"]) in spec_paths): + ab["spec_covered"] += 1 + elif any(c in openapi_text or any(c in t for t in api_docs_text.values()) + for c in {route["path"].lower(), rel.lower()}): + ab["docs_covered"] += 1 + elif rel_str in api_findings: + ab["finding"] += 1 + else: + missing.append(rel_str) + if missing: + unaccounted["api_routes"] = missing + acc["api_routes"] = ab + + # Slash commands ---------------------------------------------------------- + slash_map = surface_map.get("slash_to_doc", {}) + slash_findings = {f.get("command") for f in findings.get("undocumented_slash_commands", [])} + sb = {"total": len(slash_commands), "mapped": 0, "doc_covered": 0, "finding": 0} + missing = [] + for name in slash_commands: + if name in slash_map: + sb["mapped"] += 1 + elif any(_slash_mention_re(name).search(t) for t in docs_text.values()): + sb["doc_covered"] += 1 + elif name in slash_findings: + sb["finding"] += 1 + else: + missing.append(name) + if missing: + unaccounted["slash_commands"] = missing + acc["slash_commands"] = sb + + # Settings ---------------------------------------------------------------- + settings_map = surface_map.get("settings_to_doc", {}) + setting_findings = {f.get("setting") for f in findings.get("undocumented_settings", [])} + doc_sections, _ = parse_settings_doc(docs_root) + tb = {"total": len(settings), "private": 0, "tracked_non_ga": 0, + "mapped": 0, "doc_covered": 0, "finding": 0} + missing = [] + for path, info in settings.items(): + status = setting_status(info, flag_statuses) + if status == "private": + tb["private"] += 1 + continue + if status in ("dogfood", "other"): + tb["tracked_non_ga"] += 1 + continue + hierarchy, _, key = path.rpartition(".") + if path in settings_map: + tb["mapped"] += 1 + elif (key in doc_sections.get(hierarchy, set()) or path in doc_sections + or any(s.startswith(path + ".") for s in doc_sections)): + tb["doc_covered"] += 1 + elif path in setting_findings: + tb["finding"] += 1 + else: + missing.append(path) + if missing: + unaccounted["settings"] = missing + acc["settings"] = tb + + acc["unaccounted"] = unaccounted + return acc + # --------------------------------------------------------------------------- # Report generation # --------------------------------------------------------------------------- @@ -2233,7 +2446,8 @@ def changelog_review_findings(changelog_entries: list[dict], def generate_report(findings_by_category: dict[str, list], audits_run: list[str], - audits_skipped: list[dict], mode: str) -> dict: + audits_skipped: list[dict], mode: str, + accounting: dict | None = None) -> dict: """Assemble the full audit report.""" total = sum(len(v) for v in findings_by_category.values()) report = { @@ -2248,6 +2462,8 @@ def generate_report(findings_by_category: dict[str, list], audits_run: list[str] }, }, } + if accounting is not None: + report["summary"]["accounting"] = accounting for key, _, _ in REPORT_CATEGORIES: report[key] = findings_by_category.get(key, []) return report @@ -2271,6 +2487,24 @@ def print_report(report: dict) -> None: print(f" {category}: {count}") print() + accounting = summary.get("accounting") + if accounting: + print("-" * 60) + print("COMPLETENESS ACCOUNTING (every item in exactly one bucket)") + print("-" * 60) + for surface, buckets in accounting.items(): + if surface == "unaccounted": + continue + parts = ", ".join(f"{k}={v}" for k, v in buckets.items()) + print(f" {surface}: {parts}") + if accounting.get("unaccounted"): + print(" !! UNACCOUNTED ITEMS (audit logic regression):") + for surface, items in accounting["unaccounted"].items(): + print(f" {surface}: {items}") + else: + print(" unaccounted: none — every extracted surface item is accounted for") + print() + severity_order = {"high": 0, "medium": 1, "low": 2} for key, title, describe in REPORT_CATEGORIES: @@ -2580,6 +2814,24 @@ def guard(label: str, count: int) -> bool: ), }) + # Completeness accounting: prove every extracted surface item lands in + # exactly one accountability bucket. Runs on full audits with healthy + # extraction; any unaccounted item means an audit-logic regression and + # the run is treated as incomplete. + accounting = None + if args.category is None and warp_internal and warp_server and extraction_ok: + accounting = compute_accounting( + docs_root, surface_map, findings, flag_statuses, cli_commands, + api_routes, slash_commands, settings, docs_text) + if accounting["unaccounted"]: + audits_skipped.append({ + "audit": "integrity:accounting", + "reason": ( + "surface items escaped every accountability bucket " + f"(audit logic regression): {accounting['unaccounted']}" + ), + }) + # Filter by severity if args.severity: severity_order = {"high": 0, "medium": 1, "low": 2} @@ -2591,7 +2843,8 @@ def guard(label: str, count: int) -> bool: ] mode = "diff" if args.diff else "audit" - report = generate_report(findings, audits_run, audits_skipped, mode) + report = generate_report(findings, audits_run, audits_skipped, mode, + accounting=accounting) print_report(report) From c6710671858041a1523620f63f7068af839673df Mon Sep 17 00:00:00 2001 From: hongyi-chen Date: Thu, 11 Jun 2026 20:56:23 +0000 Subject: [PATCH 4/4] Target the public warp repo instead of warp-internal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Warp's client code is open source at warpdotdev/warp — the audit now treats the public repo as the primary source: - Repo auto-detection prefers a sibling checkout named 'warp' and falls back to 'warp-internal' for transitional environments - New --warp flag is the primary CLI option; --warp-internal remains as a deprecated alias (same destination) so existing invocations keep working - All docstrings, stderr messages, skip reasons, section headers, and SKILL.md guidance (requirements, audit descriptions, drift-watch command, scheduled-agent prompt) now reference the public warp client repo Validated: auto-detect fallback, preferred 'warp' sibling resolution, explicit --warp, and the deprecated alias all run the full audit with clean completeness accounting. Co-Authored-By: Oz --- .agents/skills/missing_docs/SKILL.md | 29 +-- .../skills/missing_docs/scripts/audit_docs.py | 177 ++++++++++-------- 2 files changed, 109 insertions(+), 97 deletions(-) diff --git a/.agents/skills/missing_docs/SKILL.md b/.agents/skills/missing_docs/SKILL.md index aedec623..ea25c448 100644 --- a/.agents/skills/missing_docs/SKILL.md +++ b/.agents/skills/missing_docs/SKILL.md @@ -2,7 +2,7 @@ name: missing_docs description: >- Find and fill documentation gaps in Warp's Astro Starlight docs by auditing coverage - against code surfaces in warp-internal and warp-server, then drafting missing + against code surfaces in the public warp client repo and warp-server, then drafting missing pages. Use when asked to find missing docs, audit documentation coverage, identify undocumented features, draft docs for new features, detect doc-impacting code changes since the last audit, or do a docs coverage check. Runs a Python @@ -18,10 +18,11 @@ Find documentation gaps, detect doc-impacting code changes, and draft missing pa ## Requirements The audit compares docs against code, so both source repos must be available: -- `warp-internal` and `warp-server`, auto-detected as siblings of the docs repo - root (e.g. `/workspace/docs` next to `/workspace/warp-internal` and - `/workspace/warp-server`), or passed explicitly via `--warp-internal PATH` / - `--warp-server PATH`. +- the public warp client repo ([warpdotdev/warp](https://github.com/warpdotdev/warp)) + and `warp-server`, auto-detected as siblings of the docs repo root (e.g. + `/workspace/docs` next to `/workspace/warp` and `/workspace/warp-server`; a + sibling named `warp-internal` is accepted as a fallback), or passed explicitly + via `--warp PATH` / `--warp-server PATH` (`--warp-internal` is a deprecated alias). The script FAILS LOUD when a repo is missing OR when an extraction sanity guard trips (a parser returning implausibly few surfaces means the source layout @@ -45,7 +46,7 @@ Options: - `--severity high|medium|low` — filter by minimum severity - `--weak-coverage` — also flag GA features whose mapped doc exists but doesn't mention feature keywords (low-severity, noisy) - `--output report.json` — save JSON report to file -- `--warp-internal PATH` / `--warp-server PATH` — explicit repo paths +- `--warp PATH` / `--warp-server PATH` — explicit repo paths (`--warp-internal` is a deprecated alias) - `--diff` — change detection against the committed snapshot (see Phase 2) - `--update-snapshot` — regenerate `references/surface_snapshot.json` (full runs only) @@ -55,7 +56,7 @@ canonical filename even when the on-disk extension differs. The script performs these coverage audits: 1. **Feature flag coverage** — classifies every `FeatureFlag` by rollout status using - the cargo-feature→flag bridge in warp-internal `app/src/features.rs` plus + the cargo-feature→flag bridge in the warp client repo's `app/src/features.rs` plus `RELEASE_FLAGS`/`PREVIEW_FLAGS`/`DOGFOOD_FLAGS` in `crates/warp_features/src/lib.rs`. GA flags must be mapped in the surface map or covered in docs; Preview flags produce low-severity "docs needed soon" findings; dogfood/other flags are tracked by the @@ -71,11 +72,11 @@ The script performs these coverage audits: `{run_id}`) and the API reference docs. For spec drift, run the docs `sync-openapi-spec` skill (or warp-server's `update-open-api-spec`) instead of hand-editing the YAML. -4. **Slash command coverage** — parses the static registry in warp-internal +4. **Slash command coverage** — parses the static registry in the warp client repo's `app/src/search/slash_command_menu/static_commands/` and checks each `/command` is mentioned in docs. 5. **Settings coverage** — parses every `toml_path: "section.key"` setting - registration in warp-internal (the same registry the JSON-schema generator uses) + registration in the warp client repo (the same registry the JSON-schema generator uses) and checks the all-settings reference page documents it. Private and dogfood/other-flagged settings are exempt; object-typed settings documented as their own `[section]` count as covered. @@ -86,7 +87,7 @@ The script performs these coverage audits: 7. **Stale doc references** — reverse checks: settings keys documented in all-settings.mdx that no longer exist in code (catches renames like `agents.oz.*` → `agents.warp_agent.*`), and keybinding actions (`scope:action`) - on the keyboard-shortcuts page that no longer exist anywhere in warp-internal. + on the keyboard-shortcuts page that no longer exist anywhere in the warp client repo. 8. **Docs structure** — pages on disk that are missing from `src/sidebar.ts` (built but unreachable through navigation). Intentionally unlisted pages go in the surface map's "Unlisted docs pages" section. @@ -159,7 +160,7 @@ The snapshot at `references/surface_snapshot.json` records all extracted surface (flags + rollout status, CLI commands and per-module flags, API routes, slash commands, settings + status, Oz web app routes, server-side agent tools, bundled skills) plus the last-seen docs-changelog version. It makes change detection -possible: a feature flag that is deleted after stabilizing (per warp-internal's +possible: a feature flag that is deleted after stabilizing (per the warp repo's remove-feature-flag policy) would otherwise vanish from the audit's universe silently. When a new surface type is introduced, diffing against an older snapshot emits a one-time "surface type newly tracked" note instead of false positives. @@ -196,7 +197,7 @@ For each gap to address (prioritize high → medium → low): 2. Read `AGENTS.md` in the docs repo root for the complete style guide 3. Read 2-3 strong examples in the target section to match formatting patterns 4. Research the relevant source code: - - **Feature gaps** → read the implementation in warp-internal `app/src/`, check UI code, settings, user-facing strings + - **Feature gaps** → read the implementation in the warp client repo's `app/src/`, check UI code, settings, user-facing strings - **CLI gaps** → read command definition in `crates/warp_cli/src/`, extract flags, arguments, help text - **API gaps** → read handler in warp-server `router/handlers/public_api/`, route definition, request/response types; prefer fixing the OpenAPI spec via the `sync-openapi-spec` skill - **Slash command gaps** → read the registry entry and gating flags in `app/src/search/slash_command_menu/` @@ -226,7 +227,7 @@ with the product. Each run: instead of concluding "no gaps": ```bash python3 .agents/skills/missing_docs/scripts/audit_docs.py \ - --warp-internal ../warp-internal --warp-server ../warp-server \ + --warp ../warp --warp-server ../warp-server \ --diff --output /tmp/docs_audit.json ``` 2. **Triage**: work through `surface_changes` and `changelog_review` first (what @@ -250,7 +251,7 @@ with the product. Each run: Recommended scheduled-agent prompt (copy when setting up the agent): > Run the missing_docs skill in drift-watch mode. Use the audit script with explicit -> --warp-internal and --warp-server paths and --diff. If the script exits non-zero with +> --warp (public warpdotdev/warp checkout) and --warp-server paths and --diff. If the script exits non-zero with > skipped audits, report the environment problem and stop. Otherwise triage all > surface_changes and changelog_review findings plus high/medium coverage findings: > draft or update doc pages, update the surface map (mapping or ignore entry with a diff --git a/.agents/skills/missing_docs/scripts/audit_docs.py b/.agents/skills/missing_docs/scripts/audit_docs.py index b7149d09..a3f7e4b1 100755 --- a/.agents/skills/missing_docs/scripts/audit_docs.py +++ b/.agents/skills/missing_docs/scripts/audit_docs.py @@ -2,8 +2,9 @@ """ Missing Docs Audit Script for Warp Astro Starlight Documentation -Compares documentation coverage against code surfaces in warp-internal and -warp-server to identify gaps, and (in --diff mode) detects surface changes +Compares documentation coverage against code surfaces in the warp client +repo (the public warpdotdev/warp checkout; a warp-internal checkout also +works) and warp-server to identify gaps, and (in --diff mode) detects surface changes since the last committed snapshot. Produces a structured JSON report. Audited surfaces: @@ -167,10 +168,12 @@ def parse_stale_terms(path: Path) -> list[tuple[str, str]]: # Generic helpers # --------------------------------------------------------------------------- -def find_repo(name: str, explicit_path: str | None, repo_root: Path) -> Path | None: +def find_repo(names: list[str], explicit_path: str | None, repo_root: Path) -> Path | None: """Find a source repo by explicit path or as a sibling of the docs repo root. - e.g. docs at /workspace/docs -> look for /workspace/. + Candidate names are tried in order, e.g. docs at /workspace/docs with + names ["warp", "warp-internal"] -> prefer /workspace/warp (the public + warpdotdev/warp checkout) and fall back to /workspace/warp-internal. """ if explicit_path: p = Path(explicit_path).resolve() @@ -179,9 +182,10 @@ def find_repo(name: str, explicit_path: str | None, repo_root: Path) -> Path | N print(f"Warning: explicit path {explicit_path} does not exist", file=sys.stderr) return None - sibling = repo_root.parent / name - if sibling.exists(): - return sibling + for name in names: + sibling = repo_root.parent / name + if sibling.exists(): + return sibling return None @@ -409,24 +413,24 @@ def _iter_attr_blocks(content: str, names: tuple[str, ...]): yield content[match.start():i] # --------------------------------------------------------------------------- -# Extraction: feature flags (warp-internal) +# Extraction: feature flags (warp client repo) # --------------------------------------------------------------------------- -def _features_lib_rs(warp_internal: Path) -> Path | None: +def _features_lib_rs(warp_repo: Path) -> Path | None: candidates = [ - warp_internal / "crates" / "warp_features" / "src" / "lib.rs", - warp_internal / "crates" / "warp_core" / "src" / "features.rs", - warp_internal / "app" / "src" / "features.rs", - warp_internal / "warp_core" / "src" / "features.rs", + warp_repo / "crates" / "warp_features" / "src" / "lib.rs", + warp_repo / "crates" / "warp_core" / "src" / "features.rs", + warp_repo / "app" / "src" / "features.rs", + warp_repo / "warp_core" / "src" / "features.rs", ] return next((c for c in candidates if c.exists()), None) -def parse_feature_flags(warp_internal: Path) -> list[str]: +def parse_feature_flags(warp_repo: Path) -> list[str]: """Parse FeatureFlag enum variants from the features lib (brace-safe).""" - features_rs = _features_lib_rs(warp_internal) + features_rs = _features_lib_rs(warp_repo) if features_rs is None: - print("Warning: FeatureFlag enum source not found in warp-internal", file=sys.stderr) + print("Warning: FeatureFlag enum source not found in the warp client repo", file=sys.stderr) return [] enum_body = _extract_enum_block(features_rs.read_text(), "FeatureFlag") @@ -436,9 +440,9 @@ def parse_feature_flags(warp_internal: Path) -> list[str]: return [v["name"] for v in _parse_enum_variants(enum_body)] -def parse_flag_list_const(warp_internal: Path, const_name: str) -> set[str]: +def parse_flag_list_const(warp_repo: Path, const_name: str) -> set[str]: """Parse a `pub const : &[FeatureFlag] = &[...]` block into flag names.""" - features_rs = _features_lib_rs(warp_internal) + features_rs = _features_lib_rs(warp_repo) if features_rs is None: return set() content = features_rs.read_text() @@ -452,8 +456,8 @@ def parse_flag_list_const(warp_internal: Path, const_name: str) -> set[str]: return set(re.findall(r"FeatureFlag::(\w+)", match.group(1))) -def parse_features_bridge(warp_internal: Path) -> dict[str, dict]: - """Parse the cargo-feature -> FeatureFlag bridge from app/src/features.rs. +def parse_features_bridge(warp_repo: Path) -> dict[str, dict]: + """Parse the cargo-feature -> FeatureFlag bridge from the warp client repo\'s app/src/features.rs. The authoritative mapping is the `enabled_features()` extend block: @@ -466,7 +470,7 @@ def parse_features_bridge(warp_internal: Path) -> dict[str, dict]: Returns {flag_name: {"cargo_feature": str, "debug_only": bool}}. """ - bridge_rs = warp_internal / "app" / "src" / "features.rs" + bridge_rs = warp_repo / "app" / "src" / "features.rs" if not bridge_rs.exists(): print(f"Warning: {bridge_rs} not found; GA detection will be incomplete", file=sys.stderr) @@ -486,11 +490,11 @@ def parse_features_bridge(warp_internal: Path) -> dict[str, dict]: return bridge -def parse_default_features(warp_internal: Path) -> set[str]: +def parse_default_features(warp_repo: Path) -> set[str]: """Parse the default feature list from app/Cargo.toml.""" candidates = [ - warp_internal / "app" / "Cargo.toml", - warp_internal / "crates" / "warp_features" / "Cargo.toml", + warp_repo / "app" / "Cargo.toml", + warp_repo / "crates" / "warp_features" / "Cargo.toml", ] cargo_toml = next((c for c in candidates if c.exists()), None) if cargo_toml is None: @@ -507,7 +511,7 @@ def parse_default_features(warp_internal: Path) -> set[str]: return set(re.findall(r'"(\w+)"', features_block)) -def compute_flag_statuses(warp_internal: Path) -> dict[str, str]: +def compute_flag_statuses(warp_repo: Path) -> dict[str, str]: """Classify every FeatureFlag by rollout status. - "ga": gating cargo feature is in app/Cargo.toml default features, or the @@ -518,12 +522,12 @@ def compute_flag_statuses(warp_internal: Path) -> dict[str, str]: may still be enabled via server-side experiments; the docs changelog cross-check covers those launches. """ - flags = parse_feature_flags(warp_internal) - bridge = parse_features_bridge(warp_internal) - default_features = parse_default_features(warp_internal) - release_flags = parse_flag_list_const(warp_internal, "RELEASE_FLAGS") - preview_flags = parse_flag_list_const(warp_internal, "PREVIEW_FLAGS") - dogfood_flags = parse_flag_list_const(warp_internal, "DOGFOOD_FLAGS") + flags = parse_feature_flags(warp_repo) + bridge = parse_features_bridge(warp_repo) + default_features = parse_default_features(warp_repo) + release_flags = parse_flag_list_const(warp_repo, "RELEASE_FLAGS") + preview_flags = parse_flag_list_const(warp_repo, "PREVIEW_FLAGS") + dogfood_flags = parse_flag_list_const(warp_repo, "DOGFOOD_FLAGS") statuses: dict[str, str] = {} for flag in flags: @@ -542,7 +546,7 @@ def compute_flag_statuses(warp_internal: Path) -> dict[str, str]: return statuses # --------------------------------------------------------------------------- -# Extraction: CLI command tree + flags (warp-internal) +# Extraction: CLI command tree + flags (warp client repo) # --------------------------------------------------------------------------- def _resolve_subcommand_enum(module_content: str, referenced_type: str | None) -> str | None: @@ -590,24 +594,24 @@ def _collect_subcommands(src_dir: Path, module_content: str, enum_body: str, return subs -def _cli_src_dir(warp_internal: Path) -> Path | None: +def _cli_src_dir(warp_repo: Path) -> Path | None: candidates = [ - warp_internal / "crates" / "warp_cli" / "src", - warp_internal / "warp_cli" / "src", + warp_repo / "crates" / "warp_cli" / "src", + warp_repo / "warp_cli" / "src", ] return next((c for c in candidates if c.exists()), None) -def parse_cli_commands(warp_internal: Path) -> list[dict]: +def parse_cli_commands(warp_repo: Path) -> list[dict]: """Parse the full `oz` CLI command tree (recursive subcommands). Returns [{"command": "oz agent", "hidden": bool, "source_file": str, "module": str|None, "subcommands": [{"command": "oz agent run", "hidden": bool}]}] """ - src_dir = _cli_src_dir(warp_internal) + src_dir = _cli_src_dir(warp_repo) if src_dir is None: - print("Warning: warp_cli/src not found in warp-internal", file=sys.stderr) + print("Warning: warp_cli/src not found in the warp client repo", file=sys.stderr) return [] lib_rs = src_dir / "lib.rs" @@ -648,7 +652,7 @@ def parse_cli_commands(warp_internal: Path) -> list[dict]: return commands -def parse_cli_flags(warp_internal: Path, cli_commands: list[dict]) -> dict[str, list[str]]: +def parse_cli_flags(warp_repo: Path, cli_commands: list[dict]) -> dict[str, list[str]]: """Extract visible `--long` flags per CLI module for change tracking. Attribution of flags to specific subcommands would require full clap @@ -656,7 +660,7 @@ def parse_cli_flags(warp_internal: Path, cli_commands: list[dict]) -> dict[str, flag was added or removed (the drift agent then reads the module to see which command it belongs to). """ - src_dir = _cli_src_dir(warp_internal) + src_dir = _cli_src_dir(warp_repo) if src_dir is None: return {} @@ -924,13 +928,13 @@ def parse_openapi_paths(openapi_text: str) -> set[str]: return paths # --------------------------------------------------------------------------- -# Extraction: slash commands (warp-internal) +# Extraction: slash commands (warp client repo) # --------------------------------------------------------------------------- -def parse_slash_commands(warp_internal: Path) -> list[str]: +def parse_slash_commands(warp_repo: Path) -> list[str]: """Parse static slash command names from the registry.""" registry_dir = ( - warp_internal / "app" / "src" / "search" / "slash_command_menu" / "static_commands" + warp_repo / "app" / "src" / "search" / "slash_command_menu" / "static_commands" ) if not registry_dir.exists(): print(f"Warning: {registry_dir} not found", file=sys.stderr) @@ -945,7 +949,7 @@ def parse_slash_commands(warp_internal: Path) -> list[str]: return sorted(names) # --------------------------------------------------------------------------- -# Extraction: settings (warp-internal) +# Extraction: settings (warp client repo) # --------------------------------------------------------------------------- _SETTING_TOML_PATH_RE = re.compile(r'toml_path:\s*"([^"]+)"') @@ -955,7 +959,7 @@ def _is_test_rs(path: Path) -> bool: return path.name.endswith("_tests.rs") or path.name == "tests.rs" or "/tests/" in str(path) -def parse_settings(warp_internal: Path) -> dict[str, dict]: +def parse_settings(warp_repo: Path) -> dict[str, dict]: """Parse user-facing settings from `define_setting!`-style registrations. Every settings.toml-backed setting declares `toml_path: "section.key"` @@ -966,7 +970,7 @@ def parse_settings(warp_internal: Path) -> dict[str, dict]: Returns {toml_path: {"private": bool, "feature_flag": str|None}}. """ settings: dict[str, dict] = {} - roots = [warp_internal / "app" / "src", warp_internal / "crates"] + roots = [warp_repo / "app" / "src", warp_repo / "crates"] for rs_file in iter_source_files(roots, ".rs"): if _is_test_rs(rs_file): continue @@ -1076,19 +1080,19 @@ def parse_server_tools(warp_server: Path) -> list[str]: return sorted(names) -def parse_bundled_skills(warp_internal: Path) -> dict[str, str]: +def parse_bundled_skills(warp_repo: Path) -> dict[str, str]: """List bundled skills shipped with the client, keyed by channel gating. resources/bundled/skills/ ships on all channels ("bundled"); resources/channel-gated-skills// ships per channel. """ skills: dict[str, str] = {} - bundled = warp_internal / "resources" / "bundled" / "skills" + bundled = warp_repo / "resources" / "bundled" / "skills" if bundled.exists(): for entry in sorted(bundled.iterdir()): if entry.is_dir(): skills[entry.name] = "bundled" - gated = warp_internal / "resources" / "channel-gated-skills" + gated = warp_repo / "resources" / "channel-gated-skills" if gated.exists(): for channel_dir in sorted(gated.iterdir()): if not channel_dir.is_dir(): @@ -1200,7 +1204,7 @@ def parse_changelog_entries(repo_root: Path) -> list[dict]: # Audit 1: Feature flag coverage # --------------------------------------------------------------------------- -def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, +def audit_features(warp_repo: Path, docs_root: Path, surface_map: dict, docs_text: dict[str, str], flag_statuses: dict[str, str] | None = None, weak_coverage: bool = False) -> list[dict]: @@ -1212,7 +1216,7 @@ def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, the snapshot diff instead). """ if flag_statuses is None: - flag_statuses = compute_flag_statuses(warp_internal) + flag_statuses = compute_flag_statuses(warp_repo) ignore_flags = surface_map.get("ignore_flags", set()) feature_to_doc = surface_map.get("feature_to_doc", {}) repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent @@ -1323,11 +1327,11 @@ def audit_features(warp_internal: Path, docs_root: Path, surface_map: dict, # Audit 2: CLI command coverage # --------------------------------------------------------------------------- -def audit_cli(warp_internal: Path, docs_root: Path, surface_map: dict, +def audit_cli(warp_repo: Path, docs_root: Path, surface_map: dict, docs_text: dict[str, str], cli_commands: list[dict] | None = None) -> list[dict]: """Audit CLI command and subcommand coverage in docs.""" - commands = cli_commands if cli_commands is not None else parse_cli_commands(warp_internal) + commands = cli_commands if cli_commands is not None else parse_cli_commands(warp_repo) cli_to_doc = surface_map.get("cli_to_doc", {}) repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent @@ -1481,11 +1485,11 @@ def _slash_mention_re(name: str) -> re.Pattern: return re.compile(r"(? list[dict]: """Audit static slash command coverage in docs.""" - names = slash_commands if slash_commands is not None else parse_slash_commands(warp_internal) + names = slash_commands if slash_commands is not None else parse_slash_commands(warp_repo) slash_to_doc = surface_map.get("slash_to_doc", {}) repo_root = DOCS_REPO_ROOT[0] or docs_root.parent.parent.parent @@ -1576,14 +1580,14 @@ def audit_settings(docs_root: Path, surface_map: dict, # Audit 6: Stale doc references (docs pointing at removed code surfaces) # --------------------------------------------------------------------------- -def audit_stale_doc_references(warp_internal: Path, docs_root: Path, +def audit_stale_doc_references(warp_repo: Path, docs_root: Path, settings: dict[str, dict]) -> list[dict]: """Find doc references to code surfaces that no longer exist. - Settings keys documented in all-settings.mdx but absent from the code settings registry (renamed/removed settings). - Keybinding action names (`scope:action`) documented on the keyboard - shortcuts page but absent from warp-internal source. + shortcuts page but absent from the warp client repo source. """ findings = [] @@ -1629,7 +1633,7 @@ def is_object_setting_section(section: str) -> bool: actions = sorted(set(re.findall(r"`([a-z0-9_]+:[a-z0-9_]+)`", text))) remaining = set(actions) if remaining: - roots = [warp_internal / "app" / "src", warp_internal / "crates"] + roots = [warp_repo / "app" / "src", warp_repo / "crates"] for rs_file in iter_source_files(roots, ".rs"): if not remaining: break @@ -1646,7 +1650,7 @@ def is_object_setting_section(section: str) -> bool: "severity": "low", "reason": ( "Documented keybinding action not found anywhere in " - "warp-internal source — it was renamed or removed; update " + "the warp client repo source — it was renamed or removed; update " "the keyboard shortcuts page" ), }) @@ -1657,7 +1661,7 @@ def is_object_setting_section(section: str) -> bool: # Audit 7: Docs staleness (terminology) # --------------------------------------------------------------------------- -def audit_staleness(warp_internal: Path, docs_root: Path, +def audit_staleness(warp_repo: Path, docs_root: Path, docs_text: dict[str, str], stale_terms_path: Path = STALE_TERMS_PATH) -> list[dict]: """Check existing docs for stale terminology. @@ -2549,9 +2553,16 @@ def main(): parser = argparse.ArgumentParser( description="Audit Warp documentation coverage against code surfaces" ) + parser.add_argument( + "--warp", + dest="warp_repo", + help="Path to the public warp client repo (auto-detected as a sibling " + "of the docs repo named 'warp', with 'warp-internal' as fallback)", + ) parser.add_argument( "--warp-internal", - help="Path to warp-internal repo (auto-detected as a sibling of the docs repo)", + dest="warp_repo", + help="Deprecated alias for --warp", ) parser.add_argument( "--warp-server", @@ -2618,8 +2629,8 @@ def main(): # repo_root carries the developers/ openapi spec etc. DOCS_REPO_ROOT[0] = repo_root - warp_internal = find_repo("warp-internal", args.warp_internal, repo_root) - warp_server = find_repo("warp-server", args.warp_server, repo_root) + warp_repo = find_repo(["warp", "warp-internal"], args.warp_repo, repo_root) + warp_server = find_repo(["warp-server"], args.warp_server, repo_root) # Parse surface map surface_map = parse_surface_map(SURFACE_MAP_PATH) @@ -2666,15 +2677,15 @@ def guard(label: str, count: int) -> bool: server_tools: list[str] = [] bundled_skills: dict[str, str] = {} - if warp_internal and needs_internal: - print(f"Using warp-internal: {warp_internal}", file=sys.stderr) - flag_statuses = compute_flag_statuses(warp_internal) - cli_commands = parse_cli_commands(warp_internal) - cli_flags = parse_cli_flags(warp_internal, cli_commands) - slash_commands = parse_slash_commands(warp_internal) + if warp_repo and needs_internal: + print(f"Using warp client repo: {warp_repo}", file=sys.stderr) + flag_statuses = compute_flag_statuses(warp_repo) + cli_commands = parse_cli_commands(warp_repo) + cli_flags = parse_cli_flags(warp_repo, cli_commands) + slash_commands = parse_slash_commands(warp_repo) print("Parsing settings registry...", file=sys.stderr) - settings = parse_settings(warp_internal) - bundled_skills = parse_bundled_skills(warp_internal) + settings = parse_settings(warp_repo) + bundled_skills = parse_bundled_skills(warp_repo) flags_ok = guard("feature flags", len(flag_statuses)) cli_ok = guard("CLI commands", len(cli_commands)) @@ -2684,7 +2695,7 @@ def guard(label: str, count: int) -> bool: if args.category in (None, "features") and flags_ok: print("Running feature flag coverage audit...", file=sys.stderr) findings["undocumented_features"] = audit_features( - warp_internal, docs_root, surface_map, docs_text, + warp_repo, docs_root, surface_map, docs_text, flag_statuses=flag_statuses, weak_coverage=args.weak_coverage, ) audits_run.append("features") @@ -2692,14 +2703,14 @@ def guard(label: str, count: int) -> bool: if args.category in (None, "cli") and cli_ok: print("Running CLI command coverage audit...", file=sys.stderr) findings["undocumented_cli_commands"] = audit_cli( - warp_internal, docs_root, surface_map, docs_text, + warp_repo, docs_root, surface_map, docs_text, cli_commands=cli_commands) audits_run.append("cli") if args.category in (None, "slash") and slash_ok: print("Running slash command coverage audit...", file=sys.stderr) findings["undocumented_slash_commands"] = audit_slash_commands( - warp_internal, docs_root, surface_map, docs_text, + warp_repo, docs_root, surface_map, docs_text, slash_commands=slash_commands) audits_run.append("slash") @@ -2712,20 +2723,20 @@ def guard(label: str, count: int) -> bool: if args.category in (None, "staleness"): print("Running docs staleness audit...", file=sys.stderr) findings["potentially_stale_docs"] = audit_staleness( - warp_internal, docs_root, docs_text) + warp_repo, docs_root, docs_text) # The reverse checks compare docs against extracted code surfaces, # so they are only meaningful when extraction is healthy. if flags_ok and settings_ok: print("Running stale doc reference audit...", file=sys.stderr) findings["stale_doc_references"] = audit_stale_doc_references( - warp_internal, docs_root, settings) + warp_repo, docs_root, settings) audits_run.append("staleness") elif needs_internal: for audit in ("features", "cli", "slash", "settings", "staleness"): if args.category in (None, audit): audits_skipped.append({ "audit": audit, - "reason": "warp-internal repo not found (pass --warp-internal)", + "reason": "warp client repo not found (pass --warp)", }) if warp_server and needs_server: @@ -2755,7 +2766,7 @@ def guard(label: str, count: int) -> bool: audits_run.append("structure") if args.category in (None, "map"): - if warp_internal and warp_server and extraction_ok: + if warp_repo and warp_server and extraction_ok: print("Running surface map hygiene audit...", file=sys.stderr) findings["map_hygiene"] = audit_map_hygiene( surface_map, flag_statuses, cli_commands, api_routes, @@ -2765,7 +2776,7 @@ def guard(label: str, count: int) -> bool: audits_skipped.append({ "audit": "map", "reason": ( - "requires both warp-internal and warp-server with healthy " + "requires both the warp client repo and warp-server with healthy " "extraction (dead-entry checks against empty extraction " "would flag everything)" ), @@ -2775,7 +2786,7 @@ def guard(label: str, count: int) -> bool: changelog_entries = parse_changelog_entries(repo_root) snapshot_path = Path(args.snapshot) if args.diff or args.update_snapshot: - if warp_internal and warp_server and extraction_ok: + if warp_repo and warp_server and extraction_ok: current_snapshot = build_snapshot( flag_statuses, cli_commands, cli_flags, api_routes, slash_commands, settings, web_routes, server_tools, @@ -2807,10 +2818,10 @@ def guard(label: str, count: int) -> bool: audits_skipped.append({ "audit": "diff" if args.diff else "update-snapshot", "reason": ( - "requires both warp-internal and warp-server with healthy " + "requires both the warp client repo and warp-server with healthy " "extraction (see extraction:* skips above)" if not extraction_ok - else "requires both warp-internal and warp-server" + else "requires both the warp client repo and warp-server" ), }) @@ -2819,7 +2830,7 @@ def guard(label: str, count: int) -> bool: # extraction; any unaccounted item means an audit-logic regression and # the run is treated as incomplete. accounting = None - if args.category is None and warp_internal and warp_server and extraction_ok: + if args.category is None and warp_repo and warp_server and extraction_ok: accounting = compute_accounting( docs_root, surface_map, findings, flag_statuses, cli_commands, api_routes, slash_commands, settings, docs_text)