# List Provider APIs

## Purpose

Produce a complete, classified list of every API endpoint the provider documents — classified into MoSCoW priority tiers (`must_have` / `should_have` / `nice_to_have` / `would_not_have`). This is the source of truth consumed by `fetch-endpoint-parts`.

## Executable scripts

| Script | Purpose |
|--------|---------|
| `node ${CLAUDE_PLUGIN_ROOT}/scripts/research/verify_coverage.js [candidate.json]` | Verify a `api_coverage.json` candidate is internally consistent before writing it to disk. Reads from stdin or file. Exits 0 on success, 1 on any failure. |
| `node ${CLAUDE_PLUGIN_ROOT}/scripts/research/detect_collisions.js [candidate.json] [--fix]` | Detect resource-name collisions (same `category` under different parents). Pass `--fix` to apply parent-prefixed categories in place. Exits 0 if no collisions, 1 if collisions remain unfixed. |
| `node ${CLAUDE_PLUGIN_ROOT}/scripts/research/classify_priority.js <provider>` | Pass 2 — assign `intrinsic_role`, `use_cases[]`, `priority_score`, `priority`, `priority_reason` to every entry and rebuild the 4 tier arrays. |
| `node ${CLAUDE_PLUGIN_ROOT}/scripts/research/select_apis_for_investigation.js <provider> [--tiers …] [--include-resources …] [--resources …] [--limit N] [--exclude-webhooks]` | Phase 3.4/3.5 — apply the scope decision (autonomous default, or the user's single over-cap reply): mark each entry `selected: true \| false` and build `selected_for_investigation[]`. `--exclude-webhooks` is passed by default; `--resources` restricts the pool to named categories. |
| `node ${CLAUDE_PLUGIN_ROOT}/scripts/research/resolve_dependencies.js <provider>` | Phase 3.5D — walk path-parameter dependencies; auto-include the lightest producer (`list_*` > `search_*` > `create_*`) for each unsatisfied dependency, OR remove the dependent action if no producer exists. Writes `dependency_resolution.yaml` audit trail. |

---

## Discovery Strategy (in order, stop when complete list found)

### Option A: OpenAPI / Swagger spec (fastest — always try first)

**Step 1: If spec URL is known, fetch it directly:**
```
WebFetch("<openapi_spec_url>", "List every path and HTTP method in this OpenAPI spec, with operation tags")
```

**Step 2: If spec URL is NOT known, try common paths:**
```
WebFetch("<docs_base_url>/openapi.json", "List every path and HTTP method in this OpenAPI spec")
WebFetch("<docs_base_url>/swagger.json", "List every path and HTTP method in this OpenAPI spec")
WebFetch("<docs_base_url>/openapi.yaml", "List every path and HTTP method in this OpenAPI spec")
WebFetch("<docs_base_url>/api-docs", "List every path and HTTP method in this OpenAPI spec")
WebFetch("<docs_base_url>/v2/openapi.json", "List every path and HTTP method in this OpenAPI spec")
```
Also try: `WebSearch("<provider> openapi.json swagger spec site:<docs-domain>")`

**If the spec is large and WebFetch truncates or summarizes away endpoints, fetch the raw file with crwl instead:**
```bash
crwl crawl "<openapi_spec_url>" -o markdown
```

Parse `paths` object → every key is a path, every HTTP method under it is one endpoint.
**Spec found = 100% coverage guaranteed. Do not proceed to Options B/C/D.**

---

### Option B: WebFetch (quick scan — use only to get initial page list)

Only used when no spec exists. Fetches the docs index to get a list of section URLs to deep-crawl in Option C.

```
WebFetch("<docs_base_url>", "List every internal documentation link on this page with its URL")
```

Extract all internal links → pass them to Option C as starting points.
Do NOT try to extract endpoints here — this step only discovers URLs.

---

### Option C: crwl deep crawl (provider-agnostic — crawls every page)

Use when no OpenAPI spec exists. Deep-crawls the entire docs site starting from the base URL, visiting every internal page automatically. No need to know URL structure or guess which sections exist.

```bash
crwl crawl "<docs_base_url>" \
  --deep-crawl bfs \
  --max-pages 150 \
  -c "wait_until=networkidle,delay_before_return_html=2.0" \
  -o markdown
```

- `--deep-crawl bfs` — breadth-first, follows every internal link on every page visited
- `--max-pages 150` — increase if provider has more pages (check `summary.total` in output)
- Output is the combined markdown of ALL pages → scan for every method+path pattern

**If docs require authentication or are behind a login wall:**
```bash
crwl crawl "<docs_base_url>" \
  --deep-crawl bfs \
  --max-pages 150 \
  -c "wait_until=networkidle,delay_before_return_html=3.0" \
  -o markdown
```
Then fall through to Option D.

---

### Option D: Playwright (when crwl can't access interactive/auth-gated docs)

```javascript
// Navigate and extract all nav links — expand all collapsed sections first
mcp__plugin_stackone-agent-harness_playwright__browser_navigate("<docs_base_url>")
mcp__plugin_stackone-agent-harness_playwright__browser_snapshot()  // identify all expand/toggle buttons
// Click every expand button to reveal nested sections
mcp__plugin_stackone-agent-harness_playwright__browser_click(<expand button 1>)
mcp__plugin_stackone-agent-harness_playwright__browser_click(<expand button 2>)
// ... repeat for all collapsed sections
// Then extract every link
mcp__plugin_stackone-agent-harness_playwright__browser_evaluate(`
  (() => {
    const links = document.querySelectorAll('a[href]');
    return [...new Set([...links].map(a => a.href))]
      .filter(h => h.startsWith(window.location.origin));
  })()
`)
```

Then fetch each extracted URL via WebFetch or crwl to read the endpoint content.

---

## API Type Detection (MANDATORY — run before classification)

Set `api_type` based on the spec format you found during discovery:

| Discovery result | `api_type` | `api_type_details.spec_format` |
|---|---|---|
| OpenAPI 3.x spec found | `rest` | `openapi` |
| Swagger 2.0 spec found | `rest` | `swagger` |
| GraphQL schema / SDL file or introspection endpoint | `graphql` | `graphql_schema` |
| WSDL file or SOAP envelope endpoints | `soap` | `wsdl` |
| `.proto` files / gRPC reflection endpoint | `grpc` | `proto` |
| JSON-RPC discovery doc / `jsonrpc: "2.0"` in examples | `jsonrpc` | `none` |
| No spec, only HTML docs with REST-style endpoints | `rest` | `none` |

**Detection signals if spec is ambiguous:**
- `query { ... }` blocks in docs examples → `graphql`
- `<soap:Envelope>` or `<wsdl:definitions>` → `soap`
- `service Foo { rpc Bar(...) returns (...) }` → `grpc`
- `{"jsonrpc": "2.0", "method": "..."}` in request examples → `jsonrpc`
- Otherwise → `rest`

**Multi-service providers:** if the provider exposes more than one base URL (e.g. Firebase has 14+ sub-services), set `api_type` to the dominant type but list every base URL under `base_urls[]` with a descriptive `service` label.

## Provider Metadata Capture (MANDATORY — run during discovery)

While crawling docs, also capture these fields for `api_coverage.json`:

| Field | Where to find |
|---|---|
| `provider_full_name` | Title of the docs landing page, brand page, or OpenAPI `info.title` |
| `console_url` | "Sign in to console" / "Manage your account" links in docs nav |
| `developer_portal_url` | "Developers" / "API" landing page; usually the root of the docs domain |
| `api_version` | Path prefix (`/v2/...`), version header (`API-Version: 2024-01-01`), or OpenAPI `info.version` |
| `key_concepts` | "Concepts", "Overview", "Glossary", or "Getting Started" pages — capture 1–5 provider-specific terms that connector builders need to know (e.g. `userId=me`, `team_domain`, `tenant_id` patterns) |

Set fields to `null` (or `[]` for `key_concepts`) if not findable after a reasonable search — do not invent values.

---

## Two-Pass Workflow

`list-provider-apis` is invoked **twice** in Phase 1 of `/connector:research`:

### Pass 1 — Discovery (no classification)

Discover every endpoint and write a partial `api_coverage.json` with discovery fields populated. Classification fields (priority, role, use_cases, priority_score) are left empty/null. This pass writes:

- `provider`, `provider_full_name`, `docs_url`, `openapi_spec_url`, `investigated_at`
- `base_url`, `base_urls[]`, `api_type`, `api_type_details`
- `console_url`, `developer_portal_url`, `api_version`, `key_concepts[]`
- **`path_prefix_strip`** ← see "Path Prefix Strip" below
- **`high_priority_resources[]`** ← see "High-Priority Resources" below
- `all_apis[]` — every endpoint with: `action_id`, `label`, `method`, `path`, `resource_url`, `category`, `base_url_service`, `tags[]`
- The new tier arrays (`must_have`, `should_have`, `nice_to_have`, `would_not_have`) are empty in Pass 1

### `tags[]` — mandatory on every entry, never empty

Every `all_apis[]` entry MUST carry a `tags` array with at least one valid value. `verify_coverage.js` fails the file otherwise.

**Sources, in priority order:**
1. **OpenAPI operation `tags`** — if the spec defines them, use them (normalized)
2. **Docs navigation grouping** — the section/group the endpoint sits under in the provider's reference nav (e.g. "Time Off" → `time_off`)
3. **Fallback:** `[category]` — the entry's own category, guaranteeing the array is never empty

**Normalization (apply to every tag regardless of source):**
- Lowercase, trim, spaces/`&`/`-` → underscore, strip any other punctuation, then collapse consecutive underscores and trim leading/trailing ones (e.g. `"Users & Teams"` → `users___teams` → `users_teams`)
- Must match `^[a-z0-9][a-z0-9_]*$`
- Dedupe within the entry; drop empties after normalization (then fallback applies if nothing survives)

### Pass 2 — Classification

Runs **after** `discover-dynamic-use-cases` has produced Layer 3 + Layer 4 catalogs (Phase 2D). Invokes `classify_priority.js` which:

1. For each entry: compute `logical_path` + `logical_path_depth` by stripping `path_prefix_strip`
2. Assign `intrinsic_role` via the deterministic role classifier (7 roles + `unclassified`)
3. Map each entry to use cases (loads Layers 1 + 2 + 3 + 4 + 0 if present; matches against `mapping_hints`)
4. Compute `priority_score` (weighted sum: 4.0×user_supplied + 3.5×category_dynamic + 2.5×provider_dynamic + 2.0×category + 0.25×universal)
5. Compute `priority` tier from `intrinsic_role` + score
6. Rebuild the 4 top-level tier arrays
7. Rebuild `summary.by_priority` counts

Invocation:
```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/research/classify_priority.js <provider>
```

After Pass 2 the file is complete and ready for downstream consumers.

---

## Path Prefix Strip (MANDATORY in Pass 1)

Provider APIs often share a common path prefix that scopes to the authenticated user or API version. To compute "logical path depth" for role classification, this prefix is stripped first.

Determine the prefix by finding the longest path prefix shared by ≥80% of endpoints, ending at the segment that scopes to the authenticated user.

| Provider | Raw path example | Strip prefix |
|---|---|---|
| Gmail | `/gmail/v1/users/{userId}/messages` | `/gmail/v1/users/{userId}` |
| Microsoft Graph | `/users/{id}/messages` or `/me/messages` | `/users/{id}` or `/me` (provider-dependent — typically `/users/{id}`) |
| Salesforce | `/services/data/v59.0/sobjects/Contact` | `/services/data/v59.0` |
| Stripe | `/v1/charges` | `/v1` |
| HubSpot | `/crm/v3/objects/contacts` | `/crm/v3` |

Write the chosen prefix to `api_coverage.json` as `path_prefix_strip: "<prefix>"`. If no clear prefix exists, set `path_prefix_strip: ""` (empty string, not null).

The role classifier (Pass 2) computes `logical_path = path.removePrefix(path_prefix_strip).trimStart('/')` and `logical_path_depth = count of '/' segments`.

---

## High-Priority Resources (MANDATORY in Pass 1)

Identify 5–15 resources that are the provider's "headline features" — the entities advertised on marketing pages, the ones that dominate the API surface. Only APIs operating on a high-priority resource can be tagged `primary_crud` by the role classifier.

| Provider | `high_priority_resources` |
|---|---|
| Gmail | `["messages", "drafts", "threads", "labels"]` |
| Salesforce | `["accounts", "contacts", "opportunities", "leads"]` |
| Stripe | `["payments", "charges", "customers", "subscriptions", "invoices"]` |
| BambooHR | `["employees", "time_off", "compensation"]` |

Write to `api_coverage.json` as `high_priority_resources: [...]`.

**How to pick:** look at the provider's homepage / docs landing. Resources mentioned as the "primary thing you do" with this API qualify. Settings, admin, audit, webhook endpoints do NOT — those are extension/admin resources.

---

## Sub-resource Sweep (MANDATORY in Pass 1)

For every high-priority resource, explicitly walk its **nested paths** before closing
discovery: `/tickets/{id}/conversations`, `/tickets/{id}/tasks`, `/tickets/{id}/time_entries`,
`/tasks/{gid}/dependencies`, and so on. Providers document these on child pages that a
top-level sweep never opens, and a missed family is invisible downstream — coverage gates
count built ÷ *discovered*, so what discovery never records is silently absent from the
connector. Whole families — conversations, tasks, time entries and the like — go missing
this way without any gate registering a gap, because nothing downstream knows they exist.

Checklist per high-priority resource: open the resource's own doc section fully expanded ·
list every path segment nested under it · record each nested collection and its CRUD surface
in `api_coverage.json` like any top-level API.

---

## Classification Outcome (set by Pass 2, not by you directly)

`priority` is one of 4 MoSCoW values, determined by `classify_priority.js` from `intrinsic_role` + `priority_score` + `logical_path_depth`:

| Priority | Source |
|---|---|
| `must_have` | `primary_crud` at depth ≤ 1 with score ≥ 2.0, OR `extension` with score ≥ 3.0 |
| `should_have` | `primary_crud` at depth ≥ 2 (sub-resources), OR `primary_crud` with score < 2.0, OR `extension` with score < 3.0, OR `helper` with score ≥ 3.0, OR `webhook` with score ≥ 3.0 |
| `nice_to_have` | `admin_settings` (always), OR `helper` with score < 3.0, OR `webhook` with score < 3.0, OR `unclassified` (default home — surfaced for review but not headline) |
| `would_not_have` | `deprecated`, OR `experimental` — **nothing else lands here**. Webhook subscription/management endpoints are still classified as REST APIs at this stage (usually `should_have`/`nice_to_have`), but are **excluded from Phase 4 fetching by default** via `select_apis_for_investigation.js --exclude-webhooks`. Webhook coverage is not part of this flow. Unclassified entries surface as `nice_to_have` rather than buried |

The `priority_reason` vocabulary documents **why** an API landed where it did:

| Reason | Tier |
|---|---|
| `primary_crud_on_high_priority_resource` | must_have |
| `extension_covers_multiple_core_use_cases` | must_have |
| `primary_crud_on_sub_resource` | should_have |
| `primary_crud_low_use_case_coverage` | should_have |
| `extension_covers_few_use_cases` | should_have |
| `helper_covers_core_use_cases` | should_have |
| `webhook_subscription_management` | should_have |
| `admin_or_settings` | nice_to_have |
| `helper_low_coverage` | nice_to_have |
| `webhook_low_coverage` | nice_to_have |
| `unclassified_default_nice` | nice_to_have |
| `deprecated` | would_not_have |
| `experimental` | would_not_have |

---

## Output File

**Path:** `.connector-dev/<provider>/research/api_coverage.json`

```json
{
  "provider": "provider_slug",
  "provider_full_name": "Provider Full Name",
  "docs_url": "DOCS_BASE_URL/api-reference",
  "openapi_spec_url": "DOCS_BASE_URL/openapi.json",
  "investigated_at": "<ISO timestamp>",
  "base_url": "API_BASE_URL/v2",
  "base_urls": [
    { "service": "default", "url": "API_BASE_URL/v2" }
  ],

  "api_type": "rest",
  "api_type_details": {
    "protocol": "HTTP/1.1",
    "formats": ["json"],
    "spec_format": "openapi"
  },

  "console_url": "CONSOLE_URL",
  "developer_portal_url": "DEVELOPER_PORTAL_URL",
  "api_version": "v2",
  "key_concepts": [
    { "name": "concept_name", "description": "What it means in this provider's domain" }
  ],

  "path_prefix_strip": "/v2",
  "high_priority_resources": ["users", "tasks", "projects"],

  "summary": {
    "total": 42,
    "by_priority": {
      "must_have": 18,
      "should_have": 12,
      "nice_to_have": 8,
      "would_not_have": 4
    }
  },

  "all_apis": [
    {
      "action_id": "list_users",
      "label": "List Users",
      "method": "GET",
      "path": "/v2/users",
      "logical_path": "users",
      "logical_path_depth": 1,
      "resource_url": "DOCS_BASE_URL/api/users#list",
      "category": "users",
      "base_url_service": "default",
      "tags": ["users"],
      "intrinsic_role": "primary_crud",
      "use_cases": [
        { "id": "employee_management", "layer": "category" }
      ],
      "priority_score": 2.0,
      "priority": "must_have",
      "priority_reason": "primary_crud_on_high_priority_resource",
      "investigated": false
    },
    {
      "action_id": "register_webhook",
      "label": "Register Webhook",
      "method": "POST",
      "path": "/v2/webhooks",
      "logical_path": "webhooks",
      "logical_path_depth": 1,
      "resource_url": "DOCS_BASE_URL/webhooks",
      "category": "webhooks",
      "base_url_service": "default",
      "tags": ["webhooks", "events"],
      "intrinsic_role": "webhook",
      "use_cases": [],
      "priority_score": 1.0,
      "priority": "nice_to_have",
      "priority_reason": "webhook_low_coverage",
      "investigated": false
    }
  ],

  "must_have":      [ /* entries where priority == "must_have" */ ],
  "should_have":    [ /* entries where priority == "should_have" */ ],
  "nice_to_have":   [ /* entries where priority == "nice_to_have" */ ],
  "would_not_have": [ /* entries where priority == "would_not_have" */ ]
}
```

**Field rules:**
- `path_prefix_strip` — string (may be empty `""`, never null); written in Pass 1
- `high_priority_resources` — array of resource names; written in Pass 1
- `all_apis[]` entries get `logical_path` / `logical_path_depth` / `intrinsic_role` / `use_cases` / `priority_score` / `priority` / `priority_reason` populated in **Pass 2** by `classify_priority.js`
- 4 tier arrays (`must_have`, `should_have`, `nice_to_have`, `would_not_have`) are **rebuilt in Pass 2** from `all_apis[]`
- `summary.by_priority.{must_have, should_have, nice_to_have, would_not_have}` sum to `summary.total`
- `action_id` — snake_case from label: `"List Users"` → `"list_users"`
- `category` — lowercase plural noun: `users`, `tasks`, `products`, `auth`, `webhooks` (after disambiguation if needed — see Resource Disambiguation section)
- `resource_url` — specific endpoint page; `null` only as a last resort after all resolution attempts (see below)
- `tags` (Pass 1) — MANDATORY non-empty array of normalized tag strings (`^[a-z0-9][a-z0-9_]*$`). Source priority: OpenAPI operation `tags` → docs nav grouping → fallback `[category]`. Never `null`, never `[]` — see the "`tags[]`" section above. Enforced by `verify_coverage.js`
- `investigated` — always `false` on creation; updated by `fetch-endpoint-parts`
- `intrinsic_role` (Pass 2) — one of 8 values: `primary_crud` | `extension` | `helper` | `admin_settings` | `webhook` | `deprecated` | `experimental` | `unclassified`
- `priority` (Pass 2) — one of 4 MoSCoW values: `must_have` | `should_have` | `nice_to_have` | `would_not_have`
- `priority_reason` (Pass 2) — one of 13 values documenting why this priority was chosen (see the "Classification Outcome" section for the full vocabulary)
- `priority_score` (Pass 2) — non-negative number; weighted sum of use-case mapping counts across layers
- `use_cases` (Pass 2) — array of `{ id, layer, qualifier? }` mappings; `id` is from one of the catalog layers (universal / category / category_dynamic / provider_dynamic / user_supplied); may be empty when no catalog mapping matches (typically `unclassified` entries or odd/new resources)
- `logical_path` (Pass 2) — `path` minus `path_prefix_strip`, with leading slash trimmed
- `logical_path_depth` (Pass 2) — count of `/`-separated segments in `logical_path`
- `provider_full_name` — human-readable provider name (e.g. `"Gmail API"`, `"Salesforce REST API"`)
- `base_urls` — list of all base URLs the provider exposes. For single-service providers, one entry with `service: "default"`. For multi-service providers (e.g. Firebase has 14+), one entry per sub-service with a descriptive `service` label
- `base_url_service` (per entry in `all_apis[]`) — name of the entry in `base_urls[]` that this specific endpoint uses. Required when `base_urls` has more than one entry; defaults to `"default"` for single-service providers. `fetch-endpoint-parts` cross-references this to set the endpoint's `base_url` authoritatively rather than re-extracting from the docs page
- `api_type` — one of: `rest` | `graphql` | `soap` | `grpc` | `jsonrpc`. Detect from spec format: OpenAPI/Swagger → `rest`; GraphQL schema → `graphql`; WSDL → `soap`; proto files → `grpc`; JSON-RPC discovery → `jsonrpc`
- `api_type_details.protocol` — e.g. `HTTP/1.1`, `HTTP/2`, `gRPC`
- `api_type_details.formats` — accepted/returned formats: `["json"]`, `["json", "xml"]`, `["protobuf"]`
- `api_type_details.spec_format` — `openapi` | `swagger` | `graphql_schema` | `wsdl` | `proto` | `none`
- `console_url` — provider's admin/developer console URL (where users go to manage their account)
- `developer_portal_url` — provider's developer documentation portal landing page
- `api_version` — version segment in path or header (e.g. `v1`, `v2`, `2024-01-01`); `null` if unversioned
- `key_concepts` — provider-specific terminology that affects connector design (e.g. for Gmail: `{ "name": "userId", "description": "Use literal 'me' for the authenticated user — avoids needing email lookup" }`). Set to `[]` if no provider-specific concepts

---

## Resource Disambiguation (MANDATORY — run after initial classification)

After assigning a `category` to every entry, check for **collisions**. A collision is when two entries share the same `category` but belong to different parent paths — meaning they're different resources that happen to share a leaf name.

**Example collision:**

| Path | Initial category | Parent |
|---|---|---|
| `/accounts/{id}/contacts` | `contacts` | `accounts` |
| `/teams/{id}/contacts` | `contacts` | `teams` |

Without disambiguation, both groups would later overwrite each other in `endpoints/resources/contacts.yaml`. Silent data loss.

**Run the collision detector before writing the file:**

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/research/detect_collisions.js candidate.json
```

If the script exits with code 1 (collisions detected), apply the suggested parent-prefixed categories:

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/research/detect_collisions.js candidate.json --fix
```

After applying, the colliding entries get prefixed categories:

| Path | New category |
|---|---|
| `/accounts/{id}/contacts` | `accounts_contacts` |
| `/teams/{id}/contacts` | `teams_contacts` |

This guarantees `endpoints/resources/<category>.yaml` is unique per resource group.

**When to skip disambiguation:** if every resource is scoped under a single common parent (e.g., Gmail: all paths begin with `/gmail/v1/users/{userId}/...`), no collisions are possible. The detector will exit 0 and require no changes.

---

## Summary Computation (MANDATORY — always run before writing the file)

The `summary` block must be computed from the actual arrays every time, never estimated.

**After Pass 1 (discovery only):**
```
summary.total = len(all_apis)
summary.by_priority = { must_have: 0, should_have: 0, nice_to_have: 0, would_not_have: 0 }
```

All 4 tier counts are 0 in Pass 1 (no classification yet). The 4 tier arrays (`must_have`, `should_have`, `nice_to_have`, `would_not_have`) are empty.

**After Pass 2 (classify_priority.js):**
```
summary.total = len(all_apis)
summary.by_priority.must_have      = len(entries where priority == "must_have")
summary.by_priority.should_have    = len(entries where priority == "should_have")
summary.by_priority.nice_to_have   = len(entries where priority == "nice_to_have")
summary.by_priority.would_not_have = len(entries where priority == "would_not_have")
```

Invariant: `sum(by_priority.*) == summary.total` (always).

Run the verifier after each pass:

```bash
node ${CLAUDE_PLUGIN_ROOT}/scripts/research/verify_coverage.js candidate.json
```

The script checks:
- All required top-level keys present (`provider`, `docs_url`, `openapi_spec_url`, `investigated_at`, `base_url`, `base_urls`, `api_type`, `api_type_details`, `path_prefix_strip`, `high_priority_resources`, `summary`, `all_apis`, `must_have`, `should_have`, `nice_to_have`, `would_not_have`)
- `summary.total == len(all_apis)`
- `summary.by_priority.X == len(X_array)` for each of the 4 tiers
- `sum(by_priority.*) == summary.total`
- No resource-name collisions (calls `detect_collisions.js`)
- After Pass 2 only: every entry has a non-empty `intrinsic_role`, `priority`, `use_cases[]`

Exits `0` on success, `1` on any failure.

The file written to disk must contain all top-level keys: `provider`, `docs_url`, `openapi_spec_url`, `investigated_at`, `base_url`, `base_urls`, `api_type`, `api_type_details`, `path_prefix_strip`, `high_priority_resources`, `summary`, `all_apis`, `must_have`, `should_have`, `nice_to_have`, `would_not_have`. A file missing any of these is invalid.

---

## `resource_url` Resolution (always attempt — `null` is last resort)

Every entry that ends up in `must_have`, `should_have`, or `nice_to_have` after Pass 2 must have a `resource_url`. A `null` value means `fetch-endpoint-parts` writes a stub instead of fetching the docs page. Always try the following before setting `null`:

**Step 1 — URL found during discovery:** If the endpoint was discovered from a crawled page, use that page URL (with anchor if the page covers multiple endpoints).

**Step 2 — Construct from pattern:** If other endpoints have docs at `<base_docs_url>/<resource>/<action>`, construct the URL for the missing endpoint using the same pattern and verify it with a quick fetch.

**Step 3 — Search:** `WebSearch("site:<docs_domain> <method> <path>")`

**Step 4 — Anchor on shared page:** If multiple endpoints share one page (e.g. `/users` covers list, get, create), use the same URL with a fragment anchor: `DOCS_BASE_URL/users#create`.

**Only set `resource_url: null`** if all four steps fail. Note the missing URL in a comment inside the entry.

---

## Anti-Patterns

- ❌ Stop at Option A if spec is not found — always fall through to deep crawl
- ❌ Use WebFetch alone (Option B) as the final discovery step — it only sees one page; always follow with deep crawl
- ❌ Skip any endpoint — every HTTP endpoint goes into `all_apis`; classification happens in Pass 2
- ❌ Omit `tags` or leave it empty/null — every entry carries at least `[category]`; raw provider tags must be normalized (`"Users & Teams"` → `users_teams`), never stored verbatim
- ❌ Hand-assign `priority` / `intrinsic_role` / `use_cases` / `priority_score` in Pass 1 — those fields belong to Pass 2 only (set by `classify_priority.js`)
- ❌ Set `priority: "high"` or `priority: "low"` — those are old values; the 4 MoSCoW tiers (`must_have`, `should_have`, `nice_to_have`, `would_not_have`) replace them entirely
- ❌ Use free-form `priority_reason` — exactly 13 values, all defined in `classify_priority.js`
- ❌ Skip `path_prefix_strip` — required in Pass 1, even if value is empty string `""`
- ❌ Skip `high_priority_resources` — required in Pass 1; the role classifier depends on it to assign `primary_crud`
- ❌ Set `resource_url: null` without attempting all 4 resolution steps above
- ❌ Let `summary.by_priority.*` counts not sum to `summary.total`
- ❌ Omit the `summary` block — it is mandatory; a file without `summary` is invalid
- ❌ Hand-curate the 4 tier arrays — they are rebuilt by `classify_priority.js` in Pass 2 from `all_apis[]`
- ❌ Write the file before running `verify_coverage.js` — it now also calls `detect_collisions.js` and (post-Pass-2) checks every entry has a valid `intrinsic_role`, `priority`, `priority_reason`, `priority_score`, and a `use_cases` array (the array may be empty for unclassified entries)
- ❌ Write a file missing any of the required top-level keys: `provider`, `docs_url`, `openapi_spec_url`, `investigated_at`, `base_url`, `base_urls`, `api_type`, `api_type_details`, `path_prefix_strip`, `high_priority_resources`, `summary`, `all_apis`, `must_have`, `should_have`, `nice_to_have`, `would_not_have`
