From 9809aa8ac895afa13c34ef77b289f3e16135d472 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Mon, 28 Sep 2026 23:15:21 -0400 Subject: [PATCH 1/7] fix(collections): pf-4402 increase pf api crawler limits --- scripts/update.collection.patternFlyApi.ts | 151 ++++++++++++++++-- .../options.defaults.test.ts.snap | 2 +- .../collection.patternFlyApi.test.ts | 3 +- src/collection.patternFlyApi.ts | 5 +- src/options.defaults.ts | 2 +- 5 files changed, 145 insertions(+), 18 deletions(-) diff --git a/scripts/update.collection.patternFlyApi.ts b/scripts/update.collection.patternFlyApi.ts index 609e3866..e63ce63d 100644 --- a/scripts/update.collection.patternFlyApi.ts +++ b/scripts/update.collection.patternFlyApi.ts @@ -4,29 +4,132 @@ import { fileURLToPath } from 'node:url'; import { apiSpider, contentMetadata, + type ApiContent, type ApiCrawler, type ApiEmbedded, type ApiEmbeddedCollection } from '../src/collection.patternFlyApi'; -import { getOptions, runWithOptions } from '../src/options.context'; +import { getSessionOptions, getOptions, runWithOptions } from '../src/options.context'; +import { createLogger } from '../src/logger'; +import { type LoggingSession } from '../src/options.defaults'; /** - * Create a light diff report between old and new collections. + * Reason classification for omitted or removed API records. + */ +type RemovalReason = + 'lacks quality' | + 'empty response' | + 'loading error' | + 'deferred category' | + 'upstream removed'; + +/** + * Entry describing a removed record with its determined reason and details. + */ +interface RemovedRecordReport { + record: ApiEmbedded; + reason: RemovalReason; + details?: string; +} + +/** + * Entry describing a modified record and the changed fields. + */ +interface ModifiedRecordReport { + record: ApiEmbedded; + reasons: string[]; +} + +/** + * Create a diff report with annotated reasons between old and new collections. * * @param oldRecords - Previous collection * @param newRecords - Updated collection + * @param crawledMap - Map of all crawled entries and evaluated metadata */ -const diffCollections = (oldRecords: ApiEmbedded[], newRecords: ApiEmbedded[]) => { +const diffCollections = ( + oldRecords: ApiEmbedded[], + newRecords: ApiEmbedded[], + crawledMap: Map +) => { const oldMap = new Map(oldRecords.map(record => [record.p, record])); const newMap = new Map(newRecords.map(record => [record.p, record])); const added = newRecords.filter(record => !oldMap.has(record.p)); - const removed = oldRecords.filter(record => !newMap.has(record.p)); - const modified = newRecords.filter(record => { + + const removed: RemovedRecordReport[] = []; + + for (const oldRecord of oldRecords) { + if (newMap.has(oldRecord.p)) { + continue; + } + + const crawled = crawledMap.get(oldRecord.p); + + if (!crawled) { + removed.push({ + record: oldRecord, + reason: 'upstream removed', + details: 'Endpoint no longer referenced upstream' + }); + } else if (!crawled.entry.content || crawled.entry.content.trim() === '' || crawled.entry.content === '{}' || crawled.entry.content === '[]') { + removed.push({ + record: oldRecord, + reason: 'empty response', + details: 'Empty payload returned' + }); + } else if (crawled.metadata.isDeferred) { + removed.push({ + record: oldRecord, + reason: 'deferred category', + details: `Category '${crawled.metadata.category}' is deferred` + }); + } else if (crawled.metadata.isLowQuality || crawled.entry.qualityScore < 0.95) { + removed.push({ + record: oldRecord, + reason: 'lacks quality', + details: `Evaluated Q: ${crawled.entry.qualityScore} < 0.95 threshold` + }); + } else { + removed.push({ + record: oldRecord, + reason: 'lacks quality', + details: `Evaluated Q: ${crawled.entry.qualityScore}` + }); + } + } + + const modified: ModifiedRecordReport[] = []; + + for (const record of newRecords) { const prev = oldMap.get(record.p); - return prev && (prev.q !== record.q || prev.n !== record.n || prev.d !== record.d || prev.c !== record.c); - }); + if (!prev) { + continue; + } + + const reasons: string[] = []; + + if (prev.q !== record.q) { + reasons.push(`quality score (${prev.q} -> ${record.q})`); + } + + if (prev.n !== record.n) { + reasons.push(`name ("${prev.n}" -> "${record.n}")`); + } + + if (prev.d !== record.d) { + reasons.push('description updated'); + } + + if (prev.c !== record.c) { + reasons.push(`content-type (${prev.c} -> ${record.c})`); + } + + if (reasons.length > 0) { + modified.push({ record, reasons }); + } + } return { added, removed, modified }; }; @@ -59,16 +162,20 @@ const diffReport = (diff: ReturnType) => { if (removed.length > 0) { console.log(` ➖ Removed (${removed.length}):`); - removed.slice(0, 10).forEach(record => console.log(` - ${record.p}`)); + removed.slice(0, 15).forEach(({ record, reason, details }) => { + console.log(` - ${record.p} (Previous Q: ${record.q}) [Reason: ${reason}${details ? ` — ${details}` : ''}]`); + }); - if (removed.length > 10) { - console.log(` ... and ${removed.length - 10} more`); + if (removed.length > 15) { + console.log(` ... and ${removed.length - 15} more`); } } if (modified.length > 0) { console.log(` 🔄 Modified (${modified.length}):`); - modified.slice(0, 10).forEach(record => console.log(` ~ ${record.p} (Q: ${record.q})`)); + modified.slice(0, 10).forEach(({ record, reasons }) => { + console.log(` ~ ${record.p} [${reasons.join(', ')}]`); + }); if (modified.length > 10) { console.log(` ... and ${modified.length - 10} more`); @@ -89,6 +196,13 @@ const run = async ( filterLowQualityRecords = false }: { isPrettyPrint?: boolean; filterLowQualityRecords?: boolean; } = {} ) => { + // 1. Enable stderr logging so all diagnostics_channel logs (debug, info, warn, error) are printed + const unsubscribeLogger = createLogger({ + channelName: getSessionOptions().channelName, + stderr: true, + level: 'debug' + } as LoggingSession); + console.log('🚀 Generating PatternFly API embedded collection...'); const keepAlive = setTimeout(() => {}, 86_400_000); @@ -105,17 +219,19 @@ const run = async ( } const recordsMap = new Map(); + const crawledMap = new Map(); for (const entry of entries) { // Generate full metadata using the shared contentMetadata function const metadata = contentMetadata(entry, options); + const relativePath = metadata.path.replace(base, '').replace(/^\//, ''); + + crawledMap.set(relativePath, { entry, metadata }); if (filterLowQualityRecords && (metadata.isDeferred || metadata.isLowQuality)) { continue; } - const relativePath = metadata.path.replace(base, '').replace(/^\//, ''); - if (recordsMap.has(relativePath)) { continue; } @@ -162,9 +278,10 @@ const run = async ( console.log(` - File Size: ${sizeKb} KB`); console.log(` - Time Elapsed: ${durationSec}s`); - diffReport(diffCollections(oldRecords, records)); + diffReport(diffCollections(oldRecords, records, crawledMap)); } finally { clearTimeout(keepAlive); + unsubscribeLogger(); } }; @@ -175,3 +292,9 @@ run({ isPrettyPrint: true, filterLowQualityRecords: true }).catch(error => { console.error('❌ Failed to update API collection:', error); process.exit(1); }); + +export { + type ModifiedRecordReport, + type RemovalReason, + type RemovedRecordReport +}; diff --git a/src/__tests__/__snapshots__/options.defaults.test.ts.snap b/src/__tests__/__snapshots__/options.defaults.test.ts.snap index fea1f917..558fb52f 100644 --- a/src/__tests__/__snapshots__/options.defaults.test.ts.snap +++ b/src/__tests__/__snapshots__/options.defaults.test.ts.snap @@ -63,7 +63,7 @@ exports[`options defaults should return specific properties: defaults 1`] = ` "intervalMs": 604800000, "repeat": Infinity, }, - "timeoutMs": 300000, + "timeoutMs": 1200000, "traversalPaths": [ "examples", ], diff --git a/src/__tests__/collection.patternFlyApi.test.ts b/src/__tests__/collection.patternFlyApi.test.ts index 0f3a70a7..17dd74a9 100644 --- a/src/__tests__/collection.patternFlyApi.test.ts +++ b/src/__tests__/collection.patternFlyApi.test.ts @@ -445,7 +445,8 @@ describe('crawler', () => { // It should have called for sub-item AND default componentPaths (props, css) // but my mock returns 'leaf' for everything else expect(res.length).toBeGreaterThanOrEqual(1); - expect(mockedProcessDocsFunction).toHaveBeenCalledWith(['https://api.com/v1']); + expect(mockedProcessDocsFunction) + .toHaveBeenCalledWith(expect.arrayContaining(['https://api.com/v1']), expect.objectContaining({})); }); it('aborts crawling early when signal is aborted', async () => { diff --git a/src/collection.patternFlyApi.ts b/src/collection.patternFlyApi.ts index a0b78ae0..f650f967 100644 --- a/src/collection.patternFlyApi.ts +++ b/src/collection.patternFlyApi.ts @@ -365,7 +365,10 @@ const crawler = async ( return []; } - const settled = await processDocsFunction(uniqueUrls) || []; + const settled = await processDocsFunction( + uniqueUrls, { loadLimit: 200, parallelLoadLimit: 15, parallelLoadThrottleMs: 75 } + ) || []; + const content: ApiCrawler[] = []; for (const res of settled) { diff --git a/src/options.defaults.ts b/src/options.defaults.ts index 5aba2798..bc5d43da 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -541,7 +541,7 @@ const PATTERNFLY_OPTIONS: PatternFlyOptions = { traversalPaths: [ 'examples' ], - timeoutMs: 300_000, // 5 minutes + timeoutMs: 1_200_000, // 20 minutes schedule: { continueOnError: true, intervalMs: 24 * 60 * 60 * 1000 * 7, // 7 days From 29d401f3e98218b69326219ec07855a7ab1be0ed Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 30 Sep 2026 15:20:37 -0400 Subject: [PATCH 2/7] fix: review update --- scripts/update.collection.patternFlyApi.ts | 15 ++++++++------- src/collection.patternFlyApi.ts | 1 + 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/scripts/update.collection.patternFlyApi.ts b/scripts/update.collection.patternFlyApi.ts index e63ce63d..2e3c38c1 100644 --- a/scripts/update.collection.patternFlyApi.ts +++ b/scripts/update.collection.patternFlyApi.ts @@ -7,7 +7,8 @@ import { type ApiContent, type ApiCrawler, type ApiEmbedded, - type ApiEmbeddedCollection + type ApiEmbeddedCollection, + MIN_API_QUALITY_THRESHOLD } from '../src/collection.patternFlyApi'; import { getSessionOptions, getOptions, runWithOptions } from '../src/options.context'; import { createLogger } from '../src/logger'; @@ -19,9 +20,9 @@ import { type LoggingSession } from '../src/options.defaults'; type RemovalReason = 'lacks quality' | 'empty response' | - 'loading error' | 'deferred category' | - 'upstream removed'; + 'upstream removed' | + 'other'; /** * Entry describing a removed record with its determined reason and details. @@ -84,17 +85,17 @@ const diffCollections = ( reason: 'deferred category', details: `Category '${crawled.metadata.category}' is deferred` }); - } else if (crawled.metadata.isLowQuality || crawled.entry.qualityScore < 0.95) { + } else if (crawled.metadata.isLowQuality || crawled.entry.qualityScore < MIN_API_QUALITY_THRESHOLD) { removed.push({ record: oldRecord, reason: 'lacks quality', - details: `Evaluated Q: ${crawled.entry.qualityScore} < 0.95 threshold` + details: `Evaluated Q: ${crawled.entry.qualityScore} < ${MIN_API_QUALITY_THRESHOLD} threshold` }); } else { removed.push({ record: oldRecord, - reason: 'lacks quality', - details: `Evaluated Q: ${crawled.entry.qualityScore}` + reason: 'other', + details: `Excluded during crawl processing (Q: ${crawled.entry.qualityScore})` }); } } diff --git a/src/collection.patternFlyApi.ts b/src/collection.patternFlyApi.ts index f650f967..03f2d685 100644 --- a/src/collection.patternFlyApi.ts +++ b/src/collection.patternFlyApi.ts @@ -698,6 +698,7 @@ const patternFlyApiCollection = (options = getOptions(), session = getSessionOpt }; export { + MIN_API_QUALITY_THRESHOLD, patternFlyApiCollection, collectionCallback, collectionInitialCallback, From 15aa3c40b67f8f3ff996dea71cea38a57f8ea4e7 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 30 Sep 2026 15:47:02 -0400 Subject: [PATCH 3/7] fix: review update, csv reports --- package.json | 2 +- scripts/update.collection.patternFlyApi.ts | 131 ++++++++++++++++-- .../update.collection.patternFlyApi.test.ts | 108 ++++++++++++++- 3 files changed, 228 insertions(+), 13 deletions(-) diff --git a/package.json b/package.json index 89c94775..27a4a958 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "build": "npm run build:clean; npm run test:types; pkgroll", "build:clean": "rm -rf dist", "build:watch": "npm run build -- --watch", - "build:collections": "tsx ./scripts/update.collection.patternFlyApi.ts && NODE_OPTIONS='--experimental-vm-modules' jest --selectProjects collections", + "build:collections": "UPDATE_COLLECTIONS=true tsx ./scripts/update.collection.patternFlyApi.ts && NODE_OPTIONS='--experimental-vm-modules' jest --selectProjects collections", "container:build": "bash ./scripts/container.build.sh", "container:start": "bash ./scripts/container.run.sh", "release": "changelog --non-cc --link-url https://github.com/patternfly/patternfly-mcp.git", diff --git a/scripts/update.collection.patternFlyApi.ts b/scripts/update.collection.patternFlyApi.ts index 2e3c38c1..471b9d62 100644 --- a/scripts/update.collection.patternFlyApi.ts +++ b/scripts/update.collection.patternFlyApi.ts @@ -1,5 +1,5 @@ -import { readFile, writeFile } from 'node:fs/promises'; -import { resolve } from 'node:path'; +import { readFile, writeFile, mkdir } from 'node:fs/promises'; +import { dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { apiSpider, @@ -41,6 +41,93 @@ interface ModifiedRecordReport { reasons: string[]; } +/** + * Options for generating CSV report output. + */ +interface GenerateCsvReportOptions { + diff: ReturnType; + oldRecords: ApiEmbedded[]; + newRecords: ApiEmbedded[]; + crawledMap: Map; +} + +/** + * Safely escape and format a field for standard RFC 4180 CSV output. + * + * @param field - Value to format for CSV + */ +const escapeCsvField = (field: unknown): string => { + if (field === null || field === undefined) { + return ''; + } + + const str = String(field); + + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"`; + } + + return str; +}; + +/** + * Format rows and headers into standard CSV string. + * + * @param headers - Column headers + * @param rows - Table rows + */ +const formatCsv = (headers: string[], rows: (string | number | undefined | null)[][]): string => { + const headerLine = headers.map(escapeCsvField).join(','); + const rowLines = rows.map(row => row.map(escapeCsvField).join(',')); + + return [headerLine, ...rowLines].join('\n') + '\n'; +}; + +/** + * Generate a complete, non-truncated CSV report for additions, removals, modifications, and unchanged records. + * + * @param options - Generation options + * @param options.diff - Diff calculation between old and new records + * @param options.oldRecords - Previous collection records + * @param options.newRecords - Current collection records + * @param options.crawledMap - Map of crawled entries and metadata + */ +const generateReportCsv = ({ + diff, + oldRecords: _oldRecords, + newRecords, + crawledMap: _crawledMap +}: GenerateCsvReportOptions): string => { + const headers = ['status', 'path', 'name', 'qualityScore', 'contentType', 'reason', 'details']; + const rows: (string | number | undefined | null)[][] = []; + + for (const record of diff.added) { + rows.push(['ADDED', record.p, record.n, record.q, record.c, '', '']); + } + + for (const { record, reason, details } of diff.removed) { + rows.push(['REMOVED', record.p, record.n, record.q, record.c, reason, details || '']); + } + + for (const { record, reasons } of diff.modified) { + rows.push(['MODIFIED', record.p, record.n, record.q, record.c, 'property changes', reasons.join('; ')]); + } + + const changedPaths = new Set([ + ...diff.added.map(record => record.p), + ...diff.removed.map(removedItem => removedItem.record.p), + ...diff.modified.map(modifiedItem => modifiedItem.record.p) + ]); + + for (const record of newRecords) { + if (!changedPaths.has(record.p)) { + rows.push(['UNCHANGED', record.p, record.n, record.q, record.c, '', '']); + } + } + + return formatCsv(headers, rows); +}; + /** * Create a diff report with annotated reasons between old and new collections. * @@ -190,12 +277,16 @@ const diffReport = (diff: ReturnType) => { * @param [options] - Optional configuration options. * @param [options.isPrettyPrint=true] - Whether to pretty-print the JSON output. * @param [options.filterLowQualityRecords=false] - Whether to filter low-quality records based on the collection's criteria. + * @param [options.outputCsv=true] - Whether to generate and save a full CSV diff report. + * @param [options.csvOutputPath] - Custom path to write CSV report. */ const run = async ( { isPrettyPrint = true, - filterLowQualityRecords = false - }: { isPrettyPrint?: boolean; filterLowQualityRecords?: boolean; } = {} + filterLowQualityRecords = false, + outputCsv = true, + csvOutputPath + }: { isPrettyPrint?: boolean; filterLowQualityRecords?: boolean; outputCsv?: boolean; csvOutputPath?: string; } = {} ) => { // 1. Enable stderr logging so all diagnostics_channel logs (debug, info, warn, error) are printed const unsubscribeLogger = createLogger({ @@ -279,7 +370,21 @@ const run = async ( console.log(` - File Size: ${sizeKb} KB`); console.log(` - Time Elapsed: ${durationSec}s`); - diffReport(diffCollections(oldRecords, records, crawledMap)); + const diff = diffCollections(oldRecords, records, crawledMap); + + diffReport(diff); + + if (outputCsv) { + const targetCsvPath = csvOutputPath || + process.env.CSV_REPORT_PATH || + resolve(fileURLToPath(new URL('../reports/collection.patternFlyApi.report.csv', import.meta.url))); + + await mkdir(dirname(targetCsvPath), { recursive: true }); + const csvContent = generateReportCsv({ diff, oldRecords, newRecords: records, crawledMap }); + + await writeFile(targetCsvPath, csvContent, 'utf-8'); + console.log(`📄 Exported full CSV report: ${targetCsvPath}`); + } } finally { clearTimeout(keepAlive); unsubscribeLogger(); @@ -288,13 +393,21 @@ const run = async ( /** * Configurable options for maintainers. + * Only execute when explicitly requested via UPDATE_COLLECTIONS=true */ -run({ isPrettyPrint: true, filterLowQualityRecords: true }).catch(error => { - console.error('❌ Failed to update API collection:', error); - process.exit(1); -}); +if (process.env.UPDATE_COLLECTIONS === 'true') { + run({ isPrettyPrint: true, filterLowQualityRecords: true }).catch(error => { + console.error('❌ Failed to update API collection:', error); + process.exit(1); + }); +} export { + diffCollections, + escapeCsvField, + formatCsv, + generateReportCsv, + run, type ModifiedRecordReport, type RemovalReason, type RemovedRecordReport diff --git a/tests/scripts/update.collection.patternFlyApi.test.ts b/tests/scripts/update.collection.patternFlyApi.test.ts index 1e2b0cb8..57ea0d09 100644 --- a/tests/scripts/update.collection.patternFlyApi.test.ts +++ b/tests/scripts/update.collection.patternFlyApi.test.ts @@ -1,10 +1,15 @@ import { readFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { expandApiEmbeddedCollection, type ApiEmbeddedCollection } from '../../src/collection.patternFlyApi'; +import { escapeCsvField, formatCsv, generateReportCsv, diffCollections, run } from '../../scripts/update.collection.patternFlyApi'; describe('collection.patternFlyApi', () => { const catalogPath = resolve(process.cwd(), 'src/collection.patternFlyApi.json'); + it('should export the run function for programmatic invocation', () => { + expect(typeof run).toBe('function'); + }); + it('should have a generated collection catalog file', () => { expect(existsSync(catalogPath)).toBe(true); }); @@ -25,8 +30,10 @@ describe('collection.patternFlyApi', () => { it('should have records that are compressed and have key properties', () => { const raw = readFileSync(catalogPath, 'utf-8'); const parsed: ApiEmbeddedCollection = JSON.parse(raw); + const sample = parsed.records.slice(0, 50); - for (const record of parsed.records) { + expect(parsed.records.length).toBeGreaterThan(0); + for (const record of sample) { expect(typeof record.p).toBe('string'); // path expect(typeof record.n).toBe('string'); // display name expect(typeof record.d).toBe('string'); // description @@ -42,9 +49,104 @@ describe('collection.patternFlyApi', () => { it('should be able to expanded and hydrate properties without errors', () => { const raw = readFileSync(catalogPath, 'utf-8'); const parsed: ApiEmbeddedCollection = JSON.parse(raw); - const expanded = expandApiEmbeddedCollection(parsed); + const sampleRecords = parsed.records.slice(0, 50); + const expanded = expandApiEmbeddedCollection({ ...parsed, records: sampleRecords }); - expect(expanded.length).toBe(parsed.records.length); + expect(expanded.length).toBe(sampleRecords.length); expect(expanded[0]?.path?.startsWith(parsed.base)).toBe(true); }); + + describe('CSV Report Generator', () => { + it('should correctly escape fields with commas, quotes, and newlines', () => { + expect(escapeCsvField('normal')).toBe('normal'); + expect(escapeCsvField('with,comma')).toBe('"with,comma"'); + expect(escapeCsvField('with "quotes"')).toBe('"with ""quotes"""'); + expect(escapeCsvField('with\nnewline')).toBe('"with\nnewline"'); + expect(escapeCsvField(null)).toBe(''); + expect(escapeCsvField(undefined)).toBe(''); + expect(escapeCsvField(123)).toBe('123'); + }); + + it('should format header and row lines into standard CSV', () => { + const headers = ['col1', 'col2']; + const rows = [ + ['val1', 'val2'], + ['val3,with,comma', 'val4 "quoted"'] + ]; + + const result = formatCsv(headers, rows); + + expect(result).toBe('col1,col2\nval1,val2\n"val3,with,comma","val4 ""quoted"""\n'); + }); + + it('should produce a full structured CSV report for added, removed, modified, and unchanged records', () => { + const oldRecords = [ + { p: 'endpoint/removed', n: 'Old Doc', d: 'Desc', c: 'text/html', q: 0.96 }, + { p: 'endpoint/modified', n: 'Mod Doc', d: 'Old Desc', c: 'text/html', q: 0.95 }, + { p: 'endpoint/unchanged', n: 'Unchanged Doc', d: 'Desc', c: 'text/html', q: 0.98 } + ]; + + const newRecords = [ + { p: 'endpoint/added', n: 'New Doc', d: 'Desc', c: 'text/html', q: 0.97 }, + { p: 'endpoint/modified', n: 'Mod Doc', d: 'New Desc', c: 'text/html', q: 0.99 }, + { p: 'endpoint/unchanged', n: 'Unchanged Doc', d: 'Desc', c: 'text/html', q: 0.98 } + ]; + + const crawledMap = new Map(); + + crawledMap.set('endpoint/removed', { + entry: { qualityScore: 0.8, content: 'some content' }, + metadata: { isDeferred: false, isLowQuality: true, category: 'components' } + }); + crawledMap.set('endpoint/modified', { + entry: { qualityScore: 0.99, content: 'some content' }, + metadata: { isDeferred: false, isLowQuality: false, category: 'components' } + }); + crawledMap.set('endpoint/unchanged', { + entry: { qualityScore: 0.98, content: 'some content' }, + metadata: { isDeferred: false, isLowQuality: false, category: 'components' } + }); + crawledMap.set('endpoint/added', { + entry: { qualityScore: 0.97, content: 'some content' }, + metadata: { isDeferred: false, isLowQuality: false, category: 'components' } + }); + + const diff = diffCollections(oldRecords, newRecords, crawledMap); + const csv = generateReportCsv({ diff, oldRecords, newRecords, crawledMap }); + const lines = csv.trim().split('\n'); + + expect(lines[0]).toBe('status,path,name,qualityScore,contentType,reason,details'); + expect(lines.some(line => line.startsWith('ADDED,endpoint/added'))).toBe(true); + expect(lines.some(line => line.startsWith('REMOVED,endpoint/removed') && line.includes('lacks quality'))).toBe(true); + expect(lines.some(line => line.startsWith('MODIFIED,endpoint/modified') && line.includes('quality score'))).toBe(true); + expect(lines.some(line => line.startsWith('UNCHANGED,endpoint/unchanged'))).toBe(true); + }); + + it('should generate a CSV report from a sample of catalog records', () => { + const raw = readFileSync(catalogPath, 'utf-8'); + const parsed: ApiEmbeddedCollection = JSON.parse(raw); + const sampleRecords = parsed.records.slice(0, 5); + const crawledMap = new Map(); + + for (const rec of sampleRecords) { + crawledMap.set(rec.p, { + entry: { qualityScore: rec.q, content: 'sample content' }, + metadata: { isDeferred: false, isLowQuality: false, category: 'components' } + }); + } + + const diff = diffCollections(sampleRecords, sampleRecords, crawledMap); + const csv = generateReportCsv({ + diff, + oldRecords: sampleRecords, + newRecords: sampleRecords, + crawledMap + }); + const lines = csv.trim().split('\n'); + + expect(lines[0]).toBe('status,path,name,qualityScore,contentType,reason,details'); + expect(lines.length).toBe(sampleRecords.length + 1); + expect(lines.slice(1).every(line => line.startsWith('UNCHANGED,'))).toBe(true); + }); + }); }); From 2bc63c4c6d0e8907f18684bed0ecaa7497625053 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 30 Sep 2026 16:32:27 -0400 Subject: [PATCH 4/7] fix: review update --- package.json | 1 + scripts/update.collection.patternFlyApi.ts | 2 +- .../update.collection.patternFlyApi.test.ts | 172 +++++++++--------- 3 files changed, 88 insertions(+), 87 deletions(-) diff --git a/package.json b/package.json index 27a4a958..5d8abf65 100644 --- a/package.json +++ b/package.json @@ -45,6 +45,7 @@ "test:audit": "NODE_OPTIONS='--experimental-vm-modules' jest --selectProjects audit", "test:audit-container": "npm run container:build && jest --selectProjects audit:container", "test:ci": "npm test -- --coverage", + "test:collections": "NODE_OPTIONS='--experimental-vm-modules' jest --selectProjects collections", "test:dev": "npm test -- --watchAll", "test:integration": "npm run build && jest --selectProjects package && NODE_OPTIONS='--experimental-vm-modules' jest --selectProjects e2e", "test:integration-dev": "npm run test:integration -- --watchAll", diff --git a/scripts/update.collection.patternFlyApi.ts b/scripts/update.collection.patternFlyApi.ts index 471b9d62..7ad55b30 100644 --- a/scripts/update.collection.patternFlyApi.ts +++ b/scripts/update.collection.patternFlyApi.ts @@ -98,7 +98,7 @@ const generateReportCsv = ({ newRecords, crawledMap: _crawledMap }: GenerateCsvReportOptions): string => { - const headers = ['status', 'path', 'name', 'qualityScore', 'contentType', 'reason', 'details']; + const headers = ['status', 'path', 'name', 'previousQualityScore', 'contentType', 'reason', 'details']; const rows: (string | number | undefined | null)[][] = []; for (const record of diff.added) { diff --git a/tests/scripts/update.collection.patternFlyApi.test.ts b/tests/scripts/update.collection.patternFlyApi.test.ts index 57ea0d09..19ef2f35 100644 --- a/tests/scripts/update.collection.patternFlyApi.test.ts +++ b/tests/scripts/update.collection.patternFlyApi.test.ts @@ -3,19 +3,19 @@ import { resolve } from 'node:path'; import { expandApiEmbeddedCollection, type ApiEmbeddedCollection } from '../../src/collection.patternFlyApi'; import { escapeCsvField, formatCsv, generateReportCsv, diffCollections, run } from '../../scripts/update.collection.patternFlyApi'; -describe('collection.patternFlyApi', () => { - const catalogPath = resolve(process.cwd(), 'src/collection.patternFlyApi.json'); +const COLLECTION_PATH = resolve(process.cwd(), 'src/collection.patternFlyApi.json'); +describe('collection.patternFlyApi', () => { it('should export the run function for programmatic invocation', () => { expect(typeof run).toBe('function'); }); - it('should have a generated collection catalog file', () => { - expect(existsSync(catalogPath)).toBe(true); + it('should have a generated collection file', () => { + expect(existsSync(COLLECTION_PATH)).toBe(true); }); it('should have a consistent JSON schema', () => { - const raw = readFileSync(catalogPath, 'utf-8'); + const raw = readFileSync(COLLECTION_PATH, 'utf-8'); const parsed: ApiEmbeddedCollection = JSON.parse(raw); expect(parsed).toMatchObject({ @@ -28,7 +28,7 @@ describe('collection.patternFlyApi', () => { }); it('should have records that are compressed and have key properties', () => { - const raw = readFileSync(catalogPath, 'utf-8'); + const raw = readFileSync(COLLECTION_PATH, 'utf-8'); const parsed: ApiEmbeddedCollection = JSON.parse(raw); const sample = parsed.records.slice(0, 50); @@ -47,7 +47,7 @@ describe('collection.patternFlyApi', () => { }); it('should be able to expanded and hydrate properties without errors', () => { - const raw = readFileSync(catalogPath, 'utf-8'); + const raw = readFileSync(COLLECTION_PATH, 'utf-8'); const parsed: ApiEmbeddedCollection = JSON.parse(raw); const sampleRecords = parsed.records.slice(0, 50); const expanded = expandApiEmbeddedCollection({ ...parsed, records: sampleRecords }); @@ -55,98 +55,98 @@ describe('collection.patternFlyApi', () => { expect(expanded.length).toBe(sampleRecords.length); expect(expanded[0]?.path?.startsWith(parsed.base)).toBe(true); }); +}); - describe('CSV Report Generator', () => { - it('should correctly escape fields with commas, quotes, and newlines', () => { - expect(escapeCsvField('normal')).toBe('normal'); - expect(escapeCsvField('with,comma')).toBe('"with,comma"'); - expect(escapeCsvField('with "quotes"')).toBe('"with ""quotes"""'); - expect(escapeCsvField('with\nnewline')).toBe('"with\nnewline"'); - expect(escapeCsvField(null)).toBe(''); - expect(escapeCsvField(undefined)).toBe(''); - expect(escapeCsvField(123)).toBe('123'); - }); +describe('collection.patternFlyApi CSV Report Generator', () => { + it('should correctly escape fields with commas, quotes, and newlines', () => { + expect(escapeCsvField('normal')).toBe('normal'); + expect(escapeCsvField('with,comma')).toBe('"with,comma"'); + expect(escapeCsvField('with "quotes"')).toBe('"with ""quotes"""'); + expect(escapeCsvField('with\nnewline')).toBe('"with\nnewline"'); + expect(escapeCsvField(null)).toBe(''); + expect(escapeCsvField(undefined)).toBe(''); + expect(escapeCsvField(123)).toBe('123'); + }); - it('should format header and row lines into standard CSV', () => { - const headers = ['col1', 'col2']; - const rows = [ - ['val1', 'val2'], - ['val3,with,comma', 'val4 "quoted"'] - ]; + it('should format header and row lines into standard CSV', () => { + const headers = ['col1', 'col2']; + const rows = [ + ['val1', 'val2'], + ['val3,with,comma', 'val4 "quoted"'] + ]; - const result = formatCsv(headers, rows); + const result = formatCsv(headers, rows); - expect(result).toBe('col1,col2\nval1,val2\n"val3,with,comma","val4 ""quoted"""\n'); - }); + expect(result).toBe('col1,col2\nval1,val2\n"val3,with,comma","val4 ""quoted"""\n'); + }); - it('should produce a full structured CSV report for added, removed, modified, and unchanged records', () => { - const oldRecords = [ - { p: 'endpoint/removed', n: 'Old Doc', d: 'Desc', c: 'text/html', q: 0.96 }, - { p: 'endpoint/modified', n: 'Mod Doc', d: 'Old Desc', c: 'text/html', q: 0.95 }, - { p: 'endpoint/unchanged', n: 'Unchanged Doc', d: 'Desc', c: 'text/html', q: 0.98 } - ]; + it('should produce a full structured CSV report for added, removed, modified, and unchanged records', () => { + const oldRecords = [ + { p: 'endpoint/removed', n: 'Old Doc', d: 'Desc', c: 'text/html', q: 0.96 }, + { p: 'endpoint/modified', n: 'Mod Doc', d: 'Old Desc', c: 'text/html', q: 0.95 }, + { p: 'endpoint/unchanged', n: 'Unchanged Doc', d: 'Desc', c: 'text/html', q: 0.98 } + ]; - const newRecords = [ - { p: 'endpoint/added', n: 'New Doc', d: 'Desc', c: 'text/html', q: 0.97 }, - { p: 'endpoint/modified', n: 'Mod Doc', d: 'New Desc', c: 'text/html', q: 0.99 }, - { p: 'endpoint/unchanged', n: 'Unchanged Doc', d: 'Desc', c: 'text/html', q: 0.98 } - ]; + const newRecords = [ + { p: 'endpoint/added', n: 'New Doc', d: 'Desc', c: 'text/html', q: 0.97 }, + { p: 'endpoint/modified', n: 'Mod Doc', d: 'New Desc', c: 'text/html', q: 0.99 }, + { p: 'endpoint/unchanged', n: 'Unchanged Doc', d: 'Desc', c: 'text/html', q: 0.98 } + ]; - const crawledMap = new Map(); + const crawledMap = new Map(); - crawledMap.set('endpoint/removed', { - entry: { qualityScore: 0.8, content: 'some content' }, - metadata: { isDeferred: false, isLowQuality: true, category: 'components' } - }); - crawledMap.set('endpoint/modified', { - entry: { qualityScore: 0.99, content: 'some content' }, - metadata: { isDeferred: false, isLowQuality: false, category: 'components' } - }); - crawledMap.set('endpoint/unchanged', { - entry: { qualityScore: 0.98, content: 'some content' }, - metadata: { isDeferred: false, isLowQuality: false, category: 'components' } - }); - crawledMap.set('endpoint/added', { - entry: { qualityScore: 0.97, content: 'some content' }, - metadata: { isDeferred: false, isLowQuality: false, category: 'components' } - }); + crawledMap.set('endpoint/removed', { + entry: { qualityScore: 0.8, content: 'some content' }, + metadata: { isDeferred: false, isLowQuality: true, category: 'components' } + }); + crawledMap.set('endpoint/modified', { + entry: { qualityScore: 0.99, content: 'some content' }, + metadata: { isDeferred: false, isLowQuality: false, category: 'components' } + }); + crawledMap.set('endpoint/unchanged', { + entry: { qualityScore: 0.98, content: 'some content' }, + metadata: { isDeferred: false, isLowQuality: false, category: 'components' } + }); + crawledMap.set('endpoint/added', { + entry: { qualityScore: 0.97, content: 'some content' }, + metadata: { isDeferred: false, isLowQuality: false, category: 'components' } + }); - const diff = diffCollections(oldRecords, newRecords, crawledMap); - const csv = generateReportCsv({ diff, oldRecords, newRecords, crawledMap }); - const lines = csv.trim().split('\n'); + const diff = diffCollections(oldRecords, newRecords, crawledMap); + const csv = generateReportCsv({ diff, oldRecords, newRecords, crawledMap }); + const lines = csv.trim().split('\n'); - expect(lines[0]).toBe('status,path,name,qualityScore,contentType,reason,details'); - expect(lines.some(line => line.startsWith('ADDED,endpoint/added'))).toBe(true); - expect(lines.some(line => line.startsWith('REMOVED,endpoint/removed') && line.includes('lacks quality'))).toBe(true); - expect(lines.some(line => line.startsWith('MODIFIED,endpoint/modified') && line.includes('quality score'))).toBe(true); - expect(lines.some(line => line.startsWith('UNCHANGED,endpoint/unchanged'))).toBe(true); - }); + expect(lines[0]).toBe('status,path,name,previousQualityScore,contentType,reason,details'); + expect(lines.some(line => line.startsWith('ADDED,endpoint/added'))).toBe(true); + expect(lines.some(line => line.startsWith('REMOVED,endpoint/removed') && line.includes('lacks quality'))).toBe(true); + expect(lines.some(line => line.startsWith('MODIFIED,endpoint/modified') && line.includes('quality score'))).toBe(true); + expect(lines.some(line => line.startsWith('UNCHANGED,endpoint/unchanged'))).toBe(true); + }); + + it('should generate a CSV report from a sample of collection records', () => { + const raw = readFileSync(COLLECTION_PATH, 'utf-8'); + const parsed: ApiEmbeddedCollection = JSON.parse(raw); + const sampleRecords = parsed.records.slice(0, 5); + const crawledMap = new Map(); - it('should generate a CSV report from a sample of catalog records', () => { - const raw = readFileSync(catalogPath, 'utf-8'); - const parsed: ApiEmbeddedCollection = JSON.parse(raw); - const sampleRecords = parsed.records.slice(0, 5); - const crawledMap = new Map(); - - for (const rec of sampleRecords) { - crawledMap.set(rec.p, { - entry: { qualityScore: rec.q, content: 'sample content' }, - metadata: { isDeferred: false, isLowQuality: false, category: 'components' } - }); - } - - const diff = diffCollections(sampleRecords, sampleRecords, crawledMap); - const csv = generateReportCsv({ - diff, - oldRecords: sampleRecords, - newRecords: sampleRecords, - crawledMap + for (const rec of sampleRecords) { + crawledMap.set(rec.p, { + entry: { qualityScore: rec.q, content: 'sample content' }, + metadata: { isDeferred: false, isLowQuality: false, category: 'components' } }); - const lines = csv.trim().split('\n'); + } - expect(lines[0]).toBe('status,path,name,qualityScore,contentType,reason,details'); - expect(lines.length).toBe(sampleRecords.length + 1); - expect(lines.slice(1).every(line => line.startsWith('UNCHANGED,'))).toBe(true); + const diff = diffCollections(sampleRecords, sampleRecords, crawledMap); + const csv = generateReportCsv({ + diff, + oldRecords: sampleRecords, + newRecords: sampleRecords, + crawledMap }); + const lines = csv.trim().split('\n'); + + expect(lines[0]).toBe('status,path,name,previousQualityScore,contentType,reason,details'); + expect(lines.length).toBe(sampleRecords.length + 1); + expect(lines.slice(1).every(line => line.startsWith('UNCHANGED,'))).toBe(true); }); }); From 08568f0eb98bd3a80adb18c0096baea134752e2b Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 30 Sep 2026 16:57:36 -0400 Subject: [PATCH 5/7] fix: review update --- scripts/update.collection.patternFlyApi.ts | 19 ++++++++++++------- .../update.collection.patternFlyApi.test.ts | 12 ++++++------ 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/scripts/update.collection.patternFlyApi.ts b/scripts/update.collection.patternFlyApi.ts index 7ad55b30..f450c9b7 100644 --- a/scripts/update.collection.patternFlyApi.ts +++ b/scripts/update.collection.patternFlyApi.ts @@ -94,23 +94,28 @@ const formatCsv = (headers: string[], rows: (string | number | undefined | null) */ const generateReportCsv = ({ diff, - oldRecords: _oldRecords, + oldRecords, newRecords, - crawledMap: _crawledMap + crawledMap }: GenerateCsvReportOptions): string => { - const headers = ['status', 'path', 'name', 'previousQualityScore', 'contentType', 'reason', 'details']; + const oldMap = new Map(oldRecords.map(record => [record.p, record])); + const headers = ['status', 'path', 'name', 'previousQualityScore', 'newQualityScore', 'contentType', 'reason', 'details']; const rows: (string | number | undefined | null)[][] = []; for (const record of diff.added) { - rows.push(['ADDED', record.p, record.n, record.q, record.c, '', '']); + rows.push(['ADDED', record.p, record.n, '', record.q, record.c, '', '']); } for (const { record, reason, details } of diff.removed) { - rows.push(['REMOVED', record.p, record.n, record.q, record.c, reason, details || '']); + const newQualityScore = crawledMap.get(record.p)?.entry.qualityScore ?? ''; + + rows.push(['REMOVED', record.p, record.n, record.q, newQualityScore, record.c, reason, details || '']); } for (const { record, reasons } of diff.modified) { - rows.push(['MODIFIED', record.p, record.n, record.q, record.c, 'property changes', reasons.join('; ')]); + const previousQualityScore = oldMap.get(record.p)?.q ?? ''; + + rows.push(['MODIFIED', record.p, record.n, previousQualityScore, record.q, record.c, 'property changes', reasons.join('; ')]); } const changedPaths = new Set([ @@ -121,7 +126,7 @@ const generateReportCsv = ({ for (const record of newRecords) { if (!changedPaths.has(record.p)) { - rows.push(['UNCHANGED', record.p, record.n, record.q, record.c, '', '']); + rows.push(['UNCHANGED', record.p, record.n, record.q, record.q, record.c, '', '']); } } diff --git a/tests/scripts/update.collection.patternFlyApi.test.ts b/tests/scripts/update.collection.patternFlyApi.test.ts index 19ef2f35..0316d500 100644 --- a/tests/scripts/update.collection.patternFlyApi.test.ts +++ b/tests/scripts/update.collection.patternFlyApi.test.ts @@ -116,11 +116,11 @@ describe('collection.patternFlyApi CSV Report Generator', () => { const csv = generateReportCsv({ diff, oldRecords, newRecords, crawledMap }); const lines = csv.trim().split('\n'); - expect(lines[0]).toBe('status,path,name,previousQualityScore,contentType,reason,details'); - expect(lines.some(line => line.startsWith('ADDED,endpoint/added'))).toBe(true); - expect(lines.some(line => line.startsWith('REMOVED,endpoint/removed') && line.includes('lacks quality'))).toBe(true); - expect(lines.some(line => line.startsWith('MODIFIED,endpoint/modified') && line.includes('quality score'))).toBe(true); - expect(lines.some(line => line.startsWith('UNCHANGED,endpoint/unchanged'))).toBe(true); + expect(lines[0]).toBe('status,path,name,previousQualityScore,newQualityScore,contentType,reason,details'); + expect(lines.some(line => line.startsWith('ADDED,endpoint/added,New Doc,,0.97'))).toBe(true); + expect(lines.some(line => line.startsWith('REMOVED,endpoint/removed,Old Doc,0.96,0.8') && line.includes('lacks quality'))).toBe(true); + expect(lines.some(line => line.startsWith('MODIFIED,endpoint/modified,Mod Doc,0.95,0.99') && line.includes('quality score'))).toBe(true); + expect(lines.some(line => line.startsWith('UNCHANGED,endpoint/unchanged,Unchanged Doc,0.98,0.98'))).toBe(true); }); it('should generate a CSV report from a sample of collection records', () => { @@ -145,7 +145,7 @@ describe('collection.patternFlyApi CSV Report Generator', () => { }); const lines = csv.trim().split('\n'); - expect(lines[0]).toBe('status,path,name,previousQualityScore,contentType,reason,details'); + expect(lines[0]).toBe('status,path,name,previousQualityScore,newQualityScore,contentType,reason,details'); expect(lines.length).toBe(sampleRecords.length + 1); expect(lines.slice(1).every(line => line.startsWith('UNCHANGED,'))).toBe(true); }); From efa5604d3faed4d362ac45cfc4adac82cd433382 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 30 Sep 2026 17:39:16 -0400 Subject: [PATCH 6/7] fix: review update --- scripts/update.collection.patternFlyApi.ts | 23 +++++++++++++++---- .../update.collection.patternFlyApi.test.ts | 16 +++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/scripts/update.collection.patternFlyApi.ts b/scripts/update.collection.patternFlyApi.ts index f450c9b7..ef2aa023 100644 --- a/scripts/update.collection.patternFlyApi.ts +++ b/scripts/update.collection.patternFlyApi.ts @@ -54,14 +54,29 @@ interface GenerateCsvReportOptions { /** * Safely escape and format a field for standard RFC 4180 CSV output. * + * Note on CSV / Formula Injection (CWE-1236): + * Standard RFC 4180 escaping (double quote wrapping) does not prevent spreadsheet + * applications (such as Microsoft Excel, Google Sheets, or LibreOffice Calc) from + * executing cells starting with `=`, `+`, `-`, `@`, `\t`, or `\r` as formulas. + * + * By default (`sanitizeFormulas = true`), leading formula trigger characters are + * prefixed with a single quote to prevent spreadsheet execution. Pass `false` to + * preserve strict raw string fidelity for automated downstream parsers. + * * @param field - Value to format for CSV + * @param [sanitizeFormulas=true] - Whether to prefix formula trigger characters with a single quote + * @returns RFC 4180 compliant CSV cell string */ -const escapeCsvField = (field: unknown): string => { +const escapeCsvField = (field: unknown, sanitizeFormulas = true): string => { if (field === null || field === undefined) { return ''; } - const str = String(field); + let str = String(field); + + if (sanitizeFormulas && /^[=+\-@\t\r]/.test(str)) { + str = `'${str}`; + } if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { return `"${str.replace(/"/g, '""')}"`; @@ -77,8 +92,8 @@ const escapeCsvField = (field: unknown): string => { * @param rows - Table rows */ const formatCsv = (headers: string[], rows: (string | number | undefined | null)[][]): string => { - const headerLine = headers.map(escapeCsvField).join(','); - const rowLines = rows.map(row => row.map(escapeCsvField).join(',')); + const headerLine = headers.map(field => escapeCsvField(field)).join(','); + const rowLines = rows.map(row => row.map(cell => escapeCsvField(cell)).join(',')); return [headerLine, ...rowLines].join('\n') + '\n'; }; diff --git a/tests/scripts/update.collection.patternFlyApi.test.ts b/tests/scripts/update.collection.patternFlyApi.test.ts index 0316d500..3f5aaeda 100644 --- a/tests/scripts/update.collection.patternFlyApi.test.ts +++ b/tests/scripts/update.collection.patternFlyApi.test.ts @@ -68,6 +68,22 @@ describe('collection.patternFlyApi CSV Report Generator', () => { expect(escapeCsvField(123)).toBe('123'); }); + it('should sanitize formula injection characters by default', () => { + expect(escapeCsvField('=SUM(1+1)')).toBe("'=SUM(1+1)"); + expect(escapeCsvField('+123')).toBe("'+123"); + expect(escapeCsvField('-456')).toBe("'-456"); + expect(escapeCsvField('@lookup')).toBe("'@lookup"); + expect(escapeCsvField('\ttabPrefix')).toBe("'\ttabPrefix"); + expect(escapeCsvField('\rreturnPrefix')).toBe('"\'\rreturnPrefix"'); + }); + + it('should preserve raw formula characters when sanitizeFormulas is set to false', () => { + expect(escapeCsvField('=SUM(1+1)', false)).toBe('=SUM(1+1)'); + expect(escapeCsvField('+123', false)).toBe('+123'); + expect(escapeCsvField('-456', false)).toBe('-456'); + expect(escapeCsvField('@lookup', false)).toBe('@lookup'); + }); + it('should format header and row lines into standard CSV', () => { const headers = ['col1', 'col2']; const rows = [ From 9408df036310ab183bd899cf46115a737abc708f Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Wed, 30 Sep 2026 17:47:47 -0400 Subject: [PATCH 7/7] fix: review update --- scripts/update.collection.patternFlyApi.ts | 11 +++-------- src/options.defaults.ts | 4 +--- 2 files changed, 4 insertions(+), 11 deletions(-) diff --git a/scripts/update.collection.patternFlyApi.ts b/scripts/update.collection.patternFlyApi.ts index ef2aa023..ab9f5add 100644 --- a/scripts/update.collection.patternFlyApi.ts +++ b/scripts/update.collection.patternFlyApi.ts @@ -54,14 +54,9 @@ interface GenerateCsvReportOptions { /** * Safely escape and format a field for standard RFC 4180 CSV output. * - * Note on CSV / Formula Injection (CWE-1236): - * Standard RFC 4180 escaping (double quote wrapping) does not prevent spreadsheet - * applications (such as Microsoft Excel, Google Sheets, or LibreOffice Calc) from - * executing cells starting with `=`, `+`, `-`, `@`, `\t`, or `\r` as formulas. - * - * By default (`sanitizeFormulas = true`), leading formula trigger characters are - * prefixed with a single quote to prevent spreadsheet execution. Pass `false` to - * preserve strict raw string fidelity for automated downstream parsers. + * @note **CSV / Formula Injection:** By default (`sanitizeFormulas = true`), leading formula + * trigger characters are prefixed with a single quote to prevent spreadsheet execution. Pass + * `false` to preserve strict raw string fidelity for automated downstream parsers. * * @param field - Value to format for CSV * @param [sanitizeFormulas=true] - Whether to prefix formula trigger characters with a single quote diff --git a/src/options.defaults.ts b/src/options.defaults.ts index bc5d43da..8c4f8078 100644 --- a/src/options.defaults.ts +++ b/src/options.defaults.ts @@ -522,9 +522,7 @@ const CHANNEL_BASENAME = 'pf-mcp'; * Default PatternFly-specific options. * * @note Current settings for time - * - `timeoutMs` is set to `5` minutes to accommodate the current average crawl time - * of `75` seconds and potential network issues. This value should be adjusted as - * the API grows. + * - `timeoutMs` This value should be adjusted as the API grows. * - `schedule.intervalMs` is set to `7` days. Most users, without persistence, will * never achieve this. * - `schedule.delayStartMs` AFTER persistence is set up will be `6` hours. Short term