From a79091e56584e5d8fd1e6ab796fad264b07f0236 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Fri, 9 Oct 2026 17:06:50 -0400 Subject: [PATCH 1/4] feat(tooling): pf-4720 add pf-api collections --- CONTRIBUTING.md | 8 + cspell.config.json | 1 + docs/development.md | 16 +- eslint.config.js | 4 +- guidelines/README.md | 1 + jest.config.ts | 7 +- package.json | 8 +- tooling/collections/__tests__/csv.test.ts | 174 +++++++++++++ tooling/collections/__tests__/helpers.test.ts | 148 +++++++++++ tooling/collections/__tests__/summary.test.ts | 114 +++++++++ .../__tests__/update.patternFlyApi.test.ts | 51 +--- tooling/collections/csv.ts | 172 +++++++++++++ tooling/collections/helpers.ts | 98 ++++++++ tooling/collections/summary.ts | 101 ++++++++ .../collections/update.patternFlyApi.ts | 238 +++++++----------- tsconfig.json | 3 +- 16 files changed, 945 insertions(+), 199 deletions(-) create mode 100644 tooling/collections/__tests__/csv.test.ts create mode 100644 tooling/collections/__tests__/helpers.test.ts create mode 100644 tooling/collections/__tests__/summary.test.ts rename tests/scripts/update.collection.patternFlyApi.test.ts => tooling/collections/__tests__/update.patternFlyApi.test.ts (74%) create mode 100644 tooling/collections/csv.ts create mode 100644 tooling/collections/helpers.ts create mode 100644 tooling/collections/summary.ts rename scripts/update.collection.patternFlyApi.ts => tooling/collections/update.patternFlyApi.ts (55%) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03b51393..18412284 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,6 +193,14 @@ The `Node.js` engine requirements are updated on a predictable biannual schedule ### Project maintenance +#### Collection reseeding + +When PatternFly releases new documentation or component features, reseed local catalog data: +- `npm run build:collections`: Runs the complete crawler and manifest update pipeline and runs collection tests. +- `npm run build:collections-api`: Updates `src/collection.patternFlyApi.json` and outputs CSV diffs to `reports/`. + +For information on project development maintenance tasks, refer to [Project development maintenance in docs/development.md](./docs/development.md#project-development-maintenance). + #### Releases and post-release review Full maintainer process: **[Releasing the MCP](./docs/development.md#releasing-the-mcp)** in the development guide. Day-to-day commits use [conventional commits](#pull-request-commits-messaging) to feed [CHANGELOG.md](./CHANGELOG.md). diff --git a/cspell.config.json b/cspell.config.json index f04e8ace..13842167 100644 --- a/cspell.config.json +++ b/cspell.config.json @@ -27,6 +27,7 @@ "sandboxing", "sparkline", "streamable", + "symref", "tmpfs", "treeify", "unrepresentable", diff --git a/docs/development.md b/docs/development.md index 15bc80aa..f561752c 100644 --- a/docs/development.md +++ b/docs/development.md @@ -524,11 +524,23 @@ The original curated Markdown collection. It pins remote repository URLs to spec The server packages pre-seeded API endpoints to enable instant server startup without network overhead. -- **Update command**: +- **Update commands**: ```bash + # Reseed the API collection and run collection test suites npm run build:collections + + # Reseed the API collection + npm run build:collections-api + + # Run collection test suites only + npm run test:collections + + # Run collection test suites in watch mode + npm run test:collections-dev ``` -- **Execution**: Crawls live PatternFly API endpoints, filters quality records, updates `src/collection.patternFlyApi.json`, and executes collection-specific tests. +- **Execution**: Crawls live PatternFly API endpoints via `apiSpider`, filters low-quality records, compresses content into `src/collection.patternFlyApi.json`, and outputs structured console diffs and timestamped CSV audit reports to `reports/YYYYMMDD-collection.patternFlyApi.report.csv`. +- **Configuration & Environment Overrides**: + - `UPDATE_COLLECTIONS=true`: Required environment flag to execute collection builds directly. - **When to run**: When PatternFly publishes new releases, when updating metadata/quality filters, or as part of a general maintenance cycle. #### 3. Component schemas (`@patternfly/patternfly-component-schemas`) diff --git a/eslint.config.js b/eslint.config.js index ad204b0b..c13bafca 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -127,7 +127,9 @@ export default [ { files: [ 'scripts/**/*.ts', - 'scripts/**/*.js' + 'scripts/**/*.js', + 'tooling/**/*.ts', + 'tooling/**/*.js' ], rules: { 'no-console': 0, diff --git a/guidelines/README.md b/guidelines/README.md index 860c2cc0..0a7652db 100644 --- a/guidelines/README.md +++ b/guidelines/README.md @@ -37,6 +37,7 @@ Agents should use these phrases as signals to consult specific documentation and | **"review development guide"** | Review `docs/development.md` for CLI, API, and plugin authoring. | | **"create an example tool plugin"** | Review `guidelines/agent_coding.md`, `docs/development.md`, `docs/examples/*`, and `src/*` for context, coding standards, and existing example formats. | | **"add documentation links"** / **"add doc entries"** / **"register docs"** / **"update docs.json"** / **"contribute to docs.json"** | Follow `guidelines/skills/add-docs-links/SKILL.md`: docs.json format, duplicate check, raw URL confirmation, then run unit tests and update meta. | +| **"reseed collections"** / **"update collections"** / **"build collections"** | Follow `docs/development.md#how-collection-and-source-data-get-updated`: run `npm run build:collections` (or `build:collections-api`) to sync API records manifests. | | **"review zod"** / **"zod upgrade"** / **"zod integration review"** / **"zod update report"** | Follow `guidelines/skills/review-zod-integration/SKILL.md`: release notes vs codebase, tests, report at `reports/YYYYMMDD-HHMMSS-zod-{semver}-update-report.md` (UTC). | | **"troubleshoot server"** / **"debug server"** | Review `docs/usage.md#troubleshooting` and the PatternFly MCP server resource `patternfly://context` | diff --git a/jest.config.ts b/jest.config.ts index cb22edca..dc72920a 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -72,9 +72,10 @@ export default { ...baseConfig }, { - displayName: 'collections', - roots: ['/tests/scripts'], - testMatch: ['/tests/scripts/**/*collection*.test.ts'], + displayName: 'tooling:collections', + roots: ['/tooling/collections'], + testMatch: ['/tooling/collections/**/__tests__/**/*.test.ts'], + setupFilesAfterEnv: ['/jest.setupTests.ts'], ...baseConfig }, { diff --git a/package.json b/package.json index 494a128f..4dce845f 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,8 @@ "build": "npm run build:clean; npm run test:types; pkgroll", "build:clean": "rm -rf dist", "build:watch": "npm run build -- --watch", - "build:collections": "UPDATE_COLLECTIONS=true tsx ./scripts/update.collection.patternFlyApi.ts && NODE_OPTIONS='--experimental-vm-modules' jest --selectProjects collections", + "build:collections": "npm run build:collections-api && jest --selectProjects tooling:collections", + "build:collections-api": "UPDATE_COLLECTIONS=true tsx ./tooling/collections/update.patternFlyApi.ts", "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", @@ -45,7 +46,8 @@ "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:collections": "jest --selectProjects tooling:collections", + "test:collections-dev": "npm run test:collections -- --watchAll", "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", @@ -53,7 +55,7 @@ "test:lint-fix": "eslint . --fix", "test:package": "npm run build && jest --selectProjects package", "test:spell-docs": "cspell './README.md' './CONTRIBUTING.md' './GOVERNANCE.md' './SECURITY.md' './docs/**/*.md' './guidelines/**/*.md' './docs/**/*.ts' './docs/**/*.js' --config ./cspell.config.json --fail-fast", - "test:spell": "cspell './src/**/*.ts' './tests/**/*.ts' --exclude './src/**/*test*' --exclude './tests/**/*test*' --config ./cspell.config.json --fail-fast", + "test:spell": "cspell './src/**/*.ts' './tests/**/*.ts' './tooling/**/*.ts' --exclude './src/**/*test*' --exclude './tests/**/*test*' --exclude './tooling/**/*test*' --config ./cspell.config.json --fail-fast", "test:types": "tsc --noEmit", "prepare": "npm run build", "prepack": "npm run build" diff --git a/tooling/collections/__tests__/csv.test.ts b/tooling/collections/__tests__/csv.test.ts new file mode 100644 index 00000000..f1a4d54d --- /dev/null +++ b/tooling/collections/__tests__/csv.test.ts @@ -0,0 +1,174 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { + escapeCsvField, + formatCsv, + generateDiffCsv, + getDefaultReportPath, + getReportDatePrefix, + saveCsvReport +} from '../csv'; + +jest.mock('node:fs/promises', () => ({ + ...jest.requireActual('node:fs/promises'), + mkdir: jest.fn(), + writeFile: jest.fn() +})); + +const mockMkdir = mkdir as jest.MockedFunction; +const mockWriteFile = writeFile as jest.MockedFunction; + +describe('getReportDatePrefix', () => { + it('should format a given date into YYYYMMDD- prefix format', () => { + const fixedDate = new Date(2026, 9, 9); // Oct 9, 2026 + + expect(getReportDatePrefix(fixedDate)).toBe('20261009-'); + }); + + it('should properly zero-pad single digit months and days', () => { + const singleDigitDate = new Date(2026, 0, 5); // Jan 5, 2026 + + expect(getReportDatePrefix(singleDigitDate)).toBe('20260105-'); + }); + + it('should generate a valid 9-character prefix matching /^[0-9]{8}-$/ when using current date', () => { + expect(getReportDatePrefix()).toMatch(/^[0-9]{8}-$/); + }); +}); + +describe('getDefaultReportPath', () => { + it('should resolve default path containing the timestamp prefix and filename', () => { + const fixedDate = new Date(2026, 9, 9); + const apiFileName = 'loremIpsum.report.csv'; + const docFileName = 'dolorSit.report.csv'; + const apiPath = getDefaultReportPath(apiFileName, fixedDate); + const docsPath = getDefaultReportPath(docFileName, fixedDate); + + expect(apiPath).toMatch(new RegExp(`reports/20261009-${apiFileName}$`)); + expect(docsPath).toMatch(new RegExp(`reports/20261009-${docFileName}$`)); + }); +}); + +describe('escapeCsvField', () => { + it.each([ + { description: 'plain string', input: 'normal', expected: 'normal' }, + { description: 'integer number', input: 123, expected: '123' }, + { description: 'zero', input: 0, expected: '0' }, + { description: 'boolean true', input: true, expected: 'true' }, + { description: 'boolean false', input: false, expected: 'false' }, + { description: 'null', input: null, expected: '' }, + { description: 'undefined', input: undefined, expected: '' }, + { description: 'comma delimiter', input: 'with,comma', expected: '"with,comma"' }, + { description: 'double quotes', input: 'with "quotes"', expected: '"with ""quotes"""' }, + { description: 'newline character', input: 'with\nnewline', expected: '"with\nnewline"' }, + { description: 'carriage return character', input: 'with\rreturn', expected: '"with\rreturn"' }, + { description: 'CRLF characters', input: 'with\r\nboth', expected: '"with\r\nboth"' }, + { description: 'combined quotes, commas, and newlines', input: 'all "in, one"\nline', expected: '"all ""in, one""\nline"' }, + { description: 'formula injection (=)', input: '=SUM(1+1)', expected: "'=SUM(1+1)" }, + { description: 'formula injection (+)', input: '+123', expected: "'+123" }, + { description: 'formula injection (-)', input: '-456', expected: "'-456" }, + { description: 'formula injection (@)', input: '@lookup', expected: "'@lookup" }, + { description: 'formula injection (tab)', input: '\ttabPrefix', expected: "'\ttabPrefix" }, + { description: 'formula injection (carriage return prefix)', input: '\rreturnPrefix', expected: '"\'\rreturnPrefix"' } + ])('should correctly escape field value, $description', ({ input, expected }) => { + expect(escapeCsvField(input)).toBe(expected); + }); + + it.each([ + { description: 'equals formula', input: '=SUM(1+1)', expected: '=SUM(1+1)' }, + { description: 'plus prefix', input: '+123', expected: '+123' }, + { description: 'minus prefix', input: '-456', expected: '-456' }, + { description: 'at symbol prefix', input: '@lookup', expected: '@lookup' }, + { description: 'tab character prefix', input: '\ttabPrefix', expected: '\ttabPrefix' } + ])('should preserve raw characters when sanitizeFormulas is false, $description', ({ input, expected }) => { + expect(escapeCsvField(input, false)).toBe(expected); + }); +}); + +describe('formatCsv', () => { + it.each([ + { + description: 'standard headers and escaped row lines', + headers: ['col1', 'col2'], + rows: [ + ['val1', 'val2'], + ['val3,with,comma', 'val4 "quoted"'] + ], + expected: 'col1,col2\nval1,val2\n"val3,with,comma","val4 ""quoted"""\n' + }, + { + description: 'header with empty rows', + headers: ['header1'], + rows: [], + expected: 'header1\n' + }, + { + description: 'empty headers and empty rows', + headers: [], + rows: [], + expected: '\n' + }, + { + description: 'mixed data types (numbers, booleans, undefined)', + headers: ['id', 'name', 'score', 'active'], + rows: [ + [1, 'Alice', 98.5, true], + [2, 'Bob, Jr.', undefined, undefined] + ], + expected: 'id,name,score,active\n1,Alice,98.5,true\n2,"Bob, Jr.",,\n' + } + ])('should format CSV output correctly, $description', ({ headers, rows, expected }) => { + expect(formatCsv(headers, rows)).toBe(expected); + }); +}); + +describe('generateDiffCsv', () => { + it('should format all diff categories with status column prepended', () => { + const diff = { + added: [{ id: 'a1', val: 'Added Item' }], + removed: [{ id: 'r1', val: 'Removed Item' }], + modified: [{ id: 'm1', val: 'Modified Item' }], + unchanged: [{ id: 'u1', val: 'Unchanged Item' }] + }; + + const csv = generateDiffCsv(diff, { + headers: ['status', 'id', 'val'], + added: item => [item.id, item.val], + removed: item => [item.id, item.val], + modified: item => [item.id, item.val], + unchanged: item => [item.id, item.val] + }); + + const lines = csv.trim().split('\n'); + + expect(lines[0]).toBe('status,id,val'); + expect(lines[1]).toBe('ADDED,a1,Added Item'); + expect(lines[2]).toBe('REMOVED,r1,Removed Item'); + expect(lines[3]).toBe('MODIFIED,m1,Modified Item'); + expect(lines[4]).toBe('UNCHANGED,u1,Unchanged Item'); + }); +}); + +describe('saveCsvReport', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should recursively create directories, write CSV content to disk, and log completion', async () => { + mockMkdir.mockResolvedValue(undefined as never); + mockWriteFile.mockResolvedValue(undefined as never); + const mockLog = jest.spyOn(console, 'log').mockImplementation(() => {}); + + const targetPath = '/path/to/nested/reports/summary.csv'; + const csvContent = 'status,id,name\nADDED,1,Button\n'; + + await saveCsvReport(targetPath, csvContent); + + expect(mockMkdir).toHaveBeenCalledWith('/path/to/nested/reports', { recursive: true }); + expect(mockWriteFile).toHaveBeenCalledWith(targetPath, csvContent, 'utf-8'); + expect(mockLog).toHaveBeenCalledWith(`πŸ“„ Exported full CSV report: ${targetPath}`); + }); +}); diff --git a/tooling/collections/__tests__/helpers.test.ts b/tooling/collections/__tests__/helpers.test.ts new file mode 100644 index 00000000..98691bbb --- /dev/null +++ b/tooling/collections/__tests__/helpers.test.ts @@ -0,0 +1,148 @@ +import { writeFile } from 'node:fs/promises'; +import { + getReportsPath, + getSrcPath, + runUpdateTask, + writeJsonCollection +} from '../helpers'; + +jest.mock('node:fs/promises', () => ({ + ...jest.requireActual('node:fs/promises'), + writeFile: jest.fn() +})); + +const mockWriteFile = writeFile as jest.MockedFunction; + +describe('writeJsonCollection', () => { + let logSpy: ReturnType; + + beforeEach(() => { + mockWriteFile.mockResolvedValue(undefined as never); + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should format JSON as pretty-printed by default with trailing newline', async () => { + const targetPath = '/path/to/collection.json'; + const data = { key: 'value', count: 42 }; + + const result = await writeJsonCollection(targetPath, data); + + const expectedJson = JSON.stringify(data, null, 2); + + expect(mockWriteFile).toHaveBeenCalledWith(targetPath, expectedJson + '\n', 'utf-8'); + expect(result.jsonContent).toBe(expectedJson); + expect(logSpy).toHaveBeenCalledWith(`βœ… Updated ${targetPath}:`); + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/File Size: \d+\.\d+ KB/)); + }); + + it('should format JSON in compact format when isPrettyPrint is false', async () => { + const targetPath = '/path/to/compact.json'; + const data = { a: 1, b: 2 }; + + const result = await writeJsonCollection(targetPath, data, { isPrettyPrint: false }); + + const expectedJson = JSON.stringify(data); + + expect(mockWriteFile).toHaveBeenCalledWith(targetPath, expectedJson + '\n', 'utf-8'); + expect(result.jsonContent).toBe(expectedJson); + }); + + it('should calculate elapsed time when startTime is supplied and log custom stats', async () => { + const targetPath = '/path/to/stats.json'; + const data = { items: [1, 2, 3] }; + const startTime = Date.now() - 1500; + + const result = await writeJsonCollection(targetPath, data, { + startTime, + stats: [ + { label: 'Total Items', value: 3 }, + { label: 'Status', value: 'OK' } + ] + }); + + expect(logSpy).toHaveBeenCalledWith(' - Total Items: 3'); + expect(logSpy).toHaveBeenCalledWith(' - Status: OK'); + expect(logSpy).toHaveBeenCalledWith(expect.stringMatching(/Time Elapsed: \d+\.\d+s/)); + expect(typeof result.durationSec).toBe('string'); + expect(typeof result.sizeKb).toBe('string'); + }); +}); + +describe('getSrcPath', () => { + it('should resolve default path containing the filename within src directory', () => { + const resolved = getSrcPath('test.json'); + + expect(resolved.endsWith('src/test.json')).toBe(true); + }); + + it('should support custom path overrides', () => { + const resolved = getSrcPath('custom.json', '../../custom'); + + expect(resolved.endsWith('custom/custom.json')).toBe(true); + }); +}); + +describe('getReportsPath', () => { + it('should be an aliased function', () => { + expect(typeof getReportsPath).toBe('function'); + }); +}); + +describe('runUpdateTask', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + jest.clearAllMocks(); + }); + + afterEach(() => { + process.env = originalEnv; + jest.restoreAllMocks(); + }); + + it.each([ + { + description: 'default env var unset', + env: {}, + options: undefined, + shouldInvoke: false + }, + { + description: 'default UPDATE_COLLECTIONS set to true', + env: { UPDATE_COLLECTIONS: 'true' }, + options: undefined, + shouldInvoke: true + }, + { + description: 'custom env variable set to true', + env: { CUSTOM_TRIGGER: 'true' }, + options: { envVar: 'CUSTOM_TRIGGER' }, + shouldInvoke: true + } + ])('should handle execution trigger condition, $description', async ({ env, options, shouldInvoke }) => { + Object.assign(process.env, env); + const taskFn = jest.fn().mockResolvedValue(undefined); + + await runUpdateTask('Test Task', taskFn, options); + + expect(taskFn).toHaveBeenCalledTimes(shouldInvoke ? 1 : 0); + }); + + it('should catch task errors, log failure message, and exit with status code 1', async () => { + process.env.UPDATE_COLLECTIONS = 'true'; + const testError = new Error('Task execution failed'); + const taskFn = jest.fn().mockRejectedValue(testError); + const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {}) as any); + + await runUpdateTask('Failing Task', taskFn); + + expect(errorSpy).toHaveBeenCalledWith('❌ Failed to update Failing Task:', testError); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/tooling/collections/__tests__/summary.test.ts b/tooling/collections/__tests__/summary.test.ts new file mode 100644 index 00000000..8be5e646 --- /dev/null +++ b/tooling/collections/__tests__/summary.test.ts @@ -0,0 +1,114 @@ +import { jest } from '@jest/globals'; +import { printDiffSummary } from '../summary'; + +describe('printDiffSummary', () => { + let logSpy: ReturnType; + + beforeEach(() => { + logSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should print no-change message when diff buckets are empty', () => { + printDiffSummary( + { added: [], removed: [], modified: [] }, + { + title: 'Empty Diff', + formatAdded: item => String(item), + formatRemoved: item => String(item), + formatModified: item => String(item) + } + ); + + expect(logSpy).toHaveBeenCalledWith('\nπŸ“Š Empty Diff:'); + expect(logSpy).toHaveBeenCalledWith(' ✨ No record additions, removals, or property modifications detected.'); + }); + + it('should print categorized changes with custom formatters', () => { + printDiffSummary( + { + added: [{ name: 'A1' }], + removed: [{ name: 'R1' }], + modified: [{ name: 'M1', reason: 'score' }] + }, + { + title: 'Custom Changes', + formatAdded: item => `Item: ${item.name}`, + formatRemoved: item => `Item: ${item.name}`, + formatModified: item => `Item: ${item.name} (${item.reason})` + } + ); + + expect(logSpy).toHaveBeenCalledWith('\nπŸ“Š Custom Changes:'); + expect(logSpy).toHaveBeenCalledWith(' βž• Added (1):'); + expect(logSpy).toHaveBeenCalledWith(' + Item: A1'); + expect(logSpy).toHaveBeenCalledWith(' βž– Removed (1):'); + expect(logSpy).toHaveBeenCalledWith(' - Item: R1'); + expect(logSpy).toHaveBeenCalledWith(' πŸ”„ Modified (1):'); + expect(logSpy).toHaveBeenCalledWith(' ~ Item: M1 (score)'); + }); + + it.each([ + { + description: 'default limits (10 added, 15 removed, 10 modified)', + diff: { + added: Array.from({ length: 12 }, (_, i) => `add-${i + 1}`), + removed: Array.from({ length: 18 }, (_, i) => `rem-${i + 1}`), + modified: Array.from({ length: 11 }, (_, i) => `mod-${i + 1}`) + }, + options: { + title: 'Truncated Diff', + formatAdded: (item: string) => item, + formatRemoved: (item: string) => item, + formatModified: (item: string) => item + }, + expectedLogs: [ + ' βž• Added (12):', + ' + add-10', + ' ... and 2 more', + ' βž– Removed (18):', + ' - rem-15', + ' ... and 3 more', + ' πŸ”„ Modified (11):', + ' ~ mod-10', + ' ... and 1 more' + ] + }, + { + description: 'custom limits (1 added, 1 removed, 2 modified)', + diff: { + added: ['a1', 'a2', 'a3'], + removed: ['r1', 'r2'], + modified: ['m1', 'm2', 'm3', 'm4'] + }, + options: { + title: 'Custom Limited Diff', + formatAdded: (item: string) => item, + formatRemoved: (item: string) => item, + formatModified: (item: string) => item, + limits: { added: 1, removed: 1, modified: 2 } + }, + expectedLogs: [ + ' βž• Added (3):', + ' + a1', + ' ... and 2 more', + ' βž– Removed (2):', + ' - r1', + ' ... and 1 more', + ' πŸ”„ Modified (4):', + ' ~ m1', + ' ~ m2', + ' ... and 2 more' + ] + } + ])('should truncate diff output properly, $description', ({ diff, options, expectedLogs }) => { + printDiffSummary(diff, options); + + for (const log of expectedLogs) { + expect(logSpy).toHaveBeenCalledWith(log); + } + }); +}); diff --git a/tests/scripts/update.collection.patternFlyApi.test.ts b/tooling/collections/__tests__/update.patternFlyApi.test.ts similarity index 74% rename from tests/scripts/update.collection.patternFlyApi.test.ts rename to tooling/collections/__tests__/update.patternFlyApi.test.ts index 3f5aaeda..e18ce7b8 100644 --- a/tests/scripts/update.collection.patternFlyApi.test.ts +++ b/tooling/collections/__tests__/update.patternFlyApi.test.ts @@ -1,9 +1,14 @@ 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'; +import { expandApiEmbeddedCollection, type ApiEmbeddedCollection } from '../../../src/collection.patternFlyApi'; +import { + DEFAULT_API_FILENAME, + generateReportCsv, + diffCollections, + run +} from '../update.patternFlyApi'; +import { getSrcPath } from '../helpers'; -const COLLECTION_PATH = resolve(process.cwd(), 'src/collection.patternFlyApi.json'); +const COLLECTION_PATH = getSrcPath(DEFAULT_API_FILENAME); describe('collection.patternFlyApi', () => { it('should export the run function for programmatic invocation', () => { @@ -58,44 +63,6 @@ describe('collection.patternFlyApi', () => { }); 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 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 = [ - ['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 }, diff --git a/tooling/collections/csv.ts b/tooling/collections/csv.ts new file mode 100644 index 00000000..99f67789 --- /dev/null +++ b/tooling/collections/csv.ts @@ -0,0 +1,172 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname } from 'node:path'; +import { getReportsPath } from './helpers'; + +/** + * Value types stored in a CSV cell. + * + * - `string`: For text-based values within a CSV cell. + * - `number`: For numeric values within a CSV cell. + * - `boolean`: For true/false values within a CSV cell. + * - `undefined`: Represents the absence of a value within a CSV cell. + */ +type CsvCellValue = string | number | boolean | undefined; + +/** + * Diff status + * + * - `ADDED`: Item exists in the target but not in the source. + * - `REMOVED`: Item exists in the source but not in the target. + * - `MODIFIED`: Item exists in both the source and target but has changed. + * - `UNCHANGED`: Item exists in both the source and the target without any change. + */ +type DiffStatus = 'ADDED' | 'REMOVED' | 'MODIFIED' | 'UNCHANGED'; + +/** + * Diff row mapping transformations for each status category. + */ +interface DiffReportRowMappers { + headers: string[]; + added: (item: TAdded) => CsvCellValue[]; + removed: (item: TRemoved) => CsvCellValue[]; + modified: (item: TModified) => CsvCellValue[]; + unchanged: (item: TUnchanged) => CsvCellValue[]; +} + +/** + * Generic container for categorized diff sets. + */ +interface GenericDiff { + added: Iterable; + removed: Iterable; + modified: Iterable; + unchanged: Iterable; +} + +/** + * Generate a YYYYMMDD date prefix with trailing hyphen for report filenames. + * + * @param [date=new Date()] - Target date to format + * @returns Formatted date prefix (e.g., "20261009-") + */ +const getReportDatePrefix = (date = new Date()): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + return `${year}${month}${day}-`; +}; + +/** + * Resolve the default absolute path for a CSV report with a YYYYMMDD- prefix. + * + * @param filename - Base report filename + * @param [date=new Date()] - Date to use for the timestamp prefix + * @returns Fully resolved filesystem path in the reports directory + */ +const getDefaultReportPath = (filename: string, date = new Date()): string => { + const prefix = getReportDatePrefix(date); + + return getReportsPath(`${prefix}${filename}`); +}; + +/** + * Safely escape and format a field for standard RFC 4180 CSV output. + * + * @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 + * @returns RFC 4180 compliant CSV cell string + */ +const escapeCsvField = (field: unknown, sanitizeFormulas = true): string => { + if (field === null || field === undefined) { + return ''; + } + + 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, '""')}"`; + } + + return str; +}; + +/** + * Format rows and headers into standard CSV string. + * + * @param headers - Column headers + * @param rows - Table rows + * @returns Formatted RFC 4180 CSV string + */ +const formatCsv = ( + headers: string[], + rows: CsvCellValue[][] +): string => { + const headerLine = headers.map(header => escapeCsvField(header)).join(','); + const rowLines = rows.map(row => row.map(cell => escapeCsvField(cell)).join(',')); + + return [headerLine, ...rowLines].join('\n') + '\n'; +}; + +/** + * Generic CSV generator for collection diff reports. + * Handles iteration across status buckets, prepends status tags, and formats RFC 4180 CSV. + * + * @param diff - Categorized diff buckets + * @param mappers - Column headers and row mapping functions + * @returns RFC 4180 CSV string + */ +const generateDiffCsv = ( + diff: GenericDiff, + mappers: DiffReportRowMappers +): string => { + const rows: CsvCellValue[][] = []; + + for (const item of diff.added) { + rows.push(['ADDED', ...mappers.added(item)]); + } + for (const item of diff.removed) { + rows.push(['REMOVED', ...mappers.removed(item)]); + } + for (const item of diff.modified) { + rows.push(['MODIFIED', ...mappers.modified(item)]); + } + for (const item of diff.unchanged) { + rows.push(['UNCHANGED', ...mappers.unchanged(item)]); + } + + return formatCsv(mappers.headers, rows); +}; + +/** + * Standardized helper to persist a CSV report to disk and log output status. + * + * @param targetPath - Absolute or relative file path for the CSV report + * @param csvContent - Pre-formatted CSV string content + */ +const saveCsvReport = async (targetPath: string, csvContent: string): Promise => { + await mkdir(dirname(targetPath), { recursive: true }); + await writeFile(targetPath, csvContent, 'utf-8'); + console.log(`πŸ“„ Exported full CSV report: ${targetPath}`); +}; + +export { + getDefaultReportPath, + getReportDatePrefix, + escapeCsvField, + formatCsv, + generateDiffCsv, + saveCsvReport, + type CsvCellValue, + type DiffReportRowMappers, + type DiffStatus, + type GenericDiff +}; diff --git a/tooling/collections/helpers.ts b/tooling/collections/helpers.ts new file mode 100644 index 00000000..ae918e14 --- /dev/null +++ b/tooling/collections/helpers.ts @@ -0,0 +1,98 @@ +import { writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** + * Options for writeJsonCollection. + */ +interface WriteJsonCollectionOptions { + isPrettyPrint?: boolean; + startTime?: number; + stats?: Array<{ label: string; value: string | number }>; +} + +/** + * Serialize data to JSON, write to target path, and log file size and elapsed time. + * + * @param targetPath - Absolute or relative file path for JSON output + * @param data - Data payload to serialize + * @param [options={}] - Output and logging options + * @returns Serialized JSON content and formatted metrics + */ +const writeJsonCollection = async ( + targetPath: string, + data: T, + options: WriteJsonCollectionOptions = {} +): Promise<{ jsonContent: string; sizeKb: string; durationSec: string }> => { + const isPretty = options.isPrettyPrint !== false; + const jsonContent = isPretty ? JSON.stringify(data, null, 2) : JSON.stringify(data); + + await writeFile(targetPath, jsonContent + '\n', 'utf-8'); + + const durationSec = options.startTime ? ((Date.now() - options.startTime) / 1000).toFixed(1) : '0.0'; + const sizeKb = (Buffer.byteLength(jsonContent, 'utf-8') / 1024).toFixed(1); + + console.log(`βœ… Updated ${targetPath}:`); + if (options.stats) { + for (const { label, value } of options.stats) { + console.log(` - ${label}: ${value}`); + } + } + console.log(` - File Size: ${sizeKb} KB`); + if (options.startTime) { + console.log(` - Time Elapsed: ${durationSec}s`); + } + + return { jsonContent, sizeKb, durationSec }; +}; + +/** + * Resolve the default absolute path for a JSON collection file in the src directory. + * + * @param filename - Collection JSON filename + * @param [path] - Default optional path to prepend to the filename. + * @returns Fully resolved filesystem path in the src directory + */ +const getSrcPath = (filename: string, path: string = '../../src'): string => + resolve(fileURLToPath(new URL(`${path}/${filename}`, import.meta.url))); + +/** + * Resolve the default absolute path for a report file in the reports directory. + * + * @param filename - Report filename + * @param [path] - Default optional path to prepend to the filename. + * @returns Fully resolved filesystem path in the reports directory + */ +const getReportsPath = (filename: string, path: string = '../../reports'): string => + getSrcPath(filename, path); + +/** + * Generic lifecycle runner for collection update tasks invoked via environment variables. + * + * @param taskName - Human-readable name of the task for logging + * @param taskFn - Async task function to execute + * @param [options={}] - Execution options + * @param [options.envVar='UPDATE_COLLECTIONS'] - Environment variable trigger name + */ +const runUpdateTask = async ( + taskName: string, + taskFn: () => Promise, + options: { envVar?: string } = { envVar: 'UPDATE_COLLECTIONS' } +): Promise => { + if (process.env[options.envVar || 'UPDATE_COLLECTIONS'] === 'true') { + try { + await taskFn(); + } catch (error) { + console.error(`❌ Failed to update ${taskName}:`, error); + process.exit(1); + } + } +}; + +export { + getReportsPath, + getSrcPath, + runUpdateTask, + writeJsonCollection, + type WriteJsonCollectionOptions +}; diff --git a/tooling/collections/summary.ts b/tooling/collections/summary.ts new file mode 100644 index 00000000..5e9b095d --- /dev/null +++ b/tooling/collections/summary.ts @@ -0,0 +1,101 @@ +/** + * Generic container for categorized diff items. + */ +interface DiffSummaryBuckets { + added: TAdded[]; + removed: TRemoved[]; + modified: TModified[]; +} + +/** + * Truncation thresholds for console diff output. + */ +interface DiffSummaryLimits { + added?: number; + removed?: number; + modified?: number; +} + +/** + * Formatters and configuration for diff summary output. + */ +interface DiffSummaryFormatters { + title: string; + formatAdded: (item: TAdded) => string; + formatRemoved: (item: TRemoved) => string; + formatModified: (item: TModified) => string; + limits?: DiffSummaryLimits; +} + +/** + * Print a truncated, categorized summary of additions, removals, and modifications to the console. + * + * @param diff - Categorized diff items + * @param options - Formatters and configuration object + * @param options.title - Title for the summary report + * @param options.formatAdded - Formatter function for added items + * @param options.formatRemoved - Formatter function for removed items + * @param options.formatModified - Formatter function for modified items + * @param [options.limits] - Truncation limits + */ +const printDiffSummary = ( + diff: DiffSummaryBuckets, + { + title, + formatAdded, + formatRemoved, + formatModified, + limits = {} + }: DiffSummaryFormatters +): void => { + const { added, removed, modified } = diff; + const hasChanges = added.length > 0 || removed.length > 0 || modified.length > 0; + const maxAdded = limits.added ?? 10; + const maxRemoved = limits.removed ?? 15; + const maxModified = limits.modified ?? 10; + + console.log(`\nπŸ“Š ${title}:`); + + if (!hasChanges) { + console.log(' ✨ No record additions, removals, or property modifications detected.'); + + return; + } + + if (added.length > 0) { + console.log(` βž• Added (${added.length}):`); + added.slice(0, maxAdded).forEach(item => { + console.log(` + ${formatAdded(item)}`); + }); + if (added.length > maxAdded) { + console.log(` ... and ${added.length - maxAdded} more`); + } + } + + if (removed.length > 0) { + console.log(` βž– Removed (${removed.length}):`); + removed.slice(0, maxRemoved).forEach(item => { + console.log(` - ${formatRemoved(item)}`); + }); + if (removed.length > maxRemoved) { + console.log(` ... and ${removed.length - maxRemoved} more`); + } + } + + if (modified.length > 0) { + console.log(` πŸ”„ Modified (${modified.length}):`); + modified.slice(0, maxModified).forEach(item => { + console.log(` ~ ${formatModified(item)}`); + }); + if (modified.length > maxModified) { + console.log(` ... and ${modified.length - maxModified} more`); + } + } +}; + +export { + printDiffSummary, + type DiffSummaryBuckets, + type DiffSummaryFormatters, + type DiffSummaryLimits +}; diff --git a/scripts/update.collection.patternFlyApi.ts b/tooling/collections/update.patternFlyApi.ts similarity index 55% rename from scripts/update.collection.patternFlyApi.ts rename to tooling/collections/update.patternFlyApi.ts index ab9f5add..bcdc0717 100644 --- a/scripts/update.collection.patternFlyApi.ts +++ b/tooling/collections/update.patternFlyApi.ts @@ -1,6 +1,4 @@ -import { readFile, writeFile, mkdir } from 'node:fs/promises'; -import { dirname, resolve } from 'node:path'; -import { fileURLToPath } from 'node:url'; +import { readFile } from 'node:fs/promises'; import { apiSpider, contentMetadata, @@ -9,10 +7,16 @@ import { type ApiEmbedded, type ApiEmbeddedCollection, MIN_API_QUALITY_THRESHOLD -} from '../src/collection.patternFlyApi'; -import { getSessionOptions, getOptions, runWithOptions } from '../src/options.context'; -import { createLogger } from '../src/logger'; -import { type LoggingSession } from '../src/options.defaults'; +} from '../../src/collection.patternFlyApi'; +import { getLoggerOptions, getOptions, runWithOptions } from '../../src/options.context'; +import { createLogger } from '../../src/logger'; +import { + generateDiffCsv, + getDefaultReportPath, + saveCsvReport +} from './csv'; +import { getSrcPath, runUpdateTask, writeJsonCollection } from './helpers'; +import { printDiffSummary } from './summary'; /** * Reason classification for omitted or removed API records. @@ -52,46 +56,27 @@ interface GenerateCsvReportOptions { } /** - * Safely escape and format a field for standard RFC 4180 CSV output. - * - * @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 - * @returns RFC 4180 compliant CSV cell string + * Configurable default filename for the API collection. */ -const escapeCsvField = (field: unknown, sanitizeFormulas = true): string => { - if (field === null || field === undefined) { - return ''; - } - - 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, '""')}"`; - } - - return str; -}; +const DEFAULT_API_FILENAME = 'collection.patternFlyApi.json'; /** - * Format rows and headers into standard CSV string. - * - * @param headers - Column headers - * @param rows - Table rows + * Configurable default filename for the API report. */ -const formatCsv = (headers: string[], rows: (string | number | undefined | null)[][]): string => { - const headerLine = headers.map(field => escapeCsvField(field)).join(','); - const rowLines = rows.map(row => row.map(cell => escapeCsvField(cell)).join(',')); +const DEFAULT_API_REPORT_FILENAME = 'collection.patternFlyApi.report.csv'; - return [headerLine, ...rowLines].join('\n') + '\n'; -}; +/** + * Options for running the PatternFly API embedded collection update. + */ +interface UpdateApiOptions { + filename?: string | undefined; + reportFilename?: string | undefined; + outputPath?: string | undefined; + csvOutputPath?: string | undefined; + isPrettyPrint?: boolean | undefined; + filterLowQualityRecords?: boolean | undefined; + outputCsv?: boolean | undefined; +} /** * Generate a complete, non-truncated CSV report for additions, removals, modifications, and unchanged records. @@ -109,38 +94,39 @@ const generateReportCsv = ({ crawledMap }: GenerateCsvReportOptions): string => { 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, '', '']); - } - - for (const { record, reason, details } of diff.removed) { - 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) { - 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([ ...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.q, record.c, '', '']); + const unchanged = newRecords.filter(record => !changedPaths.has(record.p)); + + return generateDiffCsv( + { ...diff, unchanged }, + { + headers: ['status', 'path', 'name', 'previousQualityScore', 'newQualityScore', 'contentType', 'reason', 'details'], + added: record => [record.p, record.n, '', record.q, record.c, '', ''], + removed: ({ record, reason, details }) => [ + record.p, + record.n, + record.q, + crawledMap.get(record.p)?.entry.qualityScore ?? '', + record.c, + reason, + details || '' + ], + modified: ({ record, reasons }) => [ + record.p, + record.n, + oldMap.get(record.p)?.q ?? '', + record.q, + record.c, + 'property changes', + reasons.join('; ') + ], + unchanged: record => [record.p, record.n, record.q, record.q, record.c, '', ''] } - } - - return formatCsv(headers, rows); + ); }; /** @@ -238,77 +224,48 @@ const diffCollections = ( }; /** - * Create a light diff report between old and new collections. + * Create a diff report with annotated reasons between old and new collections. * - * @param diff - Diff report + * @param diff - Complete diff result */ const diffReport = (diff: ReturnType) => { - const { added, removed, modified } = diff; - const hasChanges = added.length > 0 || removed.length > 0 || modified.length > 0; - - console.log('\nπŸ“Š Collection Diff Report:'); - - if (!hasChanges) { - console.log(' ✨ No record additions, removals, or property modifications detected.'); - - return; - } - - if (added.length > 0) { - console.log(` βž• Added (${added.length}):`); - added.slice(0, 10).forEach(record => console.log(` + ${record.p} (Q: ${record.q})`)); - - if (added.length > 10) { - console.log(` ... and ${added.length - 10} more`); - } - } - - if (removed.length > 0) { - console.log(` βž– Removed (${removed.length}):`); - removed.slice(0, 15).forEach(({ record, reason, details }) => { - console.log(` - ${record.p} (Previous Q: ${record.q}) [Reason: ${reason}${details ? ` β€” ${details}` : ''}]`); - }); - - 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, reasons }) => { - console.log(` ~ ${record.p} [${reasons.join(', ')}]`); - }); - - if (modified.length > 10) { - console.log(` ... and ${modified.length - 10} more`); - } - } + printDiffSummary(diff, { + title: 'PatternFly API Collection Diff Report', + formatAdded: record => `${record.p} (${record.n})`, + formatRemoved: ({ record, reason, details }) => `${record.p} (${record.n}) [Reason: ${reason}${details ? ` β€” ${details}` : ''}]`, + formatModified: ({ record, reasons }) => `${record.p} [${reasons.join(', ')}]` + }); }; /** * Run apiSpider directly and transform crawler entries into compressed embedded JSON. * * @param [options] - Optional configuration options. + * @param [options.filename=DEFAULT_API_FILENAME] - Target collection filename. + * @param [options.reportFilename=DEFAULT_API_REPORT_FILENAME] - Target CSV report filename. + * @param [options.outputPath] - Fully resolved path to write the JSON collection. + * @param [options.csvOutputPath] - Fully resolved path to write the CSV report. * @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 ( { + filename = DEFAULT_API_FILENAME, + reportFilename = DEFAULT_API_REPORT_FILENAME, + outputPath = getSrcPath(filename), + csvOutputPath = getDefaultReportPath(reportFilename), isPrettyPrint = true, filterLowQualityRecords = false, - outputCsv = true, - csvOutputPath - }: { isPrettyPrint?: boolean; filterLowQualityRecords?: boolean; outputCsv?: boolean; csvOutputPath?: string; } = {} + outputCsv = true + }: UpdateApiOptions = {} ) => { // 1. Enable stderr logging so all diagnostics_channel logs (debug, info, warn, error) are printed const unsubscribeLogger = createLogger({ - channelName: getSessionOptions().channelName, + ...getLoggerOptions(), stderr: true, level: 'debug' - } as LoggingSession); + }); console.log('πŸš€ Generating PatternFly API embedded collection...'); const keepAlive = setTimeout(() => {}, 86_400_000); @@ -361,8 +318,6 @@ const run = async ( records }; - const outputPath = resolve(fileURLToPath(new URL('../src/collection.patternFlyApi.json', import.meta.url))); - const jsonContent = isPrettyPrint ? JSON.stringify(payload, null, 2) : JSON.stringify(payload); let oldRecords: ApiEmbedded[] = []; try { @@ -374,31 +329,23 @@ const run = async ( // File might not exist yet on initial run } - await writeFile(outputPath, jsonContent + '\n', 'utf-8'); - - const durationSec = ((Date.now() - startTime) / 1000).toFixed(1); - const sizeKb = (Buffer.byteLength(jsonContent, 'utf-8') / 1024).toFixed(1); - - console.log(`βœ… Updated src/collection.patternFlyApi.json:`); - console.log(` - Total Crawled: ${entries.length} endpoints`); - console.log(` - Admitted Records: ${records.length}`); - console.log(` - File Size: ${sizeKb} KB`); - console.log(` - Time Elapsed: ${durationSec}s`); + await writeJsonCollection(outputPath, payload, { + isPrettyPrint, + startTime, + stats: [ + { label: 'Total Crawled', value: `${entries.length} endpoints` }, + { label: 'Admitted Records', value: records.length } + ] + }); 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}`); + await saveCsvReport(csvOutputPath, csvContent); } } finally { clearTimeout(keepAlive); @@ -407,23 +354,20 @@ const run = async ( }; /** - * Configurable options for maintainers. - * Only execute when explicitly requested via UPDATE_COLLECTIONS=true + * Direct execution when invoked via UPDATE_COLLECTIONS=true */ -if (process.env.UPDATE_COLLECTIONS === 'true') { - run({ isPrettyPrint: true, filterLowQualityRecords: true }).catch(error => { - console.error('❌ Failed to update API collection:', error); - process.exit(1); - }); -} +runUpdateTask('API collection', run); export { + DEFAULT_API_FILENAME, + DEFAULT_API_REPORT_FILENAME, diffCollections, - escapeCsvField, - formatCsv, + diffReport, generateReportCsv, run, + type GenerateCsvReportOptions, type ModifiedRecordReport, type RemovalReason, - type RemovedRecordReport + type RemovedRecordReport, + type UpdateApiOptions }; diff --git a/tsconfig.json b/tsconfig.json index 6a5320a5..95b6bcc9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,7 +21,7 @@ "resolveJsonModule": true, "noEmit": true, "stripInternal": true, - "rootDirs": ["./src", "./scripts", "./tests/e2e", "./tests/audit"], + "rootDirs": ["./src", "./scripts", "./tooling", "./tests/e2e", "./tests/audit"], "paths": { "#apiCatalog": ["./src/collection.patternFlyApi.json"], "#docsCatalog": ["./src/docs.json"], @@ -34,6 +34,7 @@ "src/**/*", "tests/**/*", "scripts/**/*", + "tooling/**/*", "jest.setupTests.ts", "jest.config.ts", "types/**/*.d.ts", From d081b0bf5b427a7cb111d8d0c999c6e57623938c Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Tue, 13 Oct 2026 06:16:59 -0400 Subject: [PATCH 2/4] fix: review update --- CONTRIBUTING.md | 10 +++--- docs/development.md | 82 +++++++++++++++++++++++++-------------------- 2 files changed, 49 insertions(+), 43 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 18412284..8d4ab354 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -193,13 +193,11 @@ The `Node.js` engine requirements are updated on a predictable biannual schedule ### Project maintenance -#### Collection reseeding +For information on project development maintenance tasks, refer to [Project development maintenance in docs/development.md](./docs/development.md#project-development-maintenance). -When PatternFly releases new documentation or component features, reseed local catalog data: -- `npm run build:collections`: Runs the complete crawler and manifest update pipeline and runs collection tests. -- `npm run build:collections-api`: Updates `src/collection.patternFlyApi.json` and outputs CSV diffs to `reports/`. +#### Collection updates/reseeding -For information on project development maintenance tasks, refer to [Project development maintenance in docs/development.md](./docs/development.md#project-development-maintenance). +For detailed collection reseeding steps, see [Collection reseeding](./docs/development.md#updating-collection-seeds) in the development guide. #### Releases and post-release review @@ -207,7 +205,7 @@ Full maintainer process: **[Releasing the MCP](./docs/development.md#releasing-t After each release, run the [post-release review](./docs/development.md#post-release-review) checklist. **CI** is the automated gate; negative findings can be filed as **GitHub Issues** for triage. -For release steps, collection updates, and other maintainer tasks, see [Project development maintenance](./docs/development.md#project-development-maintenance) in the development guide. +For detailed release steps, see [Project development maintenance](./docs/development.md#project-development-maintenance) in the development guide. ## AI agent diff --git a/docs/development.md b/docs/development.md index f561752c..eeb8f7d5 100644 --- a/docs/development.md +++ b/docs/development.md @@ -427,6 +427,36 @@ These terms describe **how tools and their related properties are represented** - **`Zod schema`**: A Zod schema instance. Loosely detected with `isZodSchema` (Zod 4 `_zod` / public `.def`, Zod 3 `_def`). - **`Raw Zod shape` (`ZodRawShapeCompat`)**: A **non-empty** plain object whose **values** are Zod schemas; keys are field names. An empty `{}` is **not** a raw Zod shape. Detected with `isZodRawShape`. +## MCP collections + +Internal and custom collections implement a `McpCollection` tuple structure: + +```typescript +type McpCollection = [ + name: string, + config: { title?: string }, + handler: (arg?: unknown) => McpCollectionResult | Promise, + _config?: { + initial?: McpCollectionResult | (() => McpCollectionResult | Promise); + runParallel?: `#${string}`; + runSchedule?: { intervalMs?: number; cancelMs?: number; delayStartMs?: number; continueOnError?: boolean; repeat?: number; }; + retainLastViable?: boolean | ((context) => boolean | Promise); + isRequired?: boolean; + } +]; +``` + +#### Tuple configuration options + +- **`name`** (`string`): Unique identifier for the collection (e.g., `'patternfly-docs'`). +- **`config`** (`object`): Plugin-visible metadata, such as `title`. +- **`handler`** (`function`): Sync, or async, function returning an `McpCollectionResult` object containing `{ records: [...] }`. +- **`_config.initial`**: Synchronous or async initial data loader executed at server startup ($t=0$) before scheduled tasks or worker threads run. +- **`_config.runParallel`**: Subpath import specifier (`#specifier`) used to execute the collection handler in a background worker pool thread (`server.workerPool`). +- **`_config.runSchedule`**: Configuration for recurring background refresh intervals via `deferTask`. +- **`_config.retainLastViable`**: Retains previously loaded valid records if an update attempt fails or returns an empty payload. +- **`_config.isRequired`**: Controls whether server startup requires this collection to be populated. + ## Initial troubleshooting ### Tool plugins @@ -450,7 +480,7 @@ These terms describe **how tools and their related properties are represented** ## Project development maintenance -For information on build maintenance, refer to [Maintenance in CONTRIBUTING.md](../CONTRIBUTING.md#nodejs-engine-bumps). +For information on general build maintenance, refer to [Maintenance in CONTRIBUTING.md](../CONTRIBUTING.md#nodejs-engine-bumps). ### Releasing the MCP @@ -506,11 +536,19 @@ Negative audit findings should have a **GitHub Issue** opened. Link the release Not every observation is a bug: link checks, upstream blips, and environment-specific behavior can be **false positives**. Use an issue for **triage** when unsure (β€œinvestigate whether X is real”) rather than silent fixes or a separate audit log. -### How collection and source data get updated +### Updating collection seeds + +The server maintains multiple record collections that provide documentation and schemas to a consuming LLM. Each collection source follows its own maintenance lifecycle. + +#### General collection reseeding -The server maintains multiple record collections that provide documentation and schemas to a consuming LLM. Each collection source follows its own maintenance lifecycle: +When PatternFly releases new documentation or component features, reseed local catalog data: +- `npm run build:collections`: Runs the complete crawler and manifest update pipeline and runs collection tests. +- `npm run build:collections-api`: Updates `src/collection.patternFlyApi.json` and outputs CSV diffs to `reports/`. -#### 1. Embedded documentation collection (`src/docs.json`) +#### Unique collections + +##### 1. Embedded documentation collection (`src/docs.json`) > A large percentage of `docs.json` records are being moved into the PatternFly API. In the near future, `docs.json` will be renamed and incorporated into a support collection for one-off records. @@ -520,7 +558,7 @@ The original curated Markdown collection. It pins remote repository URLs to spec - **Validation**: Ensure all raw URLs are HTTPS, match the domain whitelist in `src/options.defaults.ts`, and return 2xx responses. - **Testing**: Run `npm test` and update `baseHashes` and the Repository Breakdown table in `src/__tests__/docs.json.test.ts` whenever refs change. -#### 2. Pre-built API collection (`src/collection.patternFlyApi.json`) +##### 2. Pre-built API collection (`src/collection.patternFlyApi.json`) The server packages pre-seeded API endpoints to enable instant server startup without network overhead. @@ -543,7 +581,7 @@ The server packages pre-seeded API endpoints to enable instant server startup wi - `UPDATE_COLLECTIONS=true`: Required environment flag to execute collection builds directly. - **When to run**: When PatternFly publishes new releases, when updating metadata/quality filters, or as part of a general maintenance cycle. -#### 3. Component schemas (`@patternfly/patternfly-component-schemas`) +##### 3. Component schemas (`@patternfly/patternfly-component-schemas`) > A large percentage of Component schemas have been moved into the PatternFly API and are also being considered for incorporation into PatternFly React architecture and may be completely superseded in the future. @@ -553,7 +591,7 @@ Component JSON schemas provide runtime prop definitions, default values, and typ - **Validation**: Verified through `src/collection.patternFlySchemas.ts` and automated unit tests. - **When to run**: When [PatternFly Component Schemas repo](https://github.com/patternfly/patternfly-component-schemas) publishes a new release. -#### 4. Red Hat AI Handbook (`src/collection.aiHandbook.ts`) +##### 4. Red Hat AI Handbook (`src/collection.aiHandbook.ts`) > The AI Handbook is a support collection since its content goes beyond PatternFly towards AI-specific patterns at an organizational and enterprise level. @@ -562,36 +600,6 @@ Collection of specialized guides and AI patterns that are fetched dynamically at - **Update workflow**: Synchronized on runtime from the [Red Hat AI Handbook repo](https://github.com/rh-uxd/ai-handbook). - **Validation**: Verified through automated unit tests. -### Collection authoring and specification - -Internal and custom collections implement the `McpCollection` tuple structure: - -```typescript -type McpCollection = [ - name: string, - config: { title?: string }, - handler: (arg?: unknown) => McpCollectionResult | Promise, - _config?: { - initial?: McpCollectionResult | (() => McpCollectionResult | Promise); - runParallel?: `#${string}`; - runSchedule?: { intervalMs?: number; cancelMs?: number; delayStartMs?: number; continueOnError?: boolean; repeat?: number; }; - retainLastViable?: boolean | ((context) => boolean | Promise); - isRequired?: boolean; - } -]; -``` - -#### Tuple configuration options - -- **`name`** (`string`): Unique identifier for the collection (e.g., `'patternfly-docs'`). -- **`config`** (`object`): Plugin-visible metadata, such as `title`. -- **`handler`** (`function`): Sync, or async, function returning an `McpCollectionResult` object containing `{ records: [...] }`. -- **`_config.initial`**: Synchronous or async initial data loader executed at server startup ($t=0$) before scheduled tasks or worker threads run. -- **`_config.runParallel`**: Subpath import specifier (`#specifier`) used to execute the collection handler in a background worker pool thread (`server.workerPool`). -- **`_config.runSchedule`**: Configuration for recurring background refresh intervals via `deferTask`. -- **`_config.retainLastViable`**: Retains previously loaded valid records if an update attempt fails or returns an empty payload. -- **`_config.isRequired`**: Controls whether server startup requires this collection to be populated. - ## In-progress and future work For more information on our development roadmap, including "in-progress" and "future" work, please refer to the [Architecture & Roadmap](./architecture.md#roadmap) documentation. From 894d1741b4c6b32ad6739e9fd0b96bda4f57d2a6 Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Tue, 13 Oct 2026 06:32:45 -0400 Subject: [PATCH 3/4] fix: review update --- CONTRIBUTING.md | 2 +- docs/development.md | 4 ++-- guidelines/README.md | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8d4ab354..6178d359 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -197,7 +197,7 @@ For information on project development maintenance tasks, refer to [Project deve #### Collection updates/reseeding -For detailed collection reseeding steps, see [Collection reseeding](./docs/development.md#updating-collection-seeds) in the development guide. +For detailed collection reseeding steps, see [Updating collections](./docs/development.md#updating-collections) in the development guide. #### Releases and post-release review diff --git a/docs/development.md b/docs/development.md index eeb8f7d5..364605ba 100644 --- a/docs/development.md +++ b/docs/development.md @@ -536,13 +536,13 @@ Negative audit findings should have a **GitHub Issue** opened. Link the release Not every observation is a bug: link checks, upstream blips, and environment-specific behavior can be **false positives**. Use an issue for **triage** when unsure (β€œinvestigate whether X is real”) rather than silent fixes or a separate audit log. -### Updating collection seeds +### Updating collections The server maintains multiple record collections that provide documentation and schemas to a consuming LLM. Each collection source follows its own maintenance lifecycle. #### General collection reseeding -When PatternFly releases new documentation or component features, reseed local catalog data: +When PatternFly releases new documentation or component features, reseed the embedded local collection data: - `npm run build:collections`: Runs the complete crawler and manifest update pipeline and runs collection tests. - `npm run build:collections-api`: Updates `src/collection.patternFlyApi.json` and outputs CSV diffs to `reports/`. diff --git a/guidelines/README.md b/guidelines/README.md index 0a7652db..a4756fa1 100644 --- a/guidelines/README.md +++ b/guidelines/README.md @@ -37,7 +37,7 @@ Agents should use these phrases as signals to consult specific documentation and | **"review development guide"** | Review `docs/development.md` for CLI, API, and plugin authoring. | | **"create an example tool plugin"** | Review `guidelines/agent_coding.md`, `docs/development.md`, `docs/examples/*`, and `src/*` for context, coding standards, and existing example formats. | | **"add documentation links"** / **"add doc entries"** / **"register docs"** / **"update docs.json"** / **"contribute to docs.json"** | Follow `guidelines/skills/add-docs-links/SKILL.md`: docs.json format, duplicate check, raw URL confirmation, then run unit tests and update meta. | -| **"reseed collections"** / **"update collections"** / **"build collections"** | Follow `docs/development.md#how-collection-and-source-data-get-updated`: run `npm run build:collections` (or `build:collections-api`) to sync API records manifests. | +| **"reseed collections"** / **"update collections"** / **"build collections"** | Follow `docs/development.md#updating-collections`: run `npm run build:collections` (or `build:collections-api`) to sync API records. | | **"review zod"** / **"zod upgrade"** / **"zod integration review"** / **"zod update report"** | Follow `guidelines/skills/review-zod-integration/SKILL.md`: release notes vs codebase, tests, report at `reports/YYYYMMDD-HHMMSS-zod-{semver}-update-report.md` (UTC). | | **"troubleshoot server"** / **"debug server"** | Review `docs/usage.md#troubleshooting` and the PatternFly MCP server resource `patternfly://context` | From 0c42db6fac41e7a57431eaf49b7c921efa48c90c Mon Sep 17 00:00:00 2001 From: CD Cabrera Date: Tue, 13 Oct 2026 06:59:23 -0400 Subject: [PATCH 4/4] fix: review update --- tooling/collections/update.patternFlyApi.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tooling/collections/update.patternFlyApi.ts b/tooling/collections/update.patternFlyApi.ts index bcdc0717..f6c609e5 100644 --- a/tooling/collections/update.patternFlyApi.ts +++ b/tooling/collections/update.patternFlyApi.ts @@ -246,7 +246,7 @@ const diffReport = (diff: ReturnType) => { * @param [options.outputPath] - Fully resolved path to write the JSON collection. * @param [options.csvOutputPath] - Fully resolved path to write the CSV report. * @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.filterLowQualityRecords=true] - 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. */ const run = async ( @@ -256,7 +256,7 @@ const run = async ( outputPath = getSrcPath(filename), csvOutputPath = getDefaultReportPath(reportFilename), isPrettyPrint = true, - filterLowQualityRecords = false, + filterLowQualityRecords = true, outputCsv = true }: UpdateApiOptions = {} ) => {