UNPKG

powerplatform-review-tool

Version:

Evaluate Power Platform solution zip files based on best practice patterns

549 lines (548 loc) 23.5 kB
"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 }; }