From 6f4c5f5674cc268d0bdb1f5b6ce56f950503ccdd Mon Sep 17 00:00:00 2001 From: Ahmed Sayeed Wasif Date: Thu, 16 Jul 2026 12:32:57 +0600 Subject: [PATCH 1/6] Add integration conformance checker library Static checks that map the VIP integration conformance checklist to an objective pass/fail/warn/n-a verdict per rule, plus human-readable and JSON report rendering. Security review and the plugin/platform config-schema match stay in the human-review layer, never an automated pass. --- src/lib/validate-integration/report.ts | 75 +++ src/lib/validate-integration/validate.ts | 755 +++++++++++++++++++++++ 2 files changed, 830 insertions(+) create mode 100644 src/lib/validate-integration/report.ts create mode 100644 src/lib/validate-integration/validate.ts diff --git a/src/lib/validate-integration/report.ts b/src/lib/validate-integration/report.ts new file mode 100644 index 000000000..3d83fe8e6 --- /dev/null +++ b/src/lib/validate-integration/report.ts @@ -0,0 +1,75 @@ +/** + * Rendering for the integration conformance report — a human-readable summary + * for developers and a machine-readable JSON payload for CI. + */ + +import chalk from 'chalk'; + +import type { CheckResult, CheckStatus, ValidationReport } from './validate'; + +const STATUS_LABEL: Record< CheckStatus, string > = { + pass: 'PASS', + fail: 'FAIL', + warn: 'WARN', + not_applicable: 'N/A ', +}; + +function colorForStatus( status: CheckStatus, text: string ): string { + switch ( status ) { + case 'pass': + return chalk.green( text ); + case 'fail': + return chalk.red( text ); + case 'warn': + return chalk.yellow( text ); + default: + return chalk.gray( text ); + } +} + +function formatCheck( result: CheckResult ): string { + const label = colorForStatus( result.status, STATUS_LABEL[ result.status ] ); + const details = ( result.details ?? [] ).map( detail => chalk.gray( ` - ${ detail }` ) ); + return [ + ` ${ label } Rule ${ result.rule }: ${ result.title }`, + ` ${ result.message }`, + ...details, + ].join( '\n' ); +} + +export function formatHumanReport( report: ValidationReport ): string { + const counts = report.results.reduce< Record< CheckStatus, number > >( + ( acc, result ) => { + acc[ result.status ] += 1; + return acc; + }, + { pass: 0, fail: 0, warn: 0, not_applicable: 0 } + ); + + const humanReviewLines = report.humanReview.flatMap( item => [ + ` ${ chalk.cyan( '•' ) } ${ item.title }`, + chalk.gray( ` ${ item.reason }` ), + ] ); + + const verdict = report.conformant + ? chalk.green.bold( '✓ Conformant — no automated checks failed.' ) + : chalk.red.bold( '✗ Not conformant — one or more automated checks failed.' ); + + return [ + '', + chalk.bold( `Integration conformance check: ${ report.path }` ), + '', + ...report.results.map( formatCheck ), + '', + chalk.bold( 'Human review required (not automated):' ), + ...humanReviewLines, + '', + `Summary: ${ counts.pass } passed, ${ counts.fail } failed, ${ counts.warn } warnings, ${ counts.not_applicable } n/a.`, + verdict, + '', + ].join( '\n' ); +} + +export function formatJsonReport( report: ValidationReport ): string { + return JSON.stringify( report, null, 2 ); +} diff --git a/src/lib/validate-integration/validate.ts b/src/lib/validate-integration/validate.ts new file mode 100644 index 000000000..749e51d62 --- /dev/null +++ b/src/lib/validate-integration/validate.ts @@ -0,0 +1,755 @@ +/** + * Conformance checker for WordPress VIP partner integrations. + * + * Encodes the VIP integration conformance checklist as automated, static checks + * a partner can run locally and in CI to get an objective + * conformant / not-conformant answer before submitting. + * + * The checks here are deliberately static (file and config inspection). Some + * rules can only be partially verified this way; those return `warn` and say + * so rather than pretending a clean pass. Two items — the plugin/platform + * config-schema match and the security review — are not automatable at all and + * are surfaced separately as human-review, never as an automated pass/fail. + */ + +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; +import { extname, join } from 'node:path'; + +export type CheckStatus = 'pass' | 'fail' | 'warn' | 'not_applicable'; + +export interface CheckResult { + /** Stable machine identifier for the rule. */ + id: string; + /** Checklist number (1-9) this rule maps to. */ + rule: number; + title: string; + status: CheckStatus; + /** One-line explanation of the verdict. */ + message: string; + /** Optional supporting evidence lines. */ + details?: string[]; +} + +export interface HumanReviewItem { + title: string; + reason: string; +} + +export interface ValidationReport { + path: string; + results: CheckResult[]; + humanReview: HumanReviewItem[]; + /** True when no check failed. Warnings do not break conformance. */ + conformant: boolean; +} + +interface ComposerJson { + type?: string; + autoload?: Record< string, unknown >; + scripts?: Record< string, unknown >; + require?: Record< string, string >; +} + +interface Context { + root: string; + composer: ComposerJson | null; + /** True when composer.json is present, even if it failed to parse. */ + composerExists: boolean; + /** npm scripts, so a `composer test` that shells to `npm test` can be resolved. */ + packageScripts: Record< string, unknown > | null; + /** Concatenated markdown from README plus every file under docs/. */ + docsText: string; + /** Concatenated PHP source, excluding vendor/ and node_modules/. */ + phpSource: string; + /** Concatenated YAML from .github/workflows/. */ + workflowsText: string; + /** The runtime config constant referenced by the integration, if any. */ + configConstant: string | null; + /** Root-level plugin entry file (with a "Plugin Name:" header), if any. */ + entryFile: string | null; +} + +const SKIP_DIRS = new Set( [ 'vendor', 'node_modules', '.git', 'dist', 'coverage' ] ); + +function readFileSafe( filePath: string ): string { + try { + return readFileSync( filePath, 'utf8' ); + } catch { + return ''; + } +} + +function parseComposer( root: string ): ComposerJson | null { + const composerPath = join( root, 'composer.json' ); + if ( ! existsSync( composerPath ) ) { + return null; + } + try { + return JSON.parse( readFileSafe( composerPath ) ) as ComposerJson; + } catch { + return null; + } +} + +function parsePackageScripts( root: string ): Record< string, unknown > | null { + const packagePath = join( root, 'package.json' ); + if ( ! existsSync( packagePath ) ) { + return null; + } + try { + const parsed = JSON.parse( readFileSafe( packagePath ) ) as { + scripts?: Record< string, unknown >; + }; + return parsed.scripts ?? null; + } catch { + return null; + } +} + +/** + * Collect files with one of the given extensions, skipping dependency and build + * directories. Bounded to a shallow-ish walk so a stray large tree can't hang. + */ +function collectFiles( root: string, extensions: string[], maxDepth = 6 ): string[] { + const found: string[] = []; + + const walk = ( dir: string, depth: number ): void => { + if ( depth > maxDepth ) { + return; + } + let entries: string[]; + try { + entries = readdirSync( dir ); + } catch { + return; + } + for ( const entry of entries ) { + const full = join( dir, entry ); + let isDir = false; + try { + isDir = statSync( full ).isDirectory(); + } catch { + continue; + } + if ( isDir ) { + if ( ! SKIP_DIRS.has( entry ) && ! entry.startsWith( '.' ) ) { + walk( full, depth + 1 ); + } + continue; + } + if ( extensions.includes( extname( entry ).toLowerCase() ) ) { + found.push( full ); + } + } + }; + + walk( root, 0 ); + return found; +} + +function collectDocsText( root: string ): string { + const parts: string[] = []; + const readme = join( root, 'README.md' ); + if ( existsSync( readme ) ) { + parts.push( readFileSafe( readme ) ); + } + const docsDir = join( root, 'docs' ); + if ( existsSync( docsDir ) ) { + for ( const file of collectFiles( docsDir, [ '.md' ] ) ) { + parts.push( readFileSafe( file ) ); + } + } + return parts.join( '\n\n' ); +} + +function collectWorkflowsText( root: string ): string { + const dir = join( root, '.github', 'workflows' ); + if ( ! existsSync( dir ) ) { + return ''; + } + return collectFiles( dir, [ '.yml', '.yaml' ], 2 ).map( readFileSafe ).join( '\n\n' ); +} + +function detectConfigConstant( phpSource: string ): string | null { + // Prefer the Starter Kit pattern: a Config class declaring + // `const CONSTANT_NAME = ''`. This catches integrations that follow + // the convention even if they don't use the VIP_*_CONFIG suffix. + const declared = /CONSTANT_NAME\s*=\s*'([A-Z_][A-Z0-9_]*)'/.exec( phpSource ); + if ( declared ) { + return declared[ 1 ]; + } + // Fall back to a VIP__CONFIG constant referenced anywhere. + const referenced = /\bVIP_[A-Z0-9_]+_CONFIG\b/.exec( phpSource ); + return referenced ? referenced[ 0 ] : null; +} + +function detectEntryFile( root: string ): string | null { + // The plugin entry file lives at the repo root and carries a plugin header. + let entries: string[]; + try { + entries = readdirSync( root ); + } catch { + return null; + } + for ( const entry of entries ) { + if ( extname( entry ).toLowerCase() !== '.php' ) { + continue; + } + const contents = readFileSafe( join( root, entry ) ); + if ( /Plugin Name:/i.test( contents ) ) { + return entry; + } + } + return null; +} + +function buildContext( root: string ): Context { + const composer = parseComposer( root ); + const phpFiles = collectFiles( root, [ '.php' ] ); + const phpSource = phpFiles.map( readFileSafe ).join( '\n\n' ); + + return { + root, + composer, + composerExists: existsSync( join( root, 'composer.json' ) ), + packageScripts: parsePackageScripts( root ), + docsText: collectDocsText( root ), + phpSource, + workflowsText: collectWorkflowsText( root ), + configConstant: detectConfigConstant( phpSource ), + entryFile: detectEntryFile( root ), + }; +} + +/** + * Expand a Composer script into the concrete commands it runs, following + * `@other-script` references. `@php ...` and other non-script `@` calls are + * kept as literal commands. + */ +function resolveComposerScript( + scripts: Record< string, unknown >, + name: string, + seen: Set< string > = new Set() +): string[] { + if ( seen.has( name ) ) { + return []; + } + seen.add( name ); + + const raw = scripts[ name ]; + if ( raw === undefined || raw === null ) { + return []; + } + + // Composer script values are a command string or an array of them. + const rawEntries: unknown[] = Array.isArray( raw ) ? raw : [ raw ]; + const entries = rawEntries.filter( ( item ): item is string => typeof item === 'string' ); + const commands: string[] = []; + + for ( const entry of entries ) { + const trimmed = entry.trim(); + if ( trimmed.startsWith( '@' ) ) { + const refName = trimmed.slice( 1 ).split( /\s+/ )[ 0 ]; + if ( Object.hasOwn( scripts, refName ) ) { + commands.push( ...resolveComposerScript( scripts, refName, seen ) ); + continue; + } + } + commands.push( trimmed ); + } + + return commands; +} + +/** + * Expand `npm test` / `npm run