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
360 changes: 360 additions & 0 deletions .github/scripts/sync-capabilities.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,360 @@
#!/usr/bin/env node

/**
* Sync Capabilities Matrix
*
* This script parses specified connector documentation files and compares their
* stated capabilities against the capabilities matrix in capabilities.mdx.
* If mismatches are found, it updates the matrix and outputs a summary.
*
* Usage: node sync-capabilities.js [file1.mdx] [file2.mdx] ...
*
* Detection is based on explicit statements in connector docs:
* - Account provisioning: "account provisioning" or Accounts row with Provision checkmark
* - Account deprovisioning: "and deprovisioning" or "deprovision" without negation
* - Entitlement provisioning: Non-account resources with Provision checkmark
*/

const fs = require('fs');
const path = require('path');

const BATON_DIR = path.join(__dirname, '../../baton');
const CAPABILITIES_FILE = path.join(BATON_DIR, 'capabilities.mdx');

/**
* Parse the capabilities table from a connector doc
* Returns structured info about what can be provisioned
*/
function parseCapabilitiesTable(content) {
const result = {
hasTable: false,
accountsCanProvision: false,
entitlementsCanProvision: false,
resources: [],
};

// Find the capabilities table
const tableMatch = content.match(/##\s*Capabilities[\s\S]*?\|[^|]*Resource[^|]*\|[^|]*Sync[^|]*\|[^|]*Provision[^|]*\|([\s\S]*?)(?=\n\n|\n##|\n<)/i);

if (!tableMatch) return result;
Comment on lines +36 to +39
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Allow capabilities table parsing at EOF.

The regex requires a blank line/heading/HTML tag after the table, so a table at end-of-file won’t be detected and hasTable stays false.

🛠️ Suggested fix
-  const tableMatch = content.match(/##\s*Capabilities[\s\S]*?\|[^|]*Resource[^|]*\|[^|]*Sync[^|]*\|[^|]*Provision[^|]*\|([\s\S]*?)(?=\n\n|\n##|\n<)/i);
+  const tableMatch = content.match(/##\s*Capabilities[\s\S]*?\|[^|]*Resource[^|]*\|[^|]*Sync[^|]*\|[^|]*Provision[^|]*\|([\s\S]*?)(?=\n\n|\n##|\n<|$)/i);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Find the capabilities table
const tableMatch = content.match(/##\s*Capabilities[\s\S]*?\|[^|]*Resource[^|]*\|[^|]*Sync[^|]*\|[^|]*Provision[^|]*\|([\s\S]*?)(?=\n\n|\n##|\n<)/i);
if (!tableMatch) return result;
// Find the capabilities table
const tableMatch = content.match(/##\s*Capabilities[\s\S]*?\|[^|]*Resource[^|]*\|[^|]*Sync[^|]*\|[^|]*Provision[^|]*\|([\s\S]*?)(?=\n\n|\n##|\n<|$)/i);
if (!tableMatch) return result;
🤖 Prompt for AI Agents
In @.github/scripts/sync-capabilities.js around lines 36 - 39, The
capabilities-table regex used to set tableMatch (the content.match call) fails
to detect a table at EOF because its lookahead requires a following blank
line/heading/HTML tag; update that regex to also allow end-of-string by adding
an alternative for end-of-input (e.g., include |$ in the lookahead). Locate the
content.match(...) expression that produces tableMatch and adjust the final
(?=\n\n|\n##|\n<) lookahead to (?=\n\n|\n##|\n<|$) so tables at file end are
recognized.


result.hasTable = true;
const tableContent = tableMatch[0];

// Parse each row
const rowRegex = /^\|\s*([^|]+)\s*\|([^|]*)\|([^|]*)\|/gm;
let match;

while ((match = rowRegex.exec(tableContent)) !== null) {
const resource = match[1].trim();
const provisionCol = match[3];

// Skip header row
if (resource.toLowerCase() === 'resource' || resource.startsWith(':') || resource.startsWith('-')) {
continue;
}

const canProvision = provisionCol.includes('square-check');
result.resources.push({ resource, canProvision });

// Check if this is accounts provisioning
if (/^accounts?$/i.test(resource) && canProvision) {
result.accountsCanProvision = true;
}

// Check for entitlement-type resources (non-accounts that can be provisioned)
if (!/^accounts?$/i.test(resource) && canProvision) {
result.entitlementsCanProvision = true;
}
}

return result;
}

/**
* Parse a connector doc file and extract capabilities
*/
function parseConnectorDoc(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const fileName = path.basename(filePath, '.mdx');

// Parse the capabilities table first
const tableInfo = parseCapabilitiesTable(content);

const capabilities = {
fileName,
cloudHosted: false,
selfHosted: false,
provisionsEntitlements: false,
provisionsAccounts: false,
deprovisionsAccounts: false,
confidence: 'low',
};

// Check for hosting options
capabilities.cloudHosted = /<Tab\s+title=["']Cloud-hosted["']/i.test(content);
capabilities.selfHosted = /<Tab\s+title=["']Self-hosted["']/i.test(content);

// Use table info as primary source
if (tableInfo.hasTable) {
capabilities.provisionsAccounts = tableInfo.accountsCanProvision;
capabilities.provisionsEntitlements = tableInfo.entitlementsCanProvision;
capabilities.confidence = 'high';
}

// Check for account provisioning text (high confidence patterns)
const hasExplicitAccountProvisioning =
/supports?\s+(?:\[)?automatic\s+account\s+provisioning/i.test(content) ||
/connector\s+supports?\s+(?:\[)?(?:automatic\s+)?account\s+provisioning/i.test(content);

if (hasExplicitAccountProvisioning) {
capabilities.provisionsAccounts = true;
capabilities.confidence = 'high';
}

// Check for deprovisioning - must have explicit positive mention
const hasDeprovisioningMention =
/provisioning\s+and\s+deprovisioning/i.test(content) ||
/account\s+provisioning\s+and\s+deprovisioning/i.test(content);

const hasNegativeDeprovisioning =
/does\s+not\s+support\s+(?:account\s+)?deprovisioning/i.test(content) ||
/cannot\s+deprovision/i.test(content) ||
/must\s+deprovision\s+(?:accounts?\s+)?directly/i.test(content);

if (hasDeprovisioningMention && !hasNegativeDeprovisioning) {
capabilities.deprovisionsAccounts = true;
} else if (hasNegativeDeprovisioning) {
capabilities.deprovisionsAccounts = false;
capabilities.confidence = 'high';
}
Comment on lines +84 to +130
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Avoid flagging deprovisioning mismatches when docs are silent.

deprovisionsAccounts defaults to false, and findMismatches always compares it, so connectors without any deprovisioning mention are treated as “does not support,” which can trigger noisy PRs. Track an “unknown” state and only compare when there’s an explicit positive/negative mention.

🛠️ Suggested fix (tri-state + gated comparison)
   const capabilities = {
     fileName,
     cloudHosted: false,
     selfHosted: false,
     provisionsEntitlements: false,
     provisionsAccounts: false,
-    deprovisionsAccounts: false,
+    deprovisionsAccounts: null,
     confidence: 'low',
   };
@@
-  if (hasDeprovisioningMention && !hasNegativeDeprovisioning) {
-    capabilities.deprovisionsAccounts = true;
-  } else if (hasNegativeDeprovisioning) {
-    capabilities.deprovisionsAccounts = false;
-    capabilities.confidence = 'high';
-  }
+  if (hasDeprovisioningMention && !hasNegativeDeprovisioning) {
+    capabilities.deprovisionsAccounts = true;
+    capabilities.confidence = 'high';
+  } else if (hasNegativeDeprovisioning) {
+    capabilities.deprovisionsAccounts = false;
+    capabilities.confidence = 'high';
+  }
@@
-  if (docCaps.deprovisionsAccounts !== matrixCaps.provisioning.deprovisions) {
+  if (docCaps.deprovisionsAccounts !== null &&
+      docCaps.deprovisionsAccounts !== matrixCaps.provisioning.deprovisions) {

Also applies to: 198-209

🤖 Prompt for AI Agents
In @.github/scripts/sync-capabilities.js around lines 84 - 130, The current
logic sets capabilities.deprovisionsAccounts to false by default which causes
silent docs to be treated as "does not support" in downstream findMismatches;
change deprovisionsAccounts to a tri-state (e.g., null or 'unknown') initially
and only set it to true when hasDeprovisioningMention is true and not
hasNegativeDeprovisioning, or explicitly set to false when
hasNegativeDeprovisioning is true; update confidence (e.g., set to 'high' when
explicit negative or positive) and modify the consumer logic (findMismatches) to
skip comparisons when capabilities.deprovisionsAccounts is unknown/null so only
explicit positive/negative mentions are compared.


return capabilities;
}

/**
* Parse the capabilities matrix and extract current state
*/
function parseCapabilitiesMatrix(content) {
const connectors = new Map();

// Match table rows (skip header and separator)
const tableRegex = /^\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|([^|]*)\|([^|]*)\|([^|]*)\|$/gm;
let match;

while ((match = tableRegex.exec(content)) !== null) {
const [fullMatch, name, link, hosting, provisioning, other] = match;

// Extract the file name from the link
const linkMatch = link.match(/\/baton\/([^)]+)/);
const fileName = linkMatch ? linkMatch[1] : null;

Comment on lines +148 to +151
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Inspect connector links in capabilities.mdx
fd -a 'capabilities.mdx' -x rg -n '\]\([^)]*baton/[^)]*\)' -A 1 -B 1

Repository: ConductorOne/docs

Length of output: 31027


🏁 Script executed:

#!/bin/bash
# Examine the sync-capabilities.js file around the flagged lines and downstream
cat -n .github/scripts/sync-capabilities.js | head -200

Repository: ConductorOne/docs

Length of output: 7912


🏁 Script executed:

#!/bin/bash
# Check if the map key usage is actually mismatching
# Search for where fileName is used after being set
rg -n 'fileName' .github/scripts/sync-capabilities.js -B 3 -A 3

Repository: ConductorOne/docs

Length of output: 2108


Normalize connector filename keys to match file structure.

The link extraction captures the full path component (e.g., v1/aws from /baton/v1/aws), but the matrix lookup uses only the basename of the actual file (e.g., aws from baton/v1/aws.mdx). This causes versioned connectors to be silently skipped from comparisons because the map lookup fails.

🛠️ Suggested fix
    const linkMatch = link.match(/\/baton\/([^)]+)/);
-   const fileName = linkMatch ? linkMatch[1] : null;
+   const linkedPath = linkMatch ? linkMatch[1].split('#')[0] : null;
+   const fileName = linkedPath ? path.basename(linkedPath, '.mdx') : null;
🤖 Prompt for AI Agents
In @.github/scripts/sync-capabilities.js around lines 148 - 151, The extraction
of fileName from link currently captures a path like "v1/aws" (linkMatch →
fileName) which doesn't match the matrix keys that use the basename ("aws");
update the normalization in the block that computes fileName (the
linkMatch/fileName logic) to take only the last path segment (e.g., split on '/'
and use the final element or use path.basename) and also strip any file
extension like ".mdx" so the resulting key exactly matches the connector map
lookup; adjust usages of fileName accordingly (same symbol: fileName) so
versioned paths no longer miss matches.

if (!fileName) continue;

connectors.set(fileName, {
name,
link,
fullMatch,
hosting: {
cloud: hosting.includes('icon="cloud"'),
selfHosted: hosting.includes('icon="plug"'),
},
provisioning: {
entitlements: provisioning.includes('icon="key"'),
accounts: provisioning.includes('icon="user"'),
deprovisions: provisioning.includes('icon="face-confused"'),
},
other: {
secrets: other.includes('icon="face-shush"'),
ticketing: other.includes('icon="ticket"'),
lastLogin: other.includes('icon="clock"'),
passwords: other.includes('rect x="3" y="11"'),
shadowApps: other.includes('icon="flashlight"'),
},
rawHosting: hosting.trim(),
rawProvisioning: provisioning.trim(),
rawOther: other.trim(),
});
}

return connectors;
}

/**
* Build the provisioning cell icons string
*/
function buildProvisioningIcons(caps) {
const icons = [];
if (caps.provisionsEntitlements) icons.push('<Icon icon="key" />');
if (caps.provisionsAccounts) icons.push('<Icon icon="user" />');
if (caps.deprovisionsAccounts) icons.push('<Icon icon="face-confused" />');
return icons.join(' ');
}

/**
* Compare detected capabilities with matrix and find mismatches
* Only report mismatches where we have high confidence in the doc parsing
*/
function findMismatches(docCaps, matrixCaps) {
const mismatches = [];

// Only report mismatches for deprovisioning, which is more reliably detected
if (docCaps.deprovisionsAccounts !== matrixCaps.provisioning.deprovisions) {
mismatches.push({
field: 'deprovisioning (face-confused icon)',
doc: docCaps.deprovisionsAccounts,
matrix: matrixCaps.provisioning.deprovisions,
});
}

// Only report account provisioning if we have high confidence
if (docCaps.confidence === 'high' &&
docCaps.provisionsAccounts !== matrixCaps.provisioning.accounts) {
mismatches.push({
field: 'accounts (user icon)',
doc: docCaps.provisionsAccounts,
matrix: matrixCaps.provisioning.accounts,
});
}

// Only report entitlement provisioning if we have a capabilities table
if (docCaps.confidence === 'high' &&
docCaps.provisionsEntitlements !== matrixCaps.provisioning.entitlements) {
mismatches.push({
field: 'entitlements (key icon)',
doc: docCaps.provisionsEntitlements,
matrix: matrixCaps.provisioning.entitlements,
});
}

return mismatches;
}

/**
* Main function
*/
function main() {
// Get files to process from command line arguments
const args = process.argv.slice(2);

if (args.length === 0) {
console.log(JSON.stringify({
hasChanges: false,
mismatchCount: 0,
summary: 'No connector files specified.',
mismatches: [],
}, null, 2));
return;
}

// Read capabilities matrix
const matrixContent = fs.readFileSync(CAPABILITIES_FILE, 'utf-8');
const matrixConnectors = parseCapabilitiesMatrix(matrixContent);

const allMismatches = [];
let updatedContent = matrixContent;

for (const filePath of args) {
// Handle both relative and absolute paths
const fullPath = path.isAbsolute(filePath) ? filePath : path.join(process.cwd(), filePath);

if (!fs.existsSync(fullPath)) {
console.error(`Warning: File not found: ${fullPath}`);
continue;
}

const fileName = path.basename(fullPath, '.mdx');

// Skip non-connector files
if (fileName === 'capabilities' || fileName.startsWith('_') || fileName.startsWith('baton-')) {
continue;
}

// Parse the connector doc
const docCaps = parseConnectorDoc(fullPath);

// Find the connector in the matrix
const matrixEntry = matrixConnectors.get(fileName);

if (!matrixEntry) {
// Connector not in matrix - skip
continue;
}

// Compare capabilities
const mismatches = findMismatches(docCaps, matrixEntry);

if (mismatches.length > 0) {
allMismatches.push({
connector: matrixEntry.name,
fileName,
mismatches,
docCaps,
matrixEntry,
});

// Build the updated provisioning icons based on what the doc says
const updatedCaps = {
provisionsEntitlements: mismatches.find(m => m.field.includes('entitlements'))
? docCaps.provisionsEntitlements
: matrixEntry.provisioning.entitlements,
provisionsAccounts: mismatches.find(m => m.field.includes('accounts'))
? docCaps.provisionsAccounts
: matrixEntry.provisioning.accounts,
deprovisionsAccounts: mismatches.find(m => m.field.includes('deprovisioning'))
? docCaps.deprovisionsAccounts
: matrixEntry.provisioning.deprovisions,
};

const newProvisioningIcons = buildProvisioningIcons(updatedCaps);

// Update the row
const newRow = matrixEntry.fullMatch.replace(
/\|([^|]*)\|([^|]*)\|$/,
`| ${newProvisioningIcons} |$2|`
);

updatedContent = updatedContent.replace(matrixEntry.fullMatch, newRow);
}
}

// Write updated content if there are changes
const hasChanges = allMismatches.length > 0;

if (hasChanges) {
fs.writeFileSync(CAPABILITIES_FILE, updatedContent);
}

// Build summary
let summary = '';
if (hasChanges) {
for (const { connector, fileName, mismatches } of allMismatches) {
summary += `### ${connector}\n`;
summary += `File: \`baton/${fileName}.mdx\`\n\n`;
for (const m of mismatches) {
const docStatus = m.doc ? 'supports' : 'does not support';
const matrixStatus = m.matrix ? 'shows' : 'does not show';
summary += `- **${m.field}**: Documentation says connector ${docStatus} this, but matrix ${matrixStatus} it\n`;
}
summary += '\n';
}
} else {
summary = 'No mismatches found. The capabilities matrix is in sync with the checked connector docs.';
}

// Output result as JSON
const result = {
hasChanges,
mismatchCount: allMismatches.length,
summary,
mismatches: allMismatches.map(m => ({
connector: m.connector,
fileName: m.fileName,
issues: m.mismatches,
})),
};

console.log(JSON.stringify(result, null, 2));
}

main();
Loading