geo-ai-search-optimization 2.5.0 → 2.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -193,7 +193,7 @@ Full TypeScript declarations included (`index.d.ts`) — 230+ exports with IDE a
193
193
  ## GitHub Action
194
194
 
195
195
  ```yaml
196
- - uses: redredchen01/geo-ai-search-optimization@v2.5.0
196
+ - uses: redredchen01/geo-ai-search-optimization@v2.7.0
197
197
  with:
198
198
  project-path: ./your-project
199
199
  min-score: 60
package/action.yml CHANGED
@@ -51,7 +51,7 @@ runs:
51
51
 
52
52
  - name: Install geo-ai-search-optimization
53
53
  shell: bash
54
- run: npm install -g geo-ai-search-optimization@2.5.0
54
+ run: npm install -g geo-ai-search-optimization@2.7.0
55
55
 
56
56
  - name: Run GEO Audit
57
57
  id: audit
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "geo-ai-search-optimization",
3
- "version": "2.5.0",
3
+ "version": "2.7.0",
4
4
  "description": "Install and run a Generative Engine Optimization (GEO)-first, SEO-supported Codex skill for website optimization.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,359 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { fetchText } from "./fetch-utils.js";
4
+ import { writeScanOutput } from "./scan.js";
5
+
6
+ function stripHtml(text) {
7
+ return text
8
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
9
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
10
+ .replace(/<nav[\s\S]*?<\/nav>/gi, " ")
11
+ .replace(/<footer[\s\S]*?<\/footer>/gi, " ")
12
+ .replace(/<[^>]+>/g, " ")
13
+ .replace(/&[a-z0-9#]+;/gi, " ")
14
+ .replace(/\s+/g, " ")
15
+ .trim();
16
+ }
17
+
18
+ function extractPlainText(content) {
19
+ if (/<html|<head|<body/i.test(content)) {
20
+ return stripHtml(content);
21
+ }
22
+ return content.replace(/^---[\s\S]*?---/, "").trim();
23
+ }
24
+
25
+ /**
26
+ * Extract headings and their positions so we can detect "follows a heading".
27
+ */
28
+ function extractHeadingPositions(content) {
29
+ const positions = [];
30
+ const regex = /<h[1-6][^>]*>([\s\S]*?)<\/h[1-6]>/gi;
31
+ let match;
32
+ while ((match = regex.exec(content)) !== null) {
33
+ positions.push(match.index);
34
+ }
35
+ // Also match markdown headings
36
+ const mdRegex = /^#{1,6}\s+.+$/gm;
37
+ while ((match = mdRegex.exec(content)) !== null) {
38
+ positions.push(match.index);
39
+ }
40
+ return positions;
41
+ }
42
+
43
+ function splitSentences(text) {
44
+ return text
45
+ .split(/(?<=[.!?])\s+/)
46
+ .map((s) => s.trim())
47
+ .filter((s) => s.length > 10);
48
+ }
49
+
50
+ const NUMBER_STAT_PATTERN = /\b\d+(\.\d+)?\s*(%|percent|million|billion|thousand|x|times|fold)\b/i;
51
+ const NUMBER_PATTERN = /\b\d{2,}(\.\d+)?\b/;
52
+ const DATE_PATTERN = /\b(20[12]\d|19\d\d)\b/;
53
+ const DEFINITIVE_PATTERN = /\b(is|are|was|were|means?|refers? to|defined as|according to|known as)\b/i;
54
+ const SELF_CONTAINED_PATTERN = /^[A-Z][A-Za-z]+\s+(is|are|was|were|has|have|had|can|could|will|would|should|must|means?)\b/;
55
+ const PROPER_NOUN_PATTERN = /\b[A-Z][a-z]+(?:\s+[A-Z][a-z]+)+\b/;
56
+ const NAV_JUNK_PATTERN = /\b(click here|read more|learn more|sign up|subscribe|menu|navigation|skip to|cookie|privacy policy)\b/i;
57
+
58
+ function scoreSentence(sentence, index, totalSentences, followsHeading) {
59
+ let score = 0;
60
+ const reasons = [];
61
+ const wordCount = sentence.split(/\s+/).length;
62
+
63
+ // +25 if contains number/stat/percentage
64
+ if (NUMBER_STAT_PATTERN.test(sentence) || (NUMBER_PATTERN.test(sentence) && DATE_PATTERN.test(sentence))) {
65
+ score += 25;
66
+ reasons.push("contains numbers/stats");
67
+ }
68
+
69
+ // +20 if self-contained (starts with subject, has verb)
70
+ if (SELF_CONTAINED_PATTERN.test(sentence)) {
71
+ score += 20;
72
+ reasons.push("self-contained");
73
+ }
74
+
75
+ // +15 if uses definitive language
76
+ if (DEFINITIVE_PATTERN.test(sentence)) {
77
+ score += 15;
78
+ reasons.push("definitive language");
79
+ }
80
+
81
+ // +15 if length 20-60 words (optimal snippet length)
82
+ if (wordCount >= 20 && wordCount <= 60) {
83
+ score += 15;
84
+ reasons.push("optimal length");
85
+ }
86
+
87
+ // +10 if in first 30% of content
88
+ if (totalSentences > 0 && index / totalSentences < 0.3) {
89
+ score += 10;
90
+ reasons.push("early position");
91
+ }
92
+
93
+ // +10 if follows a heading
94
+ if (followsHeading) {
95
+ score += 10;
96
+ reasons.push("follows heading");
97
+ }
98
+
99
+ // +5 if contains a proper noun
100
+ if (PROPER_NOUN_PATTERN.test(sentence)) {
101
+ score += 5;
102
+ reasons.push("proper noun");
103
+ }
104
+
105
+ // -20 if contains navigation text
106
+ if (NAV_JUNK_PATTERN.test(sentence)) {
107
+ score -= 20;
108
+ reasons.push("navigation/junk text");
109
+ }
110
+
111
+ return { score: Math.max(0, Math.min(100, score)), reasons };
112
+ }
113
+
114
+ function buildFollowsHeadingMap(plainText, sentences, rawContent) {
115
+ const headingPositions = extractHeadingPositions(rawContent);
116
+ const map = new Map();
117
+
118
+ for (let i = 0; i < sentences.length; i++) {
119
+ const sentencePos = plainText.indexOf(sentences[i]);
120
+ let follows = false;
121
+ // Check if any heading appears shortly before this sentence in raw content
122
+ // Simplified: first sentence and sentences right after a heading-like pattern
123
+ if (i === 0) {
124
+ follows = true;
125
+ } else {
126
+ const prevSentence = sentences[i - 1];
127
+ // If previous text ends near a heading boundary, mark as follows heading
128
+ if (headingPositions.length > 0) {
129
+ follows = i <= headingPositions.length;
130
+ }
131
+ }
132
+ map.set(i, follows);
133
+ }
134
+
135
+ return map;
136
+ }
137
+
138
+ function generateSimulatedSnippets(topQuotables, source) {
139
+ const bestSentence = topQuotables[0]?.sentence || "No quotable content found.";
140
+ const secondBest = topQuotables[1]?.sentence || bestSentence;
141
+ const domain = extractDomain(source);
142
+
143
+ return {
144
+ chatgpt: {
145
+ snippet: `According to ${domain}, ${lowerFirst(bestSentence)} ${secondBest.length < 200 ? secondBest : ""}`.trim(),
146
+ citation: `Source: ${source}`
147
+ },
148
+ perplexity: {
149
+ snippet: `"${bestSentence}" [1]`,
150
+ citation: `[1] ${source}`
151
+ },
152
+ googleAio: {
153
+ snippet: `${bestSentence} ${topQuotables[2]?.sentence || ""}`.trim(),
154
+ citation: source
155
+ },
156
+ gemini: {
157
+ snippet: bestSentence,
158
+ citation: `Based on information from ${domain}`
159
+ }
160
+ };
161
+ }
162
+
163
+ function extractDomain(source) {
164
+ try {
165
+ return new URL(source).hostname;
166
+ } catch {
167
+ return path.basename(source);
168
+ }
169
+ }
170
+
171
+ function lowerFirst(str) {
172
+ if (!str) return str;
173
+ // Don't lowercase if starts with proper noun or acronym
174
+ if (/^[A-Z]{2,}/.test(str) || /^[A-Z][a-z]+\s+[A-Z]/.test(str)) return str;
175
+ return str[0].toLowerCase() + str.slice(1);
176
+ }
177
+
178
+ function getScoreLabel(score) {
179
+ if (score >= 80) return "Highly Quotable";
180
+ if (score >= 60) return "Quotable";
181
+ if (score >= 40) return "Partially Quotable";
182
+ return "Low Quotability";
183
+ }
184
+
185
+ function buildRecommendations(topQuotables, sentences, score) {
186
+ const recs = [];
187
+ const avgScore = topQuotables.length > 0
188
+ ? Math.round(topQuotables.reduce((s, q) => s + q.score, 0) / topQuotables.length)
189
+ : 0;
190
+
191
+ if (topQuotables.length < 3) {
192
+ recs.push("Add more self-contained, factual sentences that AI engines can directly quote.");
193
+ }
194
+ if (avgScore < 40) {
195
+ recs.push("Strengthen sentences with specific numbers, statistics, or data points.");
196
+ }
197
+
198
+ const hasDefinitive = topQuotables.some((q) => q.reason.includes("definitive language"));
199
+ if (!hasDefinitive) {
200
+ recs.push("Use definitive language patterns like 'X is...', 'X means...', 'According to...' for clearer AI extraction.");
201
+ }
202
+
203
+ const hasOptimalLength = topQuotables.some((q) => q.reason.includes("optimal length"));
204
+ if (!hasOptimalLength) {
205
+ recs.push("Write key sentences in the 20-60 word range for optimal AI snippet length.");
206
+ }
207
+
208
+ const hasNumbers = topQuotables.some((q) => q.reason.includes("numbers/stats"));
209
+ if (!hasNumbers) {
210
+ recs.push("Include specific numbers, percentages, or statistics to increase citation likelihood.");
211
+ }
212
+
213
+ if (sentences.length < 10) {
214
+ recs.push("Expand content with more informative sentences to give AI engines more material to cite.");
215
+ }
216
+
217
+ const earlyQuotables = topQuotables.filter((q) => q.reason.includes("early position"));
218
+ if (earlyQuotables.length === 0 && topQuotables.length > 0) {
219
+ recs.push("Move your strongest factual claims earlier in the content for position bias advantage.");
220
+ }
221
+
222
+ return recs;
223
+ }
224
+
225
+ export async function simulateAiSnippet(input, options = {}) {
226
+ let rawContent;
227
+ let source;
228
+
229
+ if (/^https?:\/\//i.test(input)) {
230
+ rawContent = await fetchText(input);
231
+ source = input;
232
+ } else {
233
+ const filePath = path.resolve(input);
234
+ rawContent = await fs.readFile(filePath, "utf8");
235
+ source = filePath;
236
+ }
237
+
238
+ const plainText = extractPlainText(rawContent);
239
+ const sentences = splitSentences(plainText);
240
+ const followsHeadingMap = buildFollowsHeadingMap(plainText, sentences, rawContent);
241
+
242
+ // Score all sentences
243
+ const scored = sentences.map((sentence, index) => {
244
+ const { score, reasons } = scoreSentence(
245
+ sentence,
246
+ index,
247
+ sentences.length,
248
+ followsHeadingMap.get(index) || false
249
+ );
250
+ return {
251
+ sentence: sentence.slice(0, 300),
252
+ score,
253
+ reason: reasons.join(", ") || "baseline",
254
+ position: index
255
+ };
256
+ });
257
+
258
+ // Sort by score descending, take top quotables
259
+ const topQuotables = scored
260
+ .filter((s) => s.score > 0)
261
+ .sort((a, b) => b.score - a.score)
262
+ .slice(0, 10);
263
+
264
+ // Generate simulated snippets
265
+ const simulatedSnippets = generateSimulatedSnippets(topQuotables, source);
266
+
267
+ // Snippet readiness
268
+ const top5 = topQuotables.slice(0, 5);
269
+ const avgScore = top5.length > 0
270
+ ? Math.round(top5.reduce((s, q) => s + q.score, 0) / top5.length)
271
+ : 0;
272
+ const topScore = top5.length > 0 ? top5[0].score : 0;
273
+
274
+ const overallScore = Math.round(Math.min(avgScore, 100));
275
+ const scoreLabel = getScoreLabel(overallScore);
276
+ const recommendations = buildRecommendations(topQuotables, sentences, overallScore);
277
+
278
+ return {
279
+ kind: "geo-ai-snippet-simulator",
280
+ source,
281
+ topQuotables,
282
+ simulatedSnippets,
283
+ snippetReadiness: {
284
+ quotableSentences: topQuotables.length,
285
+ avgScore,
286
+ topScore
287
+ },
288
+ recommendations,
289
+ score: overallScore,
290
+ scoreLabel,
291
+ summary: `Snippet readiness: ${overallScore}/100 (${scoreLabel}). ${topQuotables.length} quotable sentences found. ${recommendations[0] || "Content is well-optimized for AI snippet extraction."}`
292
+ };
293
+ }
294
+
295
+ export function renderAiSnippetMarkdown(report) {
296
+ const lines = [
297
+ "# AI Snippet Simulator",
298
+ "",
299
+ `- Source: \`${report.source}\``,
300
+ `- Snippet Readiness Score: \`${report.score}/100\` (${report.scoreLabel})`,
301
+ `- Quotable Sentences: \`${report.snippetReadiness.quotableSentences}\``,
302
+ `- Average Score (top 5): \`${report.snippetReadiness.avgScore}\``,
303
+ `- Top Score: \`${report.snippetReadiness.topScore}\``,
304
+ "",
305
+ "## Simulated AI Snippets",
306
+ "",
307
+ "### ChatGPT Style",
308
+ "",
309
+ `> ${report.simulatedSnippets.chatgpt.snippet}`,
310
+ "",
311
+ `*${report.simulatedSnippets.chatgpt.citation}*`,
312
+ "",
313
+ "### Perplexity Style",
314
+ "",
315
+ `> ${report.simulatedSnippets.perplexity.snippet}`,
316
+ "",
317
+ `*${report.simulatedSnippets.perplexity.citation}*`,
318
+ "",
319
+ "### Google AI Overviews Style",
320
+ "",
321
+ `> ${report.simulatedSnippets.googleAio.snippet}`,
322
+ "",
323
+ `*${report.simulatedSnippets.googleAio.citation}*`,
324
+ "",
325
+ "### Gemini Style",
326
+ "",
327
+ `> ${report.simulatedSnippets.gemini.snippet}`,
328
+ "",
329
+ `*${report.simulatedSnippets.gemini.citation}*`,
330
+ "",
331
+ "## Top Quotable Sentences",
332
+ ""
333
+ ];
334
+
335
+ if (report.topQuotables.length === 0) {
336
+ lines.push("- No highly quotable sentences detected.");
337
+ } else {
338
+ for (const q of report.topQuotables) {
339
+ lines.push(`- (score ${q.score}, pos ${q.position}) ${q.sentence}`);
340
+ lines.push(` *Reason: ${q.reason}*`);
341
+ }
342
+ }
343
+
344
+ lines.push("", "## Recommendations", "");
345
+ if (report.recommendations.length === 0) {
346
+ lines.push("- Content is well-optimized for AI snippet extraction.");
347
+ } else {
348
+ for (const rec of report.recommendations) {
349
+ lines.push(`- ${rec}`);
350
+ }
351
+ }
352
+
353
+ lines.push("");
354
+ return lines.join("\n");
355
+ }
356
+
357
+ export async function writeAiSnippetOutput(outputPath, content) {
358
+ return writeScanOutput(outputPath, content);
359
+ }
@@ -0,0 +1,248 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { writeScanOutput } from "./scan.js";
4
+
5
+ const DEFAULT_RULES = [
6
+ { name: "Low Score", condition: "score-below", threshold: 40, action: "console" },
7
+ { name: "Score Drop", condition: "score-drop", threshold: 10, action: "console" },
8
+ { name: "Weak Citability", condition: "dimension-below", threshold: 30, dimension: "citability", action: "console" }
9
+ ];
10
+
11
+ /**
12
+ * Create an alert rule evaluator from a rules array.
13
+ */
14
+ export function createAlertRules(rules) {
15
+ const effectiveRules = rules && rules.length > 0 ? rules : DEFAULT_RULES;
16
+ return {
17
+ evaluate(auditResult, previousResult) {
18
+ return evaluateAlertRules(effectiveRules, auditResult, { previousResult });
19
+ }
20
+ };
21
+ }
22
+
23
+ /**
24
+ * Evaluate alert rules against an audit result.
25
+ */
26
+ export async function evaluateAlertRules(rules, auditResult, options = {}) {
27
+ const effectiveRules = rules && rules.length > 0 ? rules : DEFAULT_RULES;
28
+ const { previousResult, competitorScores } = options;
29
+ const score = auditResult.compositeScore ?? auditResult.score ?? 0;
30
+ const dimensions = auditResult.dimensions ?? {};
31
+
32
+ const triggered = [];
33
+ const passed = [];
34
+
35
+ for (const rule of effectiveRules) {
36
+ const result = evaluateSingleRule(rule, score, dimensions, previousResult, competitorScores);
37
+ if (result.triggered) {
38
+ triggered.push(result.detail);
39
+ } else {
40
+ passed.push({ rule: rule.name });
41
+ }
42
+ }
43
+
44
+ // Execute actions for triggered alerts
45
+ for (const alert of triggered) {
46
+ try {
47
+ await executeAction(alert, rules.find((r) => r.name === alert.rule));
48
+ alert.actionExecuted = true;
49
+ } catch {
50
+ alert.actionExecuted = false;
51
+ }
52
+ }
53
+
54
+ const summary = triggered.length === 0
55
+ ? `All ${effectiveRules.length} rules passed. Score: ${score}/100.`
56
+ : `${triggered.length}/${effectiveRules.length} rules triggered. Score: ${score}/100. Alerts: ${triggered.map((t) => t.rule).join(", ")}.`;
57
+
58
+ return {
59
+ kind: "geo-alert-rules",
60
+ rulesEvaluated: effectiveRules.length,
61
+ triggered,
62
+ passed,
63
+ score,
64
+ summary
65
+ };
66
+ }
67
+
68
+ function evaluateSingleRule(rule, score, dimensions, previousResult, competitorScores) {
69
+ switch (rule.condition) {
70
+ case "score-below": {
71
+ if (score < rule.threshold) {
72
+ return {
73
+ triggered: true,
74
+ detail: {
75
+ rule: rule.name,
76
+ condition: rule.condition,
77
+ message: `Composite score ${score} is below threshold ${rule.threshold}.`,
78
+ value: score,
79
+ threshold: rule.threshold,
80
+ actionExecuted: false
81
+ }
82
+ };
83
+ }
84
+ return { triggered: false };
85
+ }
86
+
87
+ case "score-drop": {
88
+ if (!previousResult) return { triggered: false };
89
+ const prevScore = previousResult.compositeScore ?? previousResult.score ?? 0;
90
+ const drop = prevScore - score;
91
+ if (drop > rule.threshold) {
92
+ return {
93
+ triggered: true,
94
+ detail: {
95
+ rule: rule.name,
96
+ condition: rule.condition,
97
+ message: `Score dropped by ${drop} points (${prevScore} → ${score}), exceeding threshold of ${rule.threshold}.`,
98
+ value: drop,
99
+ threshold: rule.threshold,
100
+ actionExecuted: false
101
+ }
102
+ };
103
+ }
104
+ return { triggered: false };
105
+ }
106
+
107
+ case "dimension-below": {
108
+ const dim = rule.dimension;
109
+ if (!dim || !dimensions[dim]) return { triggered: false };
110
+ const dimScore = typeof dimensions[dim] === "object" ? dimensions[dim].score : dimensions[dim];
111
+ if (dimScore < rule.threshold) {
112
+ return {
113
+ triggered: true,
114
+ detail: {
115
+ rule: rule.name,
116
+ condition: rule.condition,
117
+ message: `Dimension "${dim}" score ${dimScore} is below threshold ${rule.threshold}.`,
118
+ value: dimScore,
119
+ threshold: rule.threshold,
120
+ actionExecuted: false
121
+ }
122
+ };
123
+ }
124
+ return { triggered: false };
125
+ }
126
+
127
+ case "competitor-overtake": {
128
+ if (!competitorScores || competitorScores.length === 0) return { triggered: false };
129
+ const overtaking = competitorScores.filter((c) => c.score > score);
130
+ if (overtaking.length > 0) {
131
+ const top = overtaking.reduce((a, b) => (a.score > b.score ? a : b));
132
+ return {
133
+ triggered: true,
134
+ detail: {
135
+ rule: rule.name,
136
+ condition: rule.condition,
137
+ message: `Competitor "${top.url || top.name || "unknown"}" (${top.score}) overtook own score (${score}).`,
138
+ value: top.score,
139
+ threshold: score,
140
+ actionExecuted: false
141
+ }
142
+ };
143
+ }
144
+ return { triggered: false };
145
+ }
146
+
147
+ default:
148
+ return { triggered: false };
149
+ }
150
+ }
151
+
152
+ async function executeAction(alert, rule) {
153
+ if (!rule) return;
154
+
155
+ switch (rule.action) {
156
+ case "console": {
157
+ const line = `[ALERT] ${alert.rule}: ${alert.message}`;
158
+ process.stdout.write(line + "\n");
159
+ break;
160
+ }
161
+
162
+ case "webhook": {
163
+ const url = rule.actionConfig?.url;
164
+ if (!url) throw new Error("Webhook URL not configured");
165
+ const body = JSON.stringify({
166
+ alert: {
167
+ rule: alert.rule,
168
+ condition: alert.condition,
169
+ message: alert.message,
170
+ value: alert.value,
171
+ threshold: alert.threshold
172
+ },
173
+ timestamp: new Date().toISOString()
174
+ });
175
+ const resp = await fetch(url, {
176
+ method: "POST",
177
+ headers: { "Content-Type": "application/json" },
178
+ body
179
+ });
180
+ if (!resp.ok) throw new Error(`Webhook returned ${resp.status}`);
181
+ break;
182
+ }
183
+
184
+ case "file": {
185
+ const filePath = rule.actionConfig?.filePath;
186
+ if (!filePath) throw new Error("File path not configured");
187
+ const dir = path.dirname(filePath);
188
+ await fs.mkdir(dir, { recursive: true });
189
+ const line = `[${new Date().toISOString()}] ${alert.rule}: ${alert.message}\n`;
190
+ await fs.appendFile(filePath, line, "utf8");
191
+ break;
192
+ }
193
+ }
194
+ }
195
+
196
+ /**
197
+ * Load alert rules from a JSON file.
198
+ */
199
+ export async function loadAlertRulesFromFile(filePath) {
200
+ const content = await fs.readFile(filePath, "utf8");
201
+ const rules = JSON.parse(content);
202
+ if (!Array.isArray(rules)) {
203
+ throw new Error("Alert rules file must contain a JSON array");
204
+ }
205
+ return rules;
206
+ }
207
+
208
+ /**
209
+ * Render alert rules report as markdown.
210
+ */
211
+ export function renderAlertRulesMarkdown(report) {
212
+ const lines = [
213
+ "# GEO Alert Rules Report",
214
+ "",
215
+ `**Score:** ${report.score}/100`,
216
+ `**Rules evaluated:** ${report.rulesEvaluated}`,
217
+ `**Triggered:** ${report.triggered.length}`,
218
+ `**Passed:** ${report.passed.length}`,
219
+ ""
220
+ ];
221
+
222
+ if (report.triggered.length > 0) {
223
+ lines.push("## Triggered Alerts", "");
224
+ for (const alert of report.triggered) {
225
+ lines.push(`- **${alert.rule}** (${alert.condition}): ${alert.message} [action: ${alert.actionExecuted ? "executed" : "failed"}]`);
226
+ }
227
+ lines.push("");
228
+ }
229
+
230
+ if (report.passed.length > 0) {
231
+ lines.push("## Passed Rules", "");
232
+ for (const p of report.passed) {
233
+ lines.push(`- ${p.rule}`);
234
+ }
235
+ lines.push("");
236
+ }
237
+
238
+ lines.push("## Summary", "", report.summary, "");
239
+
240
+ return lines.join("\n");
241
+ }
242
+
243
+ /**
244
+ * Write alert rules output to file.
245
+ */
246
+ export async function writeAlertRulesOutput(outputPath, content) {
247
+ return writeScanOutput(outputPath, content);
248
+ }