Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,5 @@


node_modules
.env
.env
/drift-report/
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
"gen-api-pages:read": "cd src && mintlify-scrape openapi-file -o reference read.json | tail -n +2 > reference/read.json",
"gen-api-pages:write": "cd src && mintlify-scrape openapi-file -o reference write.json | tail -n +2 > reference/write.json",
"gen-api-pages:search": "cd src && mintlify-scrape openapi-file -o reference search.json | tail -n +2 > reference/search.json",
"gen-docs": "cd src && tsx generate-docs.ts docs.json"
"gen-docs": "cd src && tsx generate-docs.ts docs.json",
"drift-check": "tsx scripts/drift-check/index.ts"
},
"devDependencies": {
"@mintlify/scraping": "3.0.137",
Expand Down
66 changes: 66 additions & 0 deletions scripts/drift-check/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
# Drift check

Compares provider-guide claims against the generated [`amp-labs/connectors`](https://github.com/amp-labs/connectors/blob/main/internal/generated/catalog.json) catalog. Static, no credentials, no runtime calls.

## Quick start

```shell
pnpm run drift-check # full sweep, default ./drift-report/
pnpm run drift-check -- --out /tmp/out # custom output directory
pnpm run drift-check -- --mode provider --provider hubspot
pnpm run drift-check -- --fail-on-error # exit non-zero on any error finding
```

Both `--out X` and `-- --out X` (the pnpm forwarding form) work. Output is written to `drift-report.json` (canonical) and `drift-report.md` (human-readable rollup).

## Modes

- `full` (default): scan every guide; run the undocumented-provider check.
- `per-pr --changed <path>...`: scan only the listed `.mdx` files. Skips the undocumented check (needs a global view).
- `provider --provider <catalogKey>`: scan a single guide. Useful while iterating.

## Finding types

| Kind | Severity | Triggers |
|---|---|---|
| `undocumented-provider` | error | Catalog has a provider; no guide; no allowance |
| `undocumented-provider-allowance-expired` | warning | An entry in `undocumentedAllowed` has passed its `until` date |
| `guide-without-catalog-entry` | error | A guide resolves to a catalog key that does not exist |
| `doc-overclaims-{read,write,subscribe,proxy,search}` | error | Guide claims an action; neither provider-level nor any module-level support flag is true |
| `broken-sample-link` | error | Guide links to a `samples` manifest that 404s |

Module-aware: a claim is valid if either the provider-level flag is true *or* any module-level flag is true. Multi-module provider findings carry `"module": "all"` since v1 does not attribute the claim to a specific module.

## What this does not prove

This check uses the generated connectors catalog from `main`. It does **not** validate the deployed `/v1/providers` API against its declared shape, and it does **not** prove that read/write/proxy/subscribe calls succeed at runtime. Several drift surfaces are deliberately out of scope:

| Surface | Why out of scope |
|---|---|
| Connector Go source vs generated catalog | Regeneration lag; lives in the connectors repo |
| Generated catalog vs live `/v1/providers` | Deployment lag; separate cheap check |
| Live API declared shape vs runtime behavior | Requires sandbox accounts; separate program |
| Guides vs connector source object names | Requires parsing Go; deferred to a follow-up PR |
| Guides vs provider's own developer docs | Per-provider HTML parsing |
| Guides vs provider product UIs (screenshots, GIFs) | Manual surface; account + MFA |

## Adding a recipe

Edit `recipes.ts`. Two surfaces, both intentionally sparse.

**`slugOverrides`** — when the filename slug and the catalog key are different *strings* (not just different cases — case differences are handled automatically):

```ts
slugOverrides: {
instantly: 'instantlyAI',
jira: 'atlassian',
}
```

**`undocumentedAllowed`** — when a catalog entry intentionally has no guide. Every entry needs a reason and a time-bounded `until` so the allowance cannot persist forever:

```ts
{ provider: 'adyenTest', reason: 'Internal test variant.', until: '2027-01-01' }
```

When `until` passes, the entry becomes an `undocumented-provider-allowance-expired` warning until renewed or removed.
97 changes: 97 additions & 0 deletions scripts/drift-check/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
const CATALOG_URL =
'https://raw.githubusercontent.com/amp-labs/connectors/main/internal/generated/catalog.json';

export type Capability = 'read' | 'write' | 'proxy' | 'subscribe' | 'search';

interface SearchSupport {
operators?: Record<string, boolean>;
}

interface SupportFlags {
read?: boolean;
write?: boolean;
proxy?: boolean;
subscribe?: boolean;
search?: SearchSupport;
}

interface ModuleEntry {
baseURL?: string;
displayName?: string;
support?: SupportFlags;
}

export interface CatalogProvider {
name?: string;
displayName?: string;
authType?: string;
baseURL?: string;
defaultModule?: string;
support?: SupportFlags;
modules?: Record<string, ModuleEntry>;
}

export interface Catalog {
data: Record<string, CatalogProvider>;
sourceUrl: string;
fetchedAt: string;
}

export async function fetchCatalog(): Promise<Catalog> {
const res = await fetch(CATALOG_URL);
if (!res.ok) {
throw new Error(`Failed to fetch catalog: ${res.status} ${res.statusText}`);
}
const json = (await res.json()) as { catalog: Record<string, CatalogProvider> };
return {
data: json.catalog,
sourceUrl: CATALOG_URL,
fetchedAt: new Date().toISOString(),
};
}

// Module-aware: returns true if the provider-level flag is true, or any
// module-level flag is true. Catches cases like Google, where the provider
// has subscribe=false but modules.gmail has subscribe=true.
export function supports(
provider: CatalogProvider | undefined,
capability: Capability,
): boolean {
if (!provider) return false;
if (capability === 'search') {
if (supportsSearch(provider.support?.search)) return true;
for (const mod of Object.values(provider.modules ?? {})) {
if (supportsSearch(mod.support?.search)) return true;
}
return false;
}
if (provider.support?.[capability]) return true;
for (const mod of Object.values(provider.modules ?? {})) {
if (mod.support?.[capability]) return true;
}
return false;
}

function supportsSearch(s: SearchSupport | undefined): boolean {
if (!s?.operators) return false;
return Object.values(s.operators).some(Boolean);
}

export function hasModules(provider: CatalogProvider | undefined): boolean {
return !!provider?.modules && Object.keys(provider.modules).length > 0;
}

export function modulesSupporting(
provider: CatalogProvider | undefined,
capability: Capability,
): string[] {
if (!provider?.modules) return [];
if (capability === 'search') {
return Object.entries(provider.modules)
.filter(([, mod]) => supportsSearch(mod.support?.search))
.map(([name]) => name);
}
return Object.entries(provider.modules)
.filter(([, mod]) => mod.support?.[capability])
.map(([name]) => name);
}
78 changes: 78 additions & 0 deletions scripts/drift-check/docs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { promises as fs } from 'node:fs';
import path from 'node:path';
import matter from 'gray-matter';

const PROVIDER_GUIDES_DIR = path.join(process.cwd(), 'src', 'provider-guides');

// Files in src/provider-guides/ that are not provider guides.
const NON_GUIDE_FILES = new Set(['overview.mdx', 'AGENTS.md', 'CLAUDE.md']);

export type Capability = 'read' | 'write' | 'proxy' | 'subscribe' | 'search';

export interface GuideDoc {
docPath: string;
slug: string;
providerKeyFromFrontmatter?: string;
guideType?: string;
claimedActions: Set<Capability>;
sampleLinks: string[];
}

const ACTION_PATTERNS: Array<{ cap: Capability; re: RegExp }> = [
{ cap: 'read', re: /\[Read Actions\]\(\/read-actions\)/ },
{ cap: 'write', re: /\[Write Actions\]\(\/write-actions\)/ },
{ cap: 'subscribe', re: /\[Subscribe Actions\]\(\/subscribe-actions\)/ },
{ cap: 'proxy', re: /\[Proxy Actions\]\(\/proxy-actions\)/ },
{ cap: 'search', re: /\[Search Actions\]\(\/search-actions\)/ },
];

const SAMPLE_LINK_RE =
/https:\/\/github\.com\/amp-labs\/samples\/blob\/[^/]+\/[^/]+\/amp\.ya?ml/g;

export async function scanGuides(): Promise<GuideDoc[]> {
const entries = await fs.readdir(PROVIDER_GUIDES_DIR);
const guides: GuideDoc[] = [];
for (const entry of entries) {
if (!entry.endsWith('.mdx')) continue;
if (NON_GUIDE_FILES.has(entry)) continue;
const full = path.join(PROVIDER_GUIDES_DIR, entry);
const raw = await fs.readFile(full, 'utf8');
const parsed = matter(raw);
const slug = entry.replace(/\.mdx$/, '');
const claimedActions = new Set<Capability>();
for (const { cap, re } of ACTION_PATTERNS) {
if (re.test(parsed.content)) claimedActions.add(cap);
}
const sampleLinks = Array.from(
new Set(parsed.content.match(SAMPLE_LINK_RE) ?? []),
);
guides.push({
docPath: path.relative(process.cwd(), full),
slug,
providerKeyFromFrontmatter:
typeof parsed.data.provider === 'string' ? parsed.data.provider : undefined,
guideType:
typeof parsed.data.guide_type === 'string' ? parsed.data.guide_type : undefined,
claimedActions,
sampleLinks,
});
}
return guides;
}

// Resolution priority: frontmatter `provider` > slugOverrides > case-insensitive
// match against catalog keys > filename slug verbatim.
export function resolveProviderKey(
guide: GuideDoc,
slugOverrides: Record<string, string>,
catalogKeys?: readonly string[],
): string {
if (guide.providerKeyFromFrontmatter) return guide.providerKeyFromFrontmatter;
if (slugOverrides[guide.slug]) return slugOverrides[guide.slug];
if (catalogKeys) {
const ci = guide.slug.toLowerCase();
const match = catalogKeys.find((k) => k.toLowerCase() === ci);
if (match) return match;
}
return guide.slug;
}
Loading
Loading