Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,12 @@ 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/`.
- `npm run build:collections-docs`: Updates `src/docs.json` with latest repository hashes and prunes API duplicates.

For information on project development maintenance tasks, refer to [Project development maintenance in docs/development.md](./docs/development.md#project-development-maintenance).

## AI agent
Expand Down
1 change: 1 addition & 0 deletions cspell.config.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
"sandboxing",
"sparkline",
"streamable",
"symref",
"tmpfs",
"treeify",
"unrepresentable",
Expand Down
21 changes: 17 additions & 4 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -460,19 +460,32 @@ The server maintains multiple record collections that provide documentation and

The original curated Markdown collection. It pins remote repository URLs to specific Git commit SHAs to guarantee consistent documentation and remove upstream breaking changes.

- **Update workflow**: Follow the [add-docs-links skill](../guidelines/skills/add-docs-links/SKILL.md) to add, update, or remove entries.
- **Update workflow**: Follow the [add-docs-links skill](../guidelines/skills/add-docs-links/SKILL.md) for manual additions/edits, or run the automated reseeding script:
```bash
npm run build:collections-docs
```
- **Automated synchronization**: The Docs reseeding pipeline (`tooling/collections/update.patternFlyDocs.ts`) automatically resolves the latest commit SHAs across referenced GitHub repositories, prunes redundant AI guideline entries superseded by high-quality API endpoints, recalculates `meta.totalEntries` and `meta.totalDocs`, and generates an audit diff report at `reports/YYYYMMDD-collection.patternFlyDocs.report.csv`.
- **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.
- **Testing**: Run `npm run test:collections` or `npm test`, updating `baseHashes` and the Repository Breakdown table in `src/__tests__/docs.json.test.ts` when refs change.

#### 2. Pre-built API collection (`src/collection.patternFlyApi.json`)

The server packages pre-seeded API endpoints to enable instant server startup without network overhead.

- **Update command**:
- **Update commands**:
```bash
# Reseed both API and Docs collections and run collection test suites
npm run build:collections

# Reseed only the API collection
npm run build:collections-api

# Run collection test suites only
npm run test:collections
```
- **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`)
Expand Down
4 changes: 3 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,9 @@ export default [
{
files: [
'scripts/**/*.ts',
'scripts/**/*.js'
'scripts/**/*.js',
'tooling/**/*.ts',
'tooling/**/*.js'
],
rules: {
'no-console': 0,
Expand Down
1 change: 1 addition & 0 deletions guidelines/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"** / **"refresh docs manifest"** | Follow `docs/development.md#how-collection-and-source-data-get-updated`: run `npm run build:collections` (or `build:collections-api` / `build:collections-docs`) to sync API records and docs 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` |

Expand Down
7 changes: 4 additions & 3 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,9 +72,10 @@ export default {
...baseConfig
},
{
displayName: 'collections',
roots: ['<rootDir>/tests/scripts'],
testMatch: ['<rootDir>/tests/scripts/**/*collection*.test.ts'],
displayName: 'tooling:collections',
roots: ['<rootDir>/tooling/collections'],
testMatch: ['<rootDir>/tooling/collections/**/__tests__/**/*.test.ts'],
setupFilesAfterEnv: ['<rootDir>/jest.setupTests.ts'],
...baseConfig
},
{
Expand Down
9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
"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 && npm run build:collections-docs && jest --selectProjects tooling:collections",
"build:collections-api": "UPDATE_COLLECTIONS=true tsx ./tooling/collections/update.patternFlyApi.ts",
"build:collections-docs": "UPDATE_COLLECTIONS=true tsx ./tooling/collections/update.patternFlyDocs.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",
Expand All @@ -45,15 +47,16 @@
"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",
"test:lint": "eslint .",
"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"
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/docs.json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ describe('docs.json', () => {
*
* | Repository | Hash / Branch | Count | Type |
* |---|---|---|---|
* | uxd-ai-helpers | e8cca17430a8ccb062ed1878073165417a081b34 | 6 | **Primary** |
* | uxd-ai-helpers | e8cca17430a8ccb062ed1878073165417a081b34 | 6 | One-off |
* | patternfly-cli | ce032cd16ddb90c540cb4f18c6830e190cd9e3e9 | 1 | One-off |
* | patternfly-elements | 402b3b0e7ed73cb2aa21531e0eab4216c2211212 | 1 | One-off |
* | patternfly-mcp | 4e1cd7c636f6ac17c851f54a920549e5377f3809 | 5 | One-off |
Expand Down
13 changes: 12 additions & 1 deletion tests/audit/docs.audit.test.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,22 @@
import { randomInt } from 'node:crypto';
import { existsSync, readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { jest } from '@jest/globals';
import docs from '../../src/docs.json';
import { type PatternFlyMcpDocsCatalog } from '../../src/docs.embedded';
import { checkUrl } from './utils/checkUrl';

describe('Documentation Link Audit', () => {
let auditSet: string[] = [];

const catalogPath = resolve(
process.cwd(),
process.env.DOCS_COLLECTION_PATH || 'src/docs.json'
);

const docs: PatternFlyMcpDocsCatalog = existsSync(catalogPath)
? JSON.parse(readFileSync(catalogPath, 'utf-8'))
: { meta: { totalEntries: 0, totalDocs: 0, source: '' }, docs: {} };

/**
* Extracts and returns a prefix based on the provided URL.
* - If the hostname is not "raw.githubusercontent.com", the hostname is returned.
Expand Down
174 changes: 174 additions & 0 deletions tooling/collections/__tests__/csv.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof mkdir>;
const mockWriteFile = writeFile as jest.MockedFunction<typeof writeFile>;

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}`);
});
});
Loading
Loading