powerplatform-review-tool
Version:
Evaluate Power Platform solution zip files based on best practice patterns
549 lines (548 loc) • 23.5 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.extractTriggerPhrases = extractTriggerPhrases;
exports.extractBotDisplayName = extractBotDisplayName;
exports.getAuthenticationMode = getAuthenticationMode;
exports.countConditionsInActions = countConditionsInActions;
exports.extractUniqueFlowIds = extractUniqueFlowIds;
exports.collectBeginDialogReferences = collectBeginDialogReferences;
exports.formatTopicName = formatTopicName;
exports.mapAuthMode = mapAuthMode;
exports.getBasePatternInfo = getBasePatternInfo;
exports.getComponentStatus = getComponentStatus;
exports.isEndOfConversationTopic = isEndOfConversationTopic;
exports.xmlTag = xmlTag;
exports.toIOArray = toIOArray;
exports.toEntityItems = toEntityItems;
exports.collectBots = collectBots;
exports.collectComponents = collectComponents;
exports.buildAgents = buildAgents;
exports.extractAgentsDetails = extractAgentsDetails;
const js_yaml_1 = __importDefault(require("js-yaml"));
const ManifestConstant_1 = require("../ManifestConstant");
/**
* Extracts trigger queries from the component data structure.
*
* @param componentData - The component's data object.
* @returns An array of trigger queries if found, otherwise an empty array.
*/
function extractTriggerPhrases(componentData) {
// Extract the trigger queries from the structure
return componentData?.beginDialog?.intent?.triggerQueries || [];
}
async function extractBotDisplayName(zipContent, botFolderName) {
const botXmlPath = `bots/${botFolderName}/bot.xml`;
if (zipContent.files[botXmlPath]) {
const botXmlContent = await zipContent.file(botXmlPath)?.async("string");
const displayNameMatch = botXmlContent?.match(/<name>(.*?)<\/name>/);
return displayNameMatch ? displayNameMatch[1] : "";
}
return "";
}
async function getAuthenticationMode(botXmlContent) {
const authModeMatch = botXmlContent?.match(/<authenticationmode>(.*?)<\/authenticationmode>/);
return authModeMatch ? authModeMatch[1] : "";
}
function countConditionsInActions(actions) {
let conditionCount = 0;
for (const action of actions) {
if (action.kind === "ConditionGroup" && action.conditions) {
conditionCount += action.conditions.length; // Count current level conditions
for (const condition of action.conditions) {
if (condition.actions) {
conditionCount += countConditionsInActions(condition.actions); // Recursively count nested actions
}
}
}
else if (action.actions) {
conditionCount += countConditionsInActions(action.actions); // Handle other nested actions
}
}
return conditionCount;
}
function extractUniqueFlowIds(actions, flowIdSet) {
actions.forEach((action) => {
if (action.kind === "InvokeFlowAction" && action.flowId) {
flowIdSet.add(action.flowId);
}
// Recursively check nested actions
Object.keys(action).forEach((key) => {
if (Array.isArray(action[key])) {
extractUniqueFlowIds(action[key], flowIdSet);
}
});
});
}
/**
* NEW HELPER:
* Recursively collects the 'dialog' property from any 'BeginDialog' actions.
*/
function collectBeginDialogReferences(actions, usedTopicRefs) {
for (const action of actions) {
if (action.kind === "BeginDialog" && action.dialog) {
usedTopicRefs.add(action.dialog);
}
// Recurse into sub-actions
if (Array.isArray(action.actions)) {
collectBeginDialogReferences(action.actions, usedTopicRefs);
}
if (Array.isArray(action.conditions)) {
for (const condition of action.conditions) {
if (Array.isArray(condition.actions)) {
collectBeginDialogReferences(condition.actions, usedTopicRefs);
}
}
}
}
}
/**
* NEW HELPER:
* Formats the reference string for a topic:
* e.g. "cra0f_forecastAgentWithFlow.topic.Getweatherforecast"
*/
function formatTopicName(botLogicalName, topicName) {
// remove spaces from topicName
const topicNameNoSpaces = topicName.replace(/\s+/g, "");
return `${botLogicalName}.topic.${topicNameNoSpaces}`;
}
/**
* Helper to map numeric authentication modes 1, 2, or 3
* to their corresponding descriptive strings.
*/
function mapAuthMode(authModeNum) {
switch (authModeNum) {
case "1":
return "No Authentication";
case "2":
return "EntraID";
case "3":
return "Manual";
default:
return "Unknown";
}
}
/**
* Helper function to retrieve the base pattern info (name, description, recommendation, docLinks, severity, category)
* either from the user-provided patternDetails array or from the default BotPatternDetails.
*
* - If the patternID exists in `patternDetails`, we use its property values if they are non-empty (string check).
* - If any such property is empty, null, or missing, we fallback to the corresponding BotPatternDetails property.
* - If patternID not found in `patternDetails`, or not found in BotPatternDetails, we use final fallback defaults.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
function getBasePatternInfo(patternID, patternDetails) {
// Attempt to find a matching entry in BotPatternDetails
const matchedBotPattern = Object.values(ManifestConstant_1.BotPatternInfo).find((infoKey) => ManifestConstant_1.BotPatternDetails[infoKey].patternID === patternID);
// If matched, store the default from BotPatternDetails for fallback.
const botDefaults = matchedBotPattern
? ManifestConstant_1.BotPatternDetails[matchedBotPattern]
: {
patternName: patternID,
description: "",
recommendation: "",
docLink: "",
severity: "Medium",
category: "Usage",
};
// 1. Check if user-provided patternDetails has an entry for this patternID
if (patternDetails && patternDetails.length > 0) {
const custom = patternDetails.find((p) => p.data.patternID === patternID);
if (custom) {
// COMMENT: We do partial fallback for each property if empty or null.
const c = custom.data;
const patternName = c.patternName && c.patternName.trim().length > 0 ? c.patternName : botDefaults.patternName;
const description = c.description && c.description.trim().length > 0 ? c.description : botDefaults.description;
const recommendation = c.recommendation && c.recommendation.trim().length > 0 ? c.recommendation : botDefaults.recommendation;
const docLinks = c.docLinks && c.docLinks.toString().trim().length > 0 ? c.docLinks : botDefaults.docLink;
const severity = c.severity && c.severity.trim().length > 0 ? c.severity : botDefaults.severity;
const category = c.category && c.category.trim().length > 0 ? c.category : botDefaults.category;
return {
patternName,
description,
recommendation,
docLinks,
severity,
category,
};
}
}
// 2. If not found in patternDetails or no custom data for it, fallback to the BotPatternDetails or final defaults
return {
patternName: botDefaults.patternName,
description: botDefaults.description,
recommendation: botDefaults.recommendation,
docLinks: botDefaults.docLink,
severity: botDefaults.severity,
category: botDefaults.category,
};
}
/**
* If 'statecode' is found in XML and is "0", topic is enabled.
* If not found or any other value, treat as disabled => "1".
*/
function getComponentStatus(botComponentXml) {
if (!botComponentXml)
return "1";
const statecodeMatch = botComponentXml.match(/<statecode>(.*?)<\/statecode>/);
// if (!statecodeMatch) return "1";
// return statecodeMatch[1]; // "0" = enabled, otherwise "1" = disabled
return statecodeMatch ? statecodeMatch[1] : "0";
}
function isEndOfConversationTopic(data) {
// 1. Check top-level 'startBehavior'
if (!data || data.startBehavior !== "CancelOtherTopics") {
return false;
}
// 2. Only if startBehavior == "CancelOtherTopics", search deeply for:
// conversationOutcome: ResolvedImplied or ResolvedConfirmed
let foundOutcome = false;
function recurse(obj) {
if (!obj || typeof obj !== "object")
return;
for (const key of Object.keys(obj)) {
const val = obj[key];
if (key === "conversationOutcome" && (val === "ResolvedImplied" || val === "ResolvedConfirmed")) {
foundOutcome = true;
}
if (typeof val === "object") {
recurse(val);
}
}
}
recurse(data);
return foundOutcome;
}
/** Return the text of the first `<tag>` in xml or null. */
function xmlTag(xml, tag) {
return xml.match(new RegExp(`<${tag}>([\\s\\S]*?)<\\/${tag}>`, "i"))?.[1].trim() ?? null;
}
/** Convert an IO-properties object into an array of TopicIOProperty. */
function toIOArray(obj) {
if (!obj || typeof obj !== "object")
return [];
return Object.entries(obj).map(([k, d]) => ({
variableName: k,
displayName: d?.displayName ?? "",
description: d?.description ?? "",
type: d?.type ?? "",
}));
}
/** Convert ClosedListEntity items into array form. */
function toEntityItems(arr) {
if (!Array.isArray(arr))
return [];
return arr.map((it) => ({
displayName: it?.displayName ?? "",
synonyms: Array.isArray(it?.synonyms) ? it.synonyms : [],
}));
}
/* ------------------------------------------------------------------ */
/* A. BOT LEVEL extraction */
/* ------------------------------------------------------------------ */
/**
* collectBots
* Reads **every** folder under /bots, parses bot.xml + configuration.json
* and builds a map keyed by botSchemaName.
*/
async function collectBots(zip) {
const bots = new Map();
const BOT_PREFIX = "bots/";
Object.keys(zip.files).forEach((p) => {
if (!p.startsWith(BOT_PREFIX))
return;
const [, botFolder, fileName] = p.split(/\/+/); // [ "bots", "<folder>", "<file>" ]
if (!fileName || fileName === "")
return;
if (fileName === "bot.xml")
bots.set(botFolder, {}); // placeholder
});
for (const [folder] of bots) {
const xmlPath = `bots/${folder}/bot.xml`;
const cfgPath = `bots/${folder}/configuration.json`;
const botXml = (await zip.file(xmlPath)?.async("string")) ?? "";
const cfgRaw = (await zip.file(cfgPath)?.async("string")) ?? null;
const botCfg = cfgRaw ? JSON.parse(cfgRaw) : null;
const skeleton = {
botSchemaName: botXml.match(/<bot[^>]+schemaname="([^"]+)"/i)?.[1] ?? folder,
botDisplayName: xmlTag(botXml, "name") ?? folder,
authenticationmode: xmlTag(botXml, "authenticationmode") ?? "",
botXml,
botConfig: botCfg,
isGenModeEnabled: !!botCfg?.settings?.GenerativeActionsEnabled,
};
bots.set(skeleton.botSchemaName, skeleton);
}
return bots;
}
/* ------------------------------------------------------------------ */
/* B. COMPONENT level extraction */
/* ------------------------------------------------------------------ */
/**
* collectComponents
* Crawls every file in /botcomponents/, reads botcomponent.xml (+ data),
* and returns a list with **no dependency on the directory layout**.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
async function collectComponents(zip) {
const components = [];
Object.keys(zip.files)
.filter((p) => p.endsWith("/botcomponent.xml") && p.startsWith("botcomponents/"))
.forEach((xmlPath) => {
components.push({}); // placeholder, will fill later
});
for (let i = 0; i < components.length; i += 1) {
const xmlPath = Object.keys(zip.files).filter((p) => p.endsWith("/botcomponent.xml") && p.startsWith("botcomponents/"))[i];
if (!xmlPath)
continue;
const folderPath = xmlPath.slice(0, xmlPath.lastIndexOf("/") + 1); // keep trailing '/'
const rawXml = (await zip.file(xmlPath)?.async("string")) ?? "";
const rawYaml = (await zip.file(`${folderPath}data`)?.async("string")) ?? null;
/* IDs from XML */
const botSchemaName = rawXml.match(/<parentbotid>\s*<schemaname>([\s\S]*?)<\/schemaname>/i)?.[1].trim() ?? "UnknownBot";
const numericType = Number(xmlTag(rawXml, "componenttype") ?? -1);
/* Component name (fallback to schemaname tail) */
const nameFromXml = xmlTag(rawXml, "name");
const attrMatch = rawXml.match(/<botcomponent[^>]+schemaname="([^"]+)"/i)?.[1];
const fallbackName = attrMatch ? attrMatch.split(".").pop() ?? attrMatch : "UnknownComponent";
/* YAML parse (best-effort) */
let yamlObj = null;
if (rawYaml) {
try {
yamlObj = js_yaml_1.default.load(rawYaml);
}
catch (err) {
//console.warn(`[YAML-PARSE-FAILED] ${xmlPath} ${err.message}`);
console.warn("YAML parsing failed for Component Type: " +
numericType +
" Component Name: " +
nameFromXml +
" in Bot: " +
botSchemaName +
"\n", err.message);
}
}
const label = (0, ManifestConstant_1.codeToLabel)(numericType, yamlObj?.kind);
/* Finished record */
components[i] = {
botSchemaName,
componentType: label,
name: nameFromXml ?? fallbackName,
description: xmlTag(rawXml, "description") ?? "",
isEnabled: getComponentStatus(rawXml) === "0",
rawXml,
rawYaml,
yamlObj,
};
}
return components;
}
/* ------------------------------------------------------------------ */
/* C. Assemble full Agents[] */
/* ------------------------------------------------------------------ */
/**
* buildAgents
* Combines the outputs of collectBots() + collectComponents() into
* the final Agents[] array required by the rest of the solution.
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
function buildAgents(bots, comps) {
/* start with a map botSchemaName → Agents */
const agentsMap = new Map();
for (const bot of bots.values()) {
agentsMap.set(bot.botSchemaName, {
botLogicalName: bot.botSchemaName,
botDisplayName: bot.botDisplayName,
botXml: bot.botXml,
botConfig: bot.botConfig,
authenticationmode: bot.authenticationmode,
isGenModeEnabled: bot.isGenModeEnabled,
/* initialise arrays so they’re never undefined */
botComponents: [],
topics: [],
entities: [],
knowledge: [],
fileAttachments: [],
globalVariables: [],
actions: [],
skills: [],
customGPT: [],
externalTriggers: [],
copilotSettings: [],
translations: [],
unknowns: [],
}); // future fields not in interface yet
}
/* put every component into its bot bucket */
for (const c of comps) {
const agent = agentsMap.get(c.botSchemaName);
if (!agent)
continue; // component for a bot we didn’t export – ignore
/* always append to flat list */
agent.botComponents.push({
componentType: c.componentType,
name: c.name,
botComponentXml: c.rawXml,
data: c.yamlObj,
description: c.description,
isEnabled: c.isEnabled,
});
/* specialised buckets */
switch (c.componentType) {
case "topic":
agent.topics.push({
name: c.name,
rawXml: c.rawXml,
rawYamlData: c.rawYaml,
modelDescription: c.yamlObj?.modelDescription ?? "",
triggerQueries: c.yamlObj?.beginDialog?.intent?.triggerQueries ?? [],
inputType: toIOArray(c.yamlObj?.inputType?.properties),
outputType: toIOArray(c.yamlObj?.outputType?.properties),
isEnabled: c.isEnabled,
description: c.description,
});
break;
case "entity":
agent.entities.push({
name: c.name,
rawXml: c.rawXml,
rawYamlData: c.rawYaml,
description: c.description,
kind: c.yamlObj?.kind ?? "",
pattern: c.yamlObj?.kind === "RegexEntity" ? c.yamlObj?.pattern ?? "" : "",
items: c.yamlObj?.kind === "ClosedListEntity" ? toEntityItems(c.yamlObj?.items) : [],
isEnabled: c.isEnabled,
});
break;
case "knowledge":
agent.knowledge.push({
name: c.name,
description: c.description,
isEnabled: c.isEnabled,
kind: c.yamlObj?.kind ?? "",
rawXml: c.rawXml,
rawYamlData: c.rawYaml,
siteSearchKind: c.yamlObj?.source?.kind ?? "",
site: c.yamlObj?.source?.site ?? "",
skillConfiguration: c.yamlObj?.source?.skillConfiguration ?? "",
});
break;
case "fileAttachment":
agent.fileAttachments.push({
name: c.name,
description: c.description,
isEnabled: c.isEnabled,
rawXml: c.rawXml,
rawYamlData: c.rawYaml, // will be null – handled gracefully
});
break;
case "globalVariable":
agent.globalVariables.push({
name: c.yamlObj?.name ?? c.name, // name from YAML, fallback XML
description: c.description,
isEnabled: c.isEnabled,
rawXml: c.rawXml,
rawYamlData: c.rawYaml,
isExternalInitializationAllowed: typeof c.yamlObj?.isExternalInitializationAllowed === "boolean"
? c.yamlObj.isExternalInitializationAllowed
: null,
scope: c.yamlObj?.scope ?? "",
aIVisibility: c.yamlObj?.aIVisibility ?? "",
initializationTimeoutInMilliseconds: typeof c.yamlObj?.initializationTimeoutInMilliseconds === "number"
? c.yamlObj.initializationTimeoutInMilliseconds
: null,
});
break;
case "action":
agent.actions.push({
name: c.name,
rawXml: c.rawXml,
rawYamlData: c.rawYaml,
description: c.description ?? "",
modelDisplayName: c.yamlObj?.modelDisplayName ?? "",
modelDescription: c.yamlObj?.modelDescription ?? "",
inputs: Array.isArray(c.yamlObj?.inputs)
? c.yamlObj.inputs.map((inp) => ({
kind: inp.kind ?? "",
propertyName: inp.propertyName ?? "",
name: inp.name ?? "",
description: inp.description ?? "",
value: inp.value ?? "",
entity: inp.entity ?? "",
shouldPromptUser: inp.shouldPromptUser,
}))
: [],
outputs: Array.isArray(c.yamlObj?.outputs)
? c.yamlObj.outputs.map((out) => ({
propertyName: out.propertyName ?? "",
description: out.description ?? "",
}))
: [],
actionKind: c.yamlObj?.action?.kind ?? "",
outputMode: c.yamlObj?.outputMode ?? "",
isEnabled: c.isEnabled,
});
break;
case "skill":
agent.skills.push(c);
break;
case "customGPT":
agent.customGPT.push({
name: c.name,
rawXml: c.rawXml,
rawYamlData: c.rawYaml,
description: c.description ?? "",
isEnabled: c.isEnabled,
instructions: c.yamlObj?.instructions ?? "",
conversationStarters: Array.isArray(c.yamlObj?.conversationStarters)
? c.yamlObj.conversationStarters.map((cs) => ({
title: cs?.title ?? "",
text: cs?.text ?? "",
}))
: [],
});
break;
case "externalTrigger":
agent.externalTriggers.push({
name: c.name,
rawXml: c.rawXml,
rawYamlData: c.rawYaml,
description: c.description ?? "",
isEnabled: c.isEnabled,
externalTriggerSourceKind: c.yamlObj?.externalTriggerSource?.kind ?? "",
flowId: c.yamlObj?.externalTriggerSource?.flowId ?? "",
flowName: c.yamlObj?.extensionData?.flowName ?? "",
flowUrl: c.yamlObj?.extensionData?.flowUrl ?? "",
triggerConnectionType: c.yamlObj?.extensionData?.triggerConnectionType ?? "",
});
break;
case "copilotSettings":
agent.copilotSettings.push(c);
break;
case "translations":
agent.translations.push(c);
break;
// case for actions??
default:
agent.unknowns.push(c);
}
}
return Array.from(agentsMap.values());
}
/* ------------------------------------------------------------------ */
/* D. One-stop API */
/* ------------------------------------------------------------------ */
/**
* extractAgentsDetails(zip)
* Convenience wrapper that performs all three steps and returns Agents[].
*/
async function extractAgentsDetails(zip) {
const bots = await collectBots(zip);
const comps = await collectComponents(zip);
const agents = buildAgents(bots, comps);
/* one console dump for verification */
console.log("[AGENTS-LIST]", agents);
return { agents };
}