diff --git a/CHANGELOG.md b/CHANGELOG.md index 1349c63af..2146be814 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,11 +28,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Removed +- **Duplicate `.github/agents/` folder (Phase 1 restructuring compliance)** — Deleted entire `.github/agents/` folder (55 files) consolidating all agent implementations to root `agents/` folder per Phase 1 restructuring rules. The `.github/agents/` folder violated the portable assets rule by containing multi-file agent implementations (Claude/Copilot/OpenAI) when it should only contain "simple YAML/JSON definitions" (GitHub-native only). All agent implementations now properly organized at root as portable reusable assets. ([PR #1533](https://github.com/lightspeedwp/.github/pull/1533), [#1510](https://github.com/lightspeedwp/.github/issues/1510), [#1507](https://github.com/lightspeedwp/.github/issues/1507)) + - **Legacy README workflows (Phase 2.4 consolidation)** — Removed three legacy README management workflows (`readme-audit.yml`, `readme-regen.yml`, `readme-update.yml`) consolidated into unified `documentation.yml` workflow. Eliminates 449 lines of code duplication (~44% reduction for README workflows), saves ~3-4 min/month GitHub Actions execution time, and establishes single source of truth for README validation logic. Push trigger re-enabled in `documentation.yml` following consolidation. ([PR #1317](https://github.com/lightspeedwp/.github/pull/1317), [Epic #1227](https://github.com/lightspeedwp/.github/issues/1227), [#1310](https://github.com/lightspeedwp/.github/issues/1310)) ### Deprecated (none identified) + +### Fixed + +- **Agent file_type frontmatter validation (Phase 1 restructuring)** — Added missing `file_type` frontmatter to all root agent configuration files: 48 provider-specific agent.md files (claude/, copilot/, openai/) with `file_type: 'agent'`, and 16 shared core-prompt.md files with `file_type: 'prompt'`. Fixes 200+ frontmatter validation errors and ensures all agent files comply with documentation schema requirements. ([PR #1533](https://github.com/lightspeedwp/.github/pull/1533), [#1510](https://github.com/lightspeedwp/.github/issues/1510), [#1507](https://github.com/lightspeedwp/.github/issues/1507)) + ### Added - **Gitleaks secret scanning** — Added `gitleaks-reusable.yml`, an organisation-wide reusable workflow other repositories call via `workflow_call`, plus a `gitleaks.yml` caller running on pull requests into `develop`/`main`. Runs the open-source Gitleaks CLI directly (the `gitleaks-action` wrapper requires a paid licence for organisation repositories). Per-PR runs scan the working tree; `workflow_dispatch` accepts a `full-history` input for on-demand full-history rescans. A baseline full-history scan of this repository returned 50 hits, all verified as placeholder values in documentation and tests, allowlisted in `.gitleaks.toml`. ([PR #1444](https://github.com/lightspeedwp/.github/pull/1444)) diff --git a/scripts/validation/__tests__/validate-labels-before-creation.test.cjs b/scripts/validation/__tests__/validate-labels-before-creation.test.cjs new file mode 100644 index 000000000..31f35da03 --- /dev/null +++ b/scripts/validation/__tests__/validate-labels-before-creation.test.cjs @@ -0,0 +1,251 @@ +/** + * Unit Tests: validate-labels-before-creation.cjs + * + * Test suite for pre-creation label validation script. + * Validates: + * 1. Canonical label existence + * 2. Family prefix requirements + * 3. One-hot per family constraint + * 4. Required type: label + * 5. Error and warning messages + */ + +const { execSync } = require('child_process'); +const path = require('path'); + +const SCRIPT_PATH = path.join(__dirname, '../validate-labels-before-creation.cjs'); +const LABELS_FILE = path.join(__dirname, '../../../.github/labels.yml'); + +/** + * Execute validation script and parse output + * @param {string[]} labels - Labels to validate + * @returns {object} Parsed result + */ +function validateLabels(labels) { + const labelStr = labels.join(','); + try { + execSync( + `node ${SCRIPT_PATH} --labels "${labelStr}" --canonical-file ${LABELS_FILE}`, + { stdio: 'pipe' } + ); + return { valid: true, errors: [], warnings: [] }; + } catch (error) { + // Extract JSON from stderr + const stderr = error.stderr.toString(); + const jsonMatch = stderr.match(/\{[\s\S]*\}/); + if (jsonMatch) { + return JSON.parse(jsonMatch[0]); + } + return { valid: false, errors: [error.message], warnings: [] }; + } +} + +// ============================================================================ +// Test Suite +// ============================================================================ + +describe('Label Validation', () => { + describe('Valid Labels', () => { + test('accepts canonical type:bug label', () => { + const result = validateLabels(['type:bug']); + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + }); + + test('accepts full canonical label set', () => { + const result = validateLabels([ + 'type:bug', + 'status:needs-triage', + 'priority:critical', + 'area:ci' + ]); + expect(result.valid).toBe(true); + expect(result.errors.length).toBe(0); + }); + + test('accepts all type:* variants', () => { + const types = [ + 'type:bug', + 'type:feature', + 'type:task', + 'type:documentation', + 'type:design', + 'type:refactor', + 'type:chore' + ]; + + for (const type of types) { + const result = validateLabels([type]); + expect(result.valid).toBe(true); + } + }); + + test('accepts multiple meta: labels (allowed exception)', () => { + const result = validateLabels([ + 'type:bug', + 'meta:needs-changelog', + 'meta:has-pr' + ]); + expect(result.valid).toBe(true); + }); + }); + + describe('Bare Labels (Invalid)', () => { + test('rejects bare "bug" label', () => { + const result = validateLabels(['bug']); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('bug'))).toBe(true); + }); + + test('rejects bare "feature" label', () => { + const result = validateLabels(['feature']); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('feature'))).toBe(true); + }); + + test('rejects all common bare labels', () => { + const bareLabels = [ + 'bug', + 'feature', + 'task', + 'documentation', + 'urgent', + 'critical', + 'ci', + 'docs', + 'release', + 'automation' + ]; + + for (const bare of bareLabels) { + const result = validateLabels([bare]); + expect(result.valid).toBe(false); + } + }); + + test('detects bare labels in mixed set', () => { + const result = validateLabels(['type:bug', 'feature', 'status:needs-triage']); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('feature'))).toBe(true); + }); + }); + + describe('Non-Existent Labels', () => { + test('rejects unknown label', () => { + const result = validateLabels(['type:unknown']); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('not found'))).toBe(true); + }); + + test('rejects completely made-up label', () => { + const result = validateLabels(['invalid:label']); + expect(result.valid).toBe(false); + }); + }); + + describe('One-Hot Constraint (One per Family)', () => { + test('rejects multiple type: labels', () => { + const result = validateLabels(['type:bug', 'type:feature']); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Multiple labels'))).toBe(true); + }); + + test('rejects multiple status: labels', () => { + const result = validateLabels([ + 'type:bug', + 'status:needs-triage', + 'status:in-progress' + ]); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes('Multiple labels'))).toBe(true); + }); + + test('rejects multiple priority: labels', () => { + const result = validateLabels([ + 'type:bug', + 'priority:critical', + 'priority:important' + ]); + expect(result.valid).toBe(false); + }); + + test('allows multiple meta: labels (exception)', () => { + const result = validateLabels([ + 'type:bug', + 'meta:needs-changelog', + 'meta:has-pr', + 'meta:duplicate' + ]); + expect(result.valid).toBe(true); + }); + + test('allows multiple comp: labels (exception)', () => { + const result = validateLabels([ + 'type:feature', + 'comp:block-editor', + 'comp:theme-json' + ]); + expect(result.valid).toBe(true); + }); + }); + + describe('Required type: Label', () => { + test('requires at least one type: label', () => { + const result = validateLabels(['status:needs-triage', 'priority:critical']); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes("Missing required 'type:*'"))).toBe(true); + }); + + test('passes with any type: variant', () => { + const types = [ + 'type:bug', + 'type:feature', + 'type:task', + 'type:documentation' + ]; + + for (const type of types) { + const result = validateLabels([type]); + expect(result.valid).toBe(true); + } + }); + }); + + describe('Warnings', () => { + test('warns about bare label "bug"', () => { + const result = validateLabels(['bug']); + expect(result.warnings.some(w => w.includes('Bare label'))).toBe(true); + }); + + test('suggests corrections for bare labels', () => { + const result = validateLabels(['bug']); + expect(result.warnings.some(w => w.includes('type:bug'))).toBe(true); + }); + }); + + describe('Edge Cases', () => { + test('handles empty label list', () => { + const result = validateLabels([]); + expect(result.valid).toBe(false); + expect(result.errors.some(e => e.includes("Missing required 'type:*'"))).toBe(true); + }); + + test('ignores whitespace in labels', () => { + const result = validateLabels(['type:bug ', ' status:needs-triage']); + // Script should handle this gracefully + expect(result).toHaveProperty('valid'); + }); + + test('handles very long label list', () => { + const labels = [ + 'type:feature', + 'status:ready', + 'priority:normal', + 'area:ci', + 'meta:needs-changelog' + ]; + const result = validateLabels(labels); + expect(result.valid).toBe(true); + }); + }); +}); diff --git a/scripts/validation/validate-labels-before-creation.cjs b/scripts/validation/validate-labels-before-creation.cjs new file mode 100644 index 000000000..92d1122dd --- /dev/null +++ b/scripts/validation/validate-labels-before-creation.cjs @@ -0,0 +1,255 @@ +#!/usr/bin/env node +/** + * Pre-Creation Label Validation Script + * + * Validates labels before issue/PR creation to enforce canonical label system. + * Ensures all labels: + * 1. Exist in canonical set (.github/labels.yml) + * 2. Include required family prefix (type:, status:, priority:, etc.) + * 3. Follow one-hot principle per family (except meta:, comp: which allow multiple) + * 4. Always include a type:* label for classification + * + * Usage: + * node validate-labels-before-creation.cjs \ + * --labels "type:bug,status:needs-triage" \ + * --canonical-file .github/labels.yml + * + * Exit Codes: + * 0 = validation passed + * 1 = validation failed + */ + +const fs = require('fs'); +const path = require('path'); +const yaml = require('js-yaml'); + +// ============================================================================ +// Constants +// ============================================================================ + +const FAMILIES_ALLOW_MULTIPLE = ['meta', 'comp', 'lang']; +const REQUIRED_FAMILIES = ['type']; + +// ============================================================================ +// Argument Parsing +// ============================================================================ + +function parseArgs() { + const args = process.argv.slice(2); + const opts = { + labels: [], + canonical_file: '.github/labels.yml' + }; + + for (let i = 0; i < args.length; i++) { + if (args[i] === '--labels' && i + 1 < args.length) { + opts.labels = args[i + 1].split(',').map(l => l.trim()).filter(Boolean); + i++; + } else if (args[i] === '--canonical-file' && i + 1 < args.length) { + opts.canonical_file = args[i + 1]; + i++; + } + } + + return opts; +} + +// ============================================================================ +// Label Loading +// ============================================================================ + +/** + * Load canonical labels from YAML file + * @param {string} filePath - Path to labels.yml + * @returns {Map} Map of label name → label metadata + */ +function loadCanonicalLabels(filePath) { + try { + const content = fs.readFileSync(filePath, 'utf-8'); + const data = yaml.load(content, { schema: yaml.JSON_SCHEMA }); + + if (!Array.isArray(data)) { + throw new Error('labels.yml must contain an array of label objects'); + } + + const labels = new Map(); + for (const label of data) { + if (label.name) { + labels.set(label.name, label); + } + } + + return labels; + } catch (error) { + throw new Error(`Failed to load canonical labels: ${error.message}`); + } +} + +// ============================================================================ +// Validation Logic +// ============================================================================ + +/** + * Extract family prefix from label (part before colon) + * @param {string} label - Label name (e.g., "type:bug") + * @returns {string} Family name or null if no prefix + */ +function getFamily(label) { + const match = label.match(/^([a-z]+):/); + return match ? match[1] : null; +} + +/** + * Validate labels against canonical set + * @param {string[]} labels - List of labels to validate + * @param {Map} canonicalLabels - Map of valid labels + * @returns {object} { valid: boolean, errors: string[], warnings: string[] } + */ +function validateLabels(labels, canonicalLabels) { + const errors = []; + const warnings = []; + const familyCount = new Map(); + + // ---- Rule 1: Each label must exist in canonical set ---- + for (const label of labels) { + if (!label || label.trim() === '') continue; + + if (!canonicalLabels.has(label)) { + errors.push(`Label '${label}' not found in canonical set (.github/labels.yml)`); + } + } + + // ---- Rule 2: Each label must have family prefix ---- + for (const label of labels) { + if (!label || label.trim() === '') continue; + + const family = getFamily(label); + if (!family) { + errors.push( + `Label '${label}' missing required family prefix. ` + + `Use one of: type:, status:, priority:, area:, meta:, release:, lang:, env:, compat:, comp:` + ); + } else { + // Track family usage for one-hot validation + if (!familyCount.has(family)) { + familyCount.set(family, []); + } + familyCount.get(family).push(label); + } + } + + // ---- Rule 3: One-hot per family (except meta:, comp:, lang:) ---- + for (const [family, familyLabels] of familyCount) { + if (FAMILIES_ALLOW_MULTIPLE.includes(family)) { + continue; // These families allow multiple labels + } + + if (familyLabels.length > 1) { + errors.push( + `Multiple labels from family '${family}' found: ${familyLabels.join(', ')}. ` + + `Only one label per family is allowed (except ${FAMILIES_ALLOW_MULTIPLE.join(', ')}).` + ); + } + } + + // ---- Rule 4: Type label is required ---- + const hasType = labels.some(label => getFamily(label) === 'type'); + if (!hasType) { + errors.push( + `Missing required 'type:*' label for classification. ` + + `Examples: type:bug, type:feature, type:task, type:documentation` + ); + } + + // ---- Rule 5: Warnings for common mistakes ---- + const bareLabels = [ + 'bug', 'feature', 'task', 'documentation', 'design', 'refactor', + 'urgent', 'critical', 'important', 'normal', + 'ci', 'docs', 'security', 'tests', 'labels', + 'release', 'automation' + ]; + + for (const label of labels) { + if (bareLabels.includes(label)) { + const family = getFamily(label); + warnings.push( + `Bare label '${label}' detected. ` + + `This is not the canonical form. Did you mean 'type:${label}' or 'priority:${label}' or 'area:${label}'?` + ); + } + } + + return { + valid: errors.length === 0, + errors, + warnings + }; +} + +// ============================================================================ +// Output Formatting +// ============================================================================ + +/** + * Format validation results for output + * @param {object} result - Validation result + * @returns {string} Formatted output + */ +function formatOutput(result) { + let output = ''; + + if (result.valid) { + output += '✅ Label validation passed\n'; + } else { + output += '❌ Label validation failed:\n\n'; + for (const error of result.errors) { + output += ` ❌ ${error}\n`; + } + output += '\n'; + } + + if (result.warnings.length > 0) { + output += '⚠️ Warnings:\n'; + for (const warning of result.warnings) { + output += ` ⚠️ ${warning}\n`; + } + } + + return output; +} + +// ============================================================================ +// Main +// ============================================================================ + +function main() { + const opts = parseArgs(); + + try { + // Load canonical labels + const canonicalLabels = loadCanonicalLabels(opts.canonical_file); + + // Validate input labels + const result = validateLabels(opts.labels, canonicalLabels); + + // Output results + console.log(formatOutput(result)); + + // Output JSON for machine parsing (on stderr) + console.error(JSON.stringify({ + valid: result.valid, + labels_count: opts.labels.length, + canonical_labels_count: canonicalLabels.size, + errors: result.errors, + warnings: result.warnings + }, null, 2)); + + // Exit with appropriate code + process.exit(result.valid ? 0 : 1); + } catch (error) { + console.error(`❌ Validation error: ${error.message}`); + process.exit(1); + } +} + +main();