diff --git a/.github/specs/003-changelog-quality-audit/checklists/requirements.md b/.github/specs/003-changelog-quality-audit/checklists/requirements.md index 32f403618a..dad78e0e5d 100644 --- a/.github/specs/003-changelog-quality-audit/checklists/requirements.md +++ b/.github/specs/003-changelog-quality-audit/checklists/requirements.md @@ -52,10 +52,11 @@ ## Feature Readiness -- [x] All functional requirements have clear acceptance criteria (FR-1 through FR-6) +- [x] All functional requirements have clear acceptance criteria (FR-1 through FR-7, including release agent integration) - [x] User scenarios cover primary flows (maintainer review, developer submission, release manager, stakeholder) - [x] Feature meets measurable outcomes defined in Success Criteria (9 specific success criteria listed) - [x] No implementation details leak into specification +- [x] Edge cases and failure handling documented (deleted PRs, API unavailability, concurrent edits, multiple issue links) ## Integration & Cross-Project Alignment @@ -75,14 +76,24 @@ ## Summary -**Result:** ✅ **SPECIFICATION VALIDATED & READY FOR PLANNING** +**Result:** ✅ **SPECIFICATION CLARIFIED & READY FOR PLANNING** -**Validation Findings:** +**Clarifications Applied (Session 2026-09-17):** -- All 8 quality dimension criteria met -- 50+ requirements defined across 6 functional areas -- 9 success criteria are specific, measurable, and technology-agnostic -- 4 user scenarios cover primary flows with acceptance criteria +5 critical ambiguities resolved: + +1. Validation feedback: GitHub PR check annotations +2. Implementation details: Code-specific terms only (not architectural verbs) +3. Release agent integration: Automatic validation gate (blocks if <95% compliance) +4. Edge case handling: Deleted PRs flag as warnings (non-blocking) +5. Consolidation scope: 5 existing validation scripts identified from `.github/workflows/` and `scripts/validation/` + +**Enhanced Specification:** + +- 7 functional requirements (FR-1 through FR-7, including release agent integration) +- Explicit edge case handling section (4 scenarios: deleted PRs, concurrent edits, API unavailability, multiple issue links) +- Clear success criteria: 95%+ compliance, 0 code-specific details, 100% PR link accuracy +- 4 user scenarios with detailed acceptance criteria - Clear scope boundaries and out-of-scope items **Ready for:** `/speckit-plan` to generate week-by-week implementation plan diff --git a/.github/specs/003-changelog-quality-audit/research.md b/.github/specs/003-changelog-quality-audit/research.md index fd84f4f832..be1e78ff21 100644 --- a/.github/specs/003-changelog-quality-audit/research.md +++ b/.github/specs/003-changelog-quality-audit/research.md @@ -53,7 +53,7 @@ --- -### Q2: Implementation Detail Detection +### Q2: Implementation Detail Detection (REVISED per Clarification Session 2026-09-17) **Question**: How to reliably identify "implementation details" without manual review? @@ -67,18 +67,20 @@ - Manual audit identified 47 entries with implementation details - Keyword matching has ~85% precision (some false positives on user-facing terms) -**Decision**: **Hybrid approach: keyword detection + manual review for edge cases** +**Decision**: **Code-specific keyword detection only (architectural verbs permitted)** -- Rationale: Keyword-based detection catches 85% of violations; CI flags borderline cases for human review +- Clarification applied: Only code-specific terms flagged; architectural verbs (refactored, optimised, deployed) are user-focused language and are PERMITTED +- Rationale: Keyword-based detection of code-specific terms catches implementation details; architectural verbs are acceptable user-friendly language - Alternatives considered: - - Pure keyword matching (rejected: ~15% false positive/negative rate too high) + - Pure keyword matching with all implementation keywords (rejected: too restrictive; eliminates user-friendly language) - Full NLP analysis (rejected: computational cost, GitHub Actions duration constraints) - Manual review only (rejected: doesn't scale to 200+ entries, defeats automation goal) **Implementation**: -- Banned keywords: `refactored`, `fixed`, `updated`, `patched`, `optimised`, `optimized`, `implemented`, `deployed`, `migrated`, `restructured`, `reorganised`, `reorganized`, `logic`, `algorithm`, `framework`, `component`, `module`, `hook`, `middleware`, `REST API`, `GraphQL`, `database`, `query`, `cache`, `transaction` -- Flagged for review: Terms with context-dependent meaning (requires human judgment) +- Flagged keywords (code-specific only): `function`, `method`, `class`, `variable`, `module`, `middleware`, `hook`, `component`, `REST API`, `GraphQL`, `database`, `query`, `cache`, `transaction`, `endpoint` +- PERMITTED architectural verbs: `refactored`, `optimised`, `optimized`, `deployed`, `migrated`, `restructured`, `reorganised`, `reorganized`, `fixed`, `updated`, `changed`, `implemented`, `patched` +- Rationale: Code-specific terms are internal details; architectural verbs describe what was improved from user perspective --- diff --git a/.github/specs/003-changelog-quality-audit/spec.md b/.github/specs/003-changelog-quality-audit/spec.md index 58c65695c6..516c374ff6 100644 --- a/.github/specs/003-changelog-quality-audit/spec.md +++ b/.github/specs/003-changelog-quality-audit/spec.md @@ -45,6 +45,18 @@ --- +## Clarifications + +### Session 2026-09-17 + +- Q: Where should changelog entry validation feedback be displayed to help maintainers assess compliance before release? → A: GitHub pull request check annotations (red/yellow badges in PR checks tab with detailed failure reasons). +- Q: Which terms should be flagged as "implementation details" when they appear in changelog entries? → A: Only code-specific terms: function/method names, class names, REST API, GraphQL, database, query, cache, transaction, endpoint. Architectural verbs (refactored, optimised, deployed, etc.) are permitted as user-focused language. +- Q: Should the release agent automatically validate and enforce changelog compliance before creating a release? → A: Automatic validation gate: release agent validates changelog compliance and blocks release if compliance < 95%. +- Q: If a PR referenced in a changelog entry is deleted, should validation fail, warn, or pass? → A: Warn (non-blocking): deleted PRs flagged for manual review, but release can proceed if needed. +- Q: Where do the 5+ existing validation scripts live and what do they check? → A: Scripts live in `.github/workflows/` and `scripts/validation/`; consolidation covers: (1) changelog file existence, (2) [Unreleased] section structure validation, (3) entry format/list validation, (4) PR link detection, (5) length checks. + +--- + ## User Scenarios & Acceptance ### Scenario 1: Maintainer Reviews Changelog Entry @@ -68,12 +80,13 @@ **Flow:** 1. Developer creates PR with changelog entry -2. CI validation gate runs (7-layer validation system) -3. Entry is checked for: length, format, PR link, implementation details -4. If compliant: CI passes, PR proceeds -5. If non-compliant: CI fails with specific actionable feedback +2. CI validation gate runs automatically +3. Entry is checked for: length (≤250 chars), format, PR link presence, implementation details +4. Validation result appears as **GitHub PR check annotation** (red/yellow badge in PR checks tab) +5. If compliant: Check passes (green), PR proceeds; if non-compliant: Check fails (red) with specific, actionable error messages +6. Developer reads failure details directly in PR checks tab and refactors entry locally -**Acceptance:** Developers receive clear, actionable failure messages; 95%+ of entries pass on first submission after Phase 5. +**Acceptance:** Developers receive clear, actionable failure messages in PR UI; 95%+ of entries pass on first submission after Phase 5. ### Scenario 3: Release Manager Generates Release Notes @@ -113,20 +126,21 @@ - **Standards enforced:** - Maximum length: 250 characters (user-facing summary, no internal details) - Presence: PR link (GitHub URL format) - - Content: No implementation details (no code snippets, framework names, API internals) + - Content: No code-specific implementation details (no code snippets, function/method names, class names, API internals like "REST API", "GraphQL", "database", "query", "cache", "transaction", "endpoint") - Format: Consistent punctuation and tense - **Assessment output:** Pass/fail status per entry, specific violation list -- **Testable:** Validator script must flag entries exceeding 250 chars; script must identify implementation keywords (e.g., "refactored", "fixed", "added logic", "updated database") +- **Testable:** Validator script must flag entries exceeding 250 chars; script must identify code-specific keywords (e.g., "function", "class", "database query", "REST API endpoint") but permit architectural verbs (e.g., "refactored", "optimised", "deployed") ### FR-2: Automated Enforcement Gates -- **Requirement:** CI/CD validation gates must block PRs with non-compliant changelog entries +- **Requirement:** CI/CD validation gates must block PRs with non-compliant changelog entries and display results as GitHub PR check annotations - **Gate behavior:** - Triggers on any PR targeting `develop` or `main` if CHANGELOG.md is modified - - Validates all [Unreleased] entries (both existing and new) - - Provides pass/fail verdict and specific failure reasons + - Validates all [Unreleased] entries (both existing and new) against all quality standards + - **Output format:** GitHub PR check (red/yellow badge) with detailed violation list per entry + - Provides clear, actionable failure reasons (character count, missing links, detected keywords) - Does NOT block PRs from branches lacking changelog entries (configuration option) -- **Testable:** CI must reject PR with 300-char entry; CI must approve PR with 250-char compliant entry +- **Testable:** PR with 300-char entry displays red check with "ENTRY_TOO_LONG" violation; PR with 250-char compliant entry displays green check ### FR-3: Auto-Linking Automation @@ -154,11 +168,12 @@ - **Requirement:** Existing changelog validation workflows must be consolidated into a single, maintainable system - **Consolidation scope:** - - Merge 5+ separate validation scripts into unified pipeline - - Establish single source of truth for validation rules + - Merge 5+ separate validation scripts from `.github/workflows/` and `scripts/validation/` into unified pipeline + - Existing checks being consolidated: (1) changelog file existence, (2) [Unreleased] section structure, (3) entry format/list, (4) PR link detection, (5) length validation + - Establish single source of truth for validation rules (configuration-driven, not hardcoded) - Remove redundant checks and conflicting rule sets - **Migration:** Existing workflows remain functional during transition; no service interruption -- **Testable:** All existing validation behavior preserved; new single pipeline passes 100% of previous tests +- **Testable:** All existing validation behavior preserved; new unified pipeline passes 100% of previous checks; no false positives/negatives introduced ### FR-6: Team Training & Documentation @@ -170,12 +185,23 @@ - **Training delivery:** Live Q&A session (targeted 90%+ attendance); recorded session available - **Testable:** Post-training assessment shows 85%+ understanding of compliance standards +### FR-7: Release Agent Integration + +- **Requirement:** Release agent must validate changelog compliance as an automated prerequisite gate before release +- **Integration scope:** + - Release workflow calls changelog validation before version bump + - Blocks release if compliance < 95% + - Provides clear feedback on validation failure with specific entry violations + - Allows release manager to override block with documented exception (if needed) +- **Workflow:** Release manager triggers release → agent validates changelog → if compliant (≥95%), proceeds to version bump; if non-compliant (<95%), blocks with detailed failure report +- **Testable:** Release with 94% compliant entries blocked with detailed violation list; release with 95%+ compliant entries proceeds + --- ## Success Criteria 1. **Quality Compliance:** 95%+ of changelog entries meet all quality standards (length, format, content, links) -2. **Zero Implementation Details:** 0 entries detected with implementation jargon or internal details +2. **Zero Code-Specific Details:** 0 entries detected with code-specific jargon (function/method names, class names, REST API, GraphQL, database references, etc.); architectural verbs permitted 3. **Automated Linking:** 100% of PR references auto-linked with 99.9% link accuracy 4. **CI Enforcement:** 100% of non-compliant entries blocked by CI validation gate; no false positives 5. **Metrics Accuracy:** Dashboard metrics within 1% of manual audit results @@ -253,6 +279,35 @@ --- +## Edge Cases & Failure Handling + +### Deleted PR References + +- **Scenario:** Changelog entry references PR #1234, but PR is subsequently deleted +- **Behavior:** Validation flags as warning (non-blocking); appears in validation report for release manager review +- **Release impact:** Release proceeds even with deleted PR warning; release manager must acknowledge and document reason +- **Recovery:** Release notes can still reference the PR number; link will be dead but change is documented + +### Concurrent CHANGELOG.md Edits + +- **Scenario:** Multiple contributors edit CHANGELOG.md simultaneously +- **Behavior:** Git merge conflict resolution handles via standard workflow; validation re-runs after merge +- **Release impact:** Release blocked until conflict resolved and entries re-validated post-merge + +### GitHub API Unavailability + +- **Scenario:** GitHub API is down during release workflow +- **Behavior:** Link validation degrades to local format check only (validates PR format, skips link verification) +- **Release impact:** Release proceeds with reduced validation (format checks pass); link verification deferred to post-release audit + +### Entry with Multiple Issue Links + +- **Scenario:** Changelog entry references multiple issues (#123, #456, #789) +- **Behavior:** All issue links validated independently; warnings/errors per link status +- **Release impact:** Entry passes if all required links are valid; optional issue links can be dead (warning only) + +--- + ## Non-Functional Requirements ### Performance @@ -284,7 +339,8 @@ ## Related Projects & Dependencies **Related:** Label Governance Audit (2026-08-05) — coordinates with labeling rules for categorization -**Depends on:** Phase 4 deliverables (PR-to-changelog linking, maintainer review checklist) +**Depends on:** Phase 4 deliverables (PR-to-changelog linking, maintainer review checklist); Release Agent Phase 2 (integration with changelog validation) +**Integrates with:** Release Agent (`agents/release/release.agent.js`) — release workflow validates changelog compliance as automated gate **Epic:** #1271 — Changelog Automation Hardening --- diff --git a/.github/specs/003-changelog-quality-audit/tasks.md b/.github/specs/003-changelog-quality-audit/tasks.md index e94c3a2f63..fc662da1cf 100644 --- a/.github/specs/003-changelog-quality-audit/tasks.md +++ b/.github/specs/003-changelog-quality-audit/tasks.md @@ -43,13 +43,13 @@ **Purpose**: Project initialization and validation framework foundation -- [ ] T001 Create validation framework directory structure at `.github/validation/changelog/` -- [ ] T002 Initialize changelog validation configuration file at `.github/validation/changelog/config.yml` -- [ ] T003 [P] Create validation rules definition at `.github/validation/changelog/rules.json` (8 built-in rules per validation-rule.contract.md) -- [ ] T004 [P] Setup metrics storage schema at `.github/validation/changelog/metrics-schema.json` -- [ ] T005 Create GitHub Actions workflow trigger at `.github/workflows/changelog-validation.yml` -- [ ] T006 [P] Setup Node.js project for validation scripts at `.github/validation/changelog/package.json` -- [ ] T007 [P] Initialize Bash validation script at `.github/validation/changelog/validate.sh` +- [x] T001 Create validation framework directory structure at `.github/validation/changelog/` +- [x] T002 Initialize changelog validation configuration file at `.github/validation/changelog/config.yml` +- [x] T003 [P] Create validation rules definition at `.github/validation/changelog/rules.json` (8 built-in rules per validation-rule.contract.md) +- [x] T004 [P] Setup metrics storage schema at `.github/validation/changelog/metrics-schema.json` +- [x] T005 Create GitHub Actions workflow trigger at `.github/workflows/changelog-validation.yml` +- [x] T006 [P] Setup Node.js project for validation scripts at `.github/validation/changelog/package.json` +- [x] T007 [P] Initialize Bash validation script at `.github/validation/changelog/validate.sh` --- diff --git a/.github/validation/changelog/bin/validate.js b/.github/validation/changelog/bin/validate.js new file mode 100644 index 0000000000..df85422fe3 --- /dev/null +++ b/.github/validation/changelog/bin/validate.js @@ -0,0 +1,359 @@ +#!/usr/bin/env node + +import yargs from 'yargs'; +import { hideBin } from 'yargs/helpers'; +import ChangelogParser from '../lib/parser.js'; +import ComplianceChecker from '../lib/compliance-checker.js'; +import MetricsAggregator from '../lib/metrics-aggregator.js'; +import CIContext from '../lib/ci-context.js'; +import fs from 'fs'; + +const argv = yargs(hideBin(process.argv)) + .option('changelog-path', { + alias: 'p', + describe: 'Path to CHANGELOG.md file', + type: 'string', + default: 'CHANGELOG.md', + }) + .option('trigger', { + alias: 't', + describe: 'Validation trigger type', + type: 'string', + choices: ['manual', 'pr_submission', 'scheduled_audit'], + default: 'manual', + }) + .option('pr-number', { + describe: 'GitHub PR number (for pr_submission trigger)', + type: 'number', + }) + .option('branch', { + describe: 'Git branch name', + type: 'string', + }) + .option('github-token', { + describe: 'GitHub API token for link validation', + type: 'string', + }) + .option('output', { + alias: 'o', + describe: 'Output format', + type: 'string', + choices: ['text', 'json'], + default: 'text', + }) + .option('verbose', { + alias: 'v', + describe: 'Enable verbose output', + type: 'boolean', + default: false, + }) + .option('metrics', { + alias: 'm', + describe: 'Save metrics snapshot', + type: 'boolean', + default: false, + }) + .option('metrics-path', { + describe: 'Path to metrics.json file', + type: 'string', + }) + .help() + .alias('help', 'h') + .parseSync(); + +async function main() { + try { + const changelogPath = argv['changelog-path']; + const trigger = argv.trigger; + const outputFormat = argv.output; + const verbose = argv.verbose; + const shouldSaveMetrics = argv.metrics; + const metricsPath = argv['metrics-path']; + + // Detect CI context + const ciContext = new CIContext(process.env); + if (verbose) { + console.error('CI Context:', JSON.stringify(ciContext.getSummary(), null, 2)); + } + + // Validate changelog file exists + if (!fs.existsSync(changelogPath)) { + const error = `CHANGELOG.md not found at ${changelogPath}`; + if (outputFormat === 'json') { + console.log( + JSON.stringify( + { error, exit_code: 1, trigger, ci_context: ciContext.getSummary() }, + null, + 2 + ) + ); + } else { + console.error(`✗ ${error}`); + } + process.exit(1); + } + + // Validate [Unreleased] section exists + const changelogContent = fs.readFileSync(changelogPath, 'utf-8'); + if (!changelogContent.includes('[Unreleased]')) { + const error = '[Unreleased] section not found in CHANGELOG.md'; + if (outputFormat === 'json') { + console.log( + JSON.stringify( + { error, exit_code: 1, trigger, ci_context: ciContext.getSummary() }, + null, + 2 + ) + ); + } else { + console.error(`✗ ${error}`); + } + process.exit(1); + } + + if (verbose) { + console.error(`Parsing changelog from ${changelogPath}...`); + } + + // Parse changelog + const parser = new ChangelogParser(changelogPath); + const parseResult = parser.parseChangelog(); + + if (parseResult.errors && parseResult.errors.length > 0) { + if (outputFormat === 'json') { + console.log( + JSON.stringify( + { + error: 'Failed to parse changelog', + errors: parseResult.errors, + exit_code: 1, + trigger, + ci_context: ciContext.getSummary(), + }, + null, + 2 + ) + ); + } else { + console.error('✗ Failed to parse changelog'); + parseResult.errors.forEach((err) => console.error(` - ${err}`)); + } + process.exit(1); + } + + const entries = parseResult.entries || []; + + if (entries.length === 0) { + const warning = 'No changelog entries found in [Unreleased] section'; + if (outputFormat === 'json') { + console.log( + JSON.stringify( + { + summary: { + total_entries: 0, + passed: 0, + failed: 0, + pass_rate: 0, + }, + ci_gate_result: 'pass', + trigger, + ci_context: ciContext.getSummary(), + recommendation: 'no_action_needed', + warning, + }, + null, + 2 + ) + ); + } else { + console.log(`⚠ ${warning}`); + } + process.exit(0); + } + + if (verbose) { + console.error(`Found ${entries.length} changelog entries`); + console.error('Validating entries against rules...'); + } + + // Check entries for compliance + const checker = new ComplianceChecker(); + const validationContext = { + githubToken: argv['github-token'] || process.env.GITHUB_TOKEN, + allEntries: entries, + validateLinks: false, + }; + + const validationResult = await checker.checkEntries(entries, validationContext); + + if (verbose) { + console.error( + `Validation complete: ${validationResult.passed} of ${validationResult.total_entries} entries passed` + ); + } + + // Prepare report + const report = { + summary: { + total_entries: validationResult.total_entries, + passed: validationResult.passed, + failed: validationResult.failed, + pass_rate: validationResult.pass_rate, + }, + validations: validationResult.validations || [], + trigger, + ci_context: ciContext.getSummary(), + timestamp: new Date().toISOString(), + }; + + // Determine gate result + const passRate = parseFloat(validationResult.pass_rate); + let gateResult; + let recommendation; + + if (passRate === 100) { + gateResult = 'pass'; + recommendation = 'approved'; + } else if (passRate >= 90) { + gateResult = 'warning'; + recommendation = 'review'; + } else if (passRate >= 75) { + gateResult = 'warning'; + recommendation = 'review_required'; + } else { + gateResult = 'fail'; + recommendation = 'blocked'; + } + + report.ci_gate_result = gateResult; + report.recommendation = recommendation; + + // Save metrics if requested + if (shouldSaveMetrics) { + const aggregator = new MetricsAggregator(metricsPath); + const snapshot = aggregator.aggregateValidation(validationResult); + const metricsSaveResult = aggregator.saveMetrics(snapshot); + + if (verbose) { + console.error('Metrics saved:', JSON.stringify(metricsSaveResult, null, 2)); + } + + report.metrics_saved = metricsSaveResult.success; + if (metricsSaveResult.snapshot_id) { + report.snapshot_id = metricsSaveResult.snapshot_id; + } + } + + // Output report + if (outputFormat === 'json') { + console.log(JSON.stringify(report, null, 2)); + } else { + outputTextReport(report); + } + + // Exit with appropriate code + const exitCode = gateResult === 'pass' || gateResult === 'warning' ? 0 : 1; + process.exit(exitCode); + } catch (error) { + const message = error.message || String(error); + + if (argv.output === 'json') { + console.log( + JSON.stringify( + { + error: 'Unexpected error during validation', + message, + exit_code: 1, + trigger: argv.trigger, + }, + null, + 2 + ) + ); + } else { + console.error('✗ Validation failed with error:'); + console.error(` ${message}`); + } + + if (argv.verbose) { + console.error(error.stack); + } + + process.exit(1); + } +} + +function outputTextReport(report) { + const { summary, ci_gate_result: gateResult, validations, recommendation } = report; + + console.log(''); + console.log('════════════════════════════════════════'); + console.log(' CHANGELOG VALIDATION REPORT'); + console.log('════════════════════════════════════════'); + console.log(''); + + // Summary stats + console.log(`Total Entries: ${summary.total_entries}`); + console.log(`Compliant: ${summary.passed} (${summary.pass_rate}%)`); + console.log(`Non-Compliant: ${summary.failed}`); + console.log(''); + + // Gate result + const gateIcon = gateResult === 'pass' ? '✓' : gateResult === 'warning' ? '⚠' : '✗'; + const gateStatus = gateResult === 'pass' ? 'PASS' : gateResult === 'warning' ? 'WARNING' : 'FAIL'; + + console.log(`Gate Result: ${gateIcon} ${gateStatus}`); + console.log(`Recommendation: ${recommendation}`); + console.log(''); + + // Violations summary + if (validations && validations.length > 0) { + const violationsByRule = {}; + + for (const validation of validations) { + if (validation.violations && validation.violations.length > 0) { + for (const violation of validation.violations) { + if (!violationsByRule[violation.rule_id]) { + violationsByRule[violation.rule_id] = { + count: 0, + severity: violation.severity, + examples: [], + }; + } + + violationsByRule[violation.rule_id].count++; + + if (violationsByRule[violation.rule_id].examples.length < 2) { + violationsByRule[violation.rule_id].examples.push({ + entry: validation.entry_id, + message: violation.message, + }); + } + } + } + } + + if (Object.keys(violationsByRule).length > 0) { + console.log('Issues Found:'); + console.log('─────────────────────────────────────'); + + for (const [ruleId, info] of Object.entries(violationsByRule)) { + console.log(`${ruleId} [${info.severity.toUpperCase()}]: ${info.count} violation(s)`); + + for (const example of info.examples) { + console.log(` → ${example.message}`); + } + } + + console.log(''); + } + } + + console.log('════════════════════════════════════════'); + console.log(''); +} + +main().catch((error) => { + console.error('Fatal error:', error.message); + process.exit(1); +}); diff --git a/.github/validation/changelog/config.yml b/.github/validation/changelog/config.yml new file mode 100644 index 0000000000..a3bc71c060 --- /dev/null +++ b/.github/validation/changelog/config.yml @@ -0,0 +1,59 @@ +# Changelog Validation Configuration +# Framework: Keep a Changelog 1.1.0 format enforcement +# Phase: 5 Implementation + +validation: + enabled: true + block_on_failure: true + allow_missing_changelog: false + report_format: github-check + +changelog: + path: CHANGELOG.md + version_section: '[Unreleased]' + max_entry_length: 250 + require_pr_link: true + +rules: + enabled: + - CHK_MAX_LENGTH + - CHK_NO_IMPL_DETAILS + - CHK_HAS_PR_LINK + - CHK_FORMAT_MARKDOWN + - CHK_CONSISTENT_TENSE + - CHK_NO_ABBREVIATIONS + - CHK_UNIQUE_CONTENT + - CHK_LINK_VALIDITY + +github: + owner: lightspeedwp + repo: '.github' + api_rate_limit: 5000 + api_timeout: 30 + link_cache_ttl: 86400 + +metrics: + enabled: true + retention_days: 90 + storage_path: '.github/validation/changelog/data/metrics.json' + daily_update: true + update_time: '00:00' + +ci: + triggers: + - pr_to_develop + - pr_to_main + skip_branches: + - main + - develop + +reporting: + pr_comment: true + github_check: true + console: true + verbosity: info + +performance: + max_validation_time_seconds: 10 + max_dashboard_load_ms: 2000 + metrics_calculation_timeout: 3600 diff --git a/.github/validation/changelog/lib/ci-context.js b/.github/validation/changelog/lib/ci-context.js new file mode 100644 index 0000000000..072dae8832 --- /dev/null +++ b/.github/validation/changelog/lib/ci-context.js @@ -0,0 +1,244 @@ +class CIContext { + constructor(env = process.env) { + this.env = env; + this.context = this.detectContext(); + } + + detectContext() { + if (this.isGitHubActions()) { + return this.getGitHubActionsContext(); + } + + if (this.isCircleCI()) { + return this.getCircleCIContext(); + } + + if (this.isTravisCI()) { + return this.getTravisCIContext(); + } + + return this.getLocalContext(); + } + + isGitHubActions() { + return this.env.GITHUB_ACTIONS === 'true' || this.env.CI === 'true'; + } + + isCircleCI() { + return this.env.CIRCLECI === 'true'; + } + + isTravisCI() { + return this.env.TRAVIS === 'true'; + } + + getGitHubActionsContext() { + const eventName = this.env.GITHUB_EVENT_NAME || 'unknown'; + const eventPath = this.env.GITHUB_EVENT_PATH; + let eventData = {}; + + if (eventPath) { + try { + const fs = require('fs'); + eventData = JSON.parse(fs.readFileSync(eventPath, 'utf-8')); + } catch (error) { + console.warn('Failed to read GitHub event data:', error.message); + } + } + + return { + ci: 'github-actions', + event: eventName, + branch: this.env.GITHUB_REF_NAME || this.getBranchFromRef(this.env.GITHUB_REF), + commit: this.env.GITHUB_SHA || '', + repo: this.env.GITHUB_REPOSITORY || '', + actor: this.env.GITHUB_ACTOR || '', + pr_number: this.getPRNumberFromEvent(eventName, eventData), + pr_title: eventData?.pull_request?.title || null, + pr_body: eventData?.pull_request?.body || null, + base_branch: eventData?.pull_request?.base?.ref || null, + head_branch: eventData?.pull_request?.head?.ref || null, + action: eventData?.action || null, + workflow: this.env.GITHUB_WORKFLOW || '', + run_id: this.env.GITHUB_RUN_ID || '', + job: this.env.GITHUB_JOB || '', + server_url: this.env.GITHUB_SERVER_URL || 'https://github.com', + api_url: this.env.GITHUB_API_URL || 'https://api.github.com', + raw_event_data: eventData, + }; + } + + getCircleCIContext() { + return { + ci: 'circleci', + branch: this.env.CIRCLE_BRANCH || '', + commit: this.env.CIRCLE_SHA1 || '', + repo: this.env.CIRCLE_REPOSITORY_URL || '', + pr_number: this.env.CIRCLE_PULL_REQUEST + ? parseInt(this.env.CIRCLE_PULL_REQUEST.split('/').pop()) + : null, + job: this.env.CIRCLE_JOB || '', + build_num: this.env.CIRCLE_BUILD_NUM || '', + }; + } + + getTravisCIContext() { + return { + ci: 'travis', + branch: this.env.TRAVIS_BRANCH || '', + commit: this.env.TRAVIS_COMMIT || '', + repo: this.env.TRAVIS_REPO_SLUG || '', + pr_number: + this.env.TRAVIS_PULL_REQUEST !== 'false' ? parseInt(this.env.TRAVIS_PULL_REQUEST) : null, + job_id: this.env.TRAVIS_JOB_ID || '', + build_id: this.env.TRAVIS_BUILD_ID || '', + }; + } + + getLocalContext() { + return { + ci: 'local', + branch: this.env.BRANCH || this.env.GIT_BRANCH || 'unknown', + commit: this.env.COMMIT || this.env.GIT_COMMIT || '', + repo: this.env.REPO || '', + pr_number: null, + }; + } + + getBranchFromRef(ref) { + if (!ref) return ''; + if (ref.startsWith('refs/heads/')) { + return ref.replace('refs/heads/', ''); + } + if (ref.startsWith('refs/pull/')) { + return ref.replace('refs/pull/', '').replace('/merge', ''); + } + return ref; + } + + getPRNumberFromEvent(eventName, eventData) { + if (eventName === 'pull_request' || eventName === 'pull_request_target') { + return eventData?.pull_request?.number || null; + } + return null; + } + + isCIPullRequest() { + return this.context.pr_number !== null; + } + + isPullRequest() { + return this.isCIPullRequest() || this.env.GITHUB_EVENT_NAME === 'pull_request'; + } + + isPushEvent() { + return this.env.GITHUB_EVENT_NAME === 'push' || this.context.ci === 'local'; + } + + isScheduledEvent() { + return this.env.GITHUB_EVENT_NAME === 'schedule'; + } + + getContext() { + return this.context; + } + + getSummary() { + const ctx = this.context; + return { + ci_system: ctx.ci, + branch: ctx.branch, + is_pr: this.isPullRequest(), + pr_number: ctx.pr_number, + commit: ctx.commit?.substring(0, 7), + repo: ctx.repo, + event: ctx.event || 'unknown', + }; + } + + getGitHubToken() { + return this.env.GITHUB_TOKEN || this.env.GH_TOKEN || this.env.PERSONAL_GITHUB_TOKEN || null; + } + + getAPIHeaders() { + const token = this.getGitHubToken(); + + if (!token) { + return { + Accept: 'application/vnd.github.v3+json', + }; + } + + return { + Authorization: `token ${token}`, + Accept: 'application/vnd.github.v3+json', + 'User-Agent': 'LightSpeed-Changelog-Validator', + }; + } + + getValidationTrigger() { + if (this.isPullRequest()) { + return 'pr_submission'; + } + + if (this.isScheduledEvent()) { + return 'scheduled_audit'; + } + + return 'manual'; + } + + shouldValidateChangelog() { + if (this.context.ci === 'local') { + return true; + } + + const changedFiles = this.getChangedFiles(); + const hasChangelogChanges = changedFiles.some( + (file) => + file.includes('CHANGELOG.md') || + file.includes('changelog') || + file.includes('.github/validation/changelog') + ); + + return hasChangelogChanges; + } + + getChangedFiles() { + const eventData = this.context.raw_event_data || {}; + + if (eventData.pull_request) { + return (eventData.pull_request.changed_files || []).map((f) => f.filename || f); + } + + if (eventData.commits) { + const files = new Set(); + for (const commit of eventData.commits) { + if (commit.modified) commit.modified.forEach((f) => files.add(f)); + if (commit.added) commit.added.forEach((f) => files.add(f)); + } + return Array.from(files); + } + + return []; + } + + validateRequiredVariables() { + const errors = []; + + if (!this.getGitHubToken() && this.context.ci === 'github-actions') { + errors.push('GITHUB_TOKEN environment variable not found'); + } + + if (this.isPullRequest() && !this.context.pr_number) { + errors.push('PR number could not be determined'); + } + + return { + valid: errors.length === 0, + errors, + }; + } +} + +export default CIContext; diff --git a/.github/validation/changelog/lib/compliance-checker.js b/.github/validation/changelog/lib/compliance-checker.js new file mode 100644 index 0000000000..44ee8b4dfd --- /dev/null +++ b/.github/validation/changelog/lib/compliance-checker.js @@ -0,0 +1,379 @@ +import RuleEngine from './rule-engine.js'; + +class ComplianceChecker { + constructor(ruleEngine = null) { + this.ruleEngine = ruleEngine || new RuleEngine(); + this.initializeRuleImplementations(); + } + + initializeRuleImplementations() { + this.ruleEngine.registerRuleImplementation('CHK_MAX_LENGTH', this.checkMaxLength.bind(this)); + this.ruleEngine.registerRuleImplementation( + 'CHK_NO_IMPL_DETAILS', + this.checkNoImplDetails.bind(this) + ); + this.ruleEngine.registerRuleImplementation('CHK_HAS_PR_LINK', this.checkHasPRLink.bind(this)); + this.ruleEngine.registerRuleImplementation( + 'CHK_FORMAT_MARKDOWN', + this.checkFormatMarkdown.bind(this) + ); + this.ruleEngine.registerRuleImplementation( + 'CHK_CONSISTENT_TENSE', + this.checkConsistentTense.bind(this) + ); + this.ruleEngine.registerRuleImplementation( + 'CHK_NO_ABBREVIATIONS', + this.checkNoAbbreviations.bind(this) + ); + this.ruleEngine.registerRuleImplementation( + 'CHK_UNIQUE_CONTENT', + this.checkUniqueContent.bind(this) + ); + this.ruleEngine.registerRuleImplementation( + 'CHK_LINK_VALIDITY', + this.checkLinkValidity.bind(this) + ); + } + + async checkMaxLength(entry, context = {}) { + const rule = this.ruleEngine.getRuleById('CHK_MAX_LENGTH'); + const maxLength = rule.max_length || 250; + const contentLength = entry.content.length; + + const passed = contentLength <= maxLength; + + return { + passed, + message: passed + ? `Entry length within limit (${contentLength}/${maxLength} chars)` + : `Entry exceeds ${maxLength} character limit. Current: ${contentLength} chars`, + details: { + contentLength, + maxLength, + exceededBy: Math.max(0, contentLength - maxLength), + }, + }; + } + + async checkNoImplDetails(entry, context = {}) { + const rule = this.ruleEngine.getRuleById('CHK_NO_IMPL_DETAILS'); + const bannedKeywords = rule.banned_keywords || []; + const architecturalVerbs = rule.architectural_verbs || []; + + const content = entry.content.toLowerCase(); + const violations = []; + + for (const keyword of bannedKeywords) { + const regex = new RegExp(`\\b${keyword.toLowerCase()}\\b`, 'gi'); + if (regex.test(content)) { + const keywordLower = keyword.toLowerCase(); + const isArchitecturalVerb = architecturalVerbs.some( + (v) => v.toLowerCase() === keywordLower + ); + + if (!isArchitecturalVerb) { + violations.push(keyword); + } + } + } + + const passed = violations.length === 0; + + return { + passed, + message: passed + ? 'No code-specific implementation details detected' + : `Entry contains code-specific implementation details: ${violations.join(', ')}`, + details: { + violatedKeywords: violations, + count: violations.length, + }, + }; + } + + async checkHasPRLink(entry, context = {}) { + const prRegex = /#(\d+)/; + const issueRegex = /issues\/#(\d+)/; + + const hasPRLink = prRegex.test(entry.content) || issueRegex.test(entry.content); + + const passed = hasPRLink; + + return { + passed, + message: passed + ? 'Entry contains valid PR/issue reference' + : 'Entry must reference a PR or issue number (e.g., #1234 or issues/#5678)', + details: { + hasPRLink, + references: entry.pr_numbers && entry.pr_numbers.length > 0 ? entry.pr_numbers : [], + }, + }; + } + + async checkFormatMarkdown(entry, context = {}) { + const violations = []; + + if (/<[^>]+>/.test(entry.content)) { + violations.push('Contains raw HTML tags'); + } + + const asteriskPairs = (entry.content.match(/\*\*/g) || []).length; + if (asteriskPairs % 2 !== 0) { + violations.push('Unbalanced ** (bold) markers'); + } + + const underscorePairs = (entry.content.match(/__/g) || []).length; + if (underscorePairs % 2 !== 0) { + violations.push('Unbalanced __ (italic) markers'); + } + + const linkPattern = /\[([^\]]+)\]\(([^)]+)\)/g; + let linkMatch; + const invalidLinks = []; + while ((linkMatch = linkPattern.exec(entry.content)) !== null) { + if (!linkMatch[2] || linkMatch[2].trim() === '') { + invalidLinks.push(`[${linkMatch[1]}]`); + } + } + if (invalidLinks.length > 0) { + violations.push(`Invalid links: ${invalidLinks.join(', ')}`); + } + + const openBrackets = (entry.content.match(/\[/g) || []).length; + const closeBrackets = (entry.content.match(/\]/g) || []).length; + if (openBrackets !== closeBrackets) { + violations.push('Unbalanced square brackets []'); + } + + const passed = violations.length === 0; + + return { + passed, + message: passed + ? 'Entry has valid markdown formatting' + : `Entry has invalid markdown syntax: ${violations.join('; ')}`, + details: { + violations, + count: violations.length, + }, + }; + } + + async checkConsistentTense(entry, context = {}) { + const pastTenseVerbs = [ + 'fixed', + 'added', + 'improved', + 'updated', + 'refactored', + 'optimised', + 'optimized', + 'deployed', + 'migrated', + 'implemented', + 'patched', + 'changed', + ]; + const presentTenseVerbs = [ + 'fix', + 'add', + 'improve', + 'update', + 'refactor', + 'optimize', + 'deploy', + 'migrate', + 'implement', + 'patch', + 'change', + ]; + + const contentLower = entry.content.toLowerCase(); + + const hasPastTense = pastTenseVerbs.some((v) => new RegExp(`\\b${v}\\b`).test(contentLower)); + const hasPresentTense = presentTenseVerbs.some((v) => + new RegExp(`\\b${v}\\b`).test(contentLower) + ); + + const passed = !hasPresentTense || !hasPastTense; + + return { + passed, + message: passed + ? 'Entry uses consistent verb tense' + : 'Entry uses inconsistent verb tense. Use past tense for completed changes.', + details: { + hasPastTense, + hasPresentTense, + hasConflict: hasPastTense && hasPresentTense, + }, + }; + } + + async checkNoAbbreviations(entry, context = {}) { + const rule = this.ruleEngine.getRuleById('CHK_NO_ABBREVIATIONS'); + const knownAcronyms = rule.known_acronyms || []; + + const content = entry.content; + const abbrevPattern = /(?= threshold) { + similar.push({ + id: otherEntry.id, + similarity: (similarity * 100).toFixed(1), + content: otherEntry.content, + }); + } + } + + return similar; + } + + levenshteinSimilarity(str1, str2) { + const distance = this.levenshteinDistance(str1, str2); + const maxLength = Math.max(str1.length, str2.length); + return 1 - distance / maxLength; + } + + levenshteinDistance(str1, str2) { + const matrix = []; + + for (let i = 0; i <= str2.length; i++) { + matrix[i] = [i]; + } + + for (let j = 0; j <= str1.length; j++) { + matrix[0][j] = j; + } + + for (let i = 1; i <= str2.length; i++) { + for (let j = 1; j <= str1.length; j++) { + if (str2.charAt(i - 1) === str1.charAt(j - 1)) { + matrix[i][j] = matrix[i - 1][j - 1]; + } else { + matrix[i][j] = Math.min( + matrix[i - 1][j - 1] + 1, + matrix[i][j - 1] + 1, + matrix[i - 1][j] + 1 + ); + } + } + } + + return matrix[str2.length][str1.length]; + } + + async checkLinkValidity(entry, context = {}) { + const prRegex = /#(\d+)/g; + const issueRegex = /issues\/#(\d+)/g; + + const prMatches = [...entry.content.matchAll(prRegex)]; + const issueMatches = [...entry.content.matchAll(issueRegex)]; + + const allMatches = [...prMatches, ...issueMatches]; + + if (allMatches.length === 0) { + return { + passed: true, + message: 'No PR/issue links to validate', + details: { linksChecked: 0, linksValid: 0 }, + }; + } + + const ghToken = context.githubToken || process.env.GITHUB_TOKEN; + const shouldValidate = ghToken && context.validateLinks !== false; + + if (!shouldValidate) { + return { + passed: true, + message: `${allMatches.length} PR/issue link(s) present (validation skipped - no token)`, + details: { + linksChecked: allMatches.length, + linksValid: allMatches.length, + validationSkipped: true, + }, + }; + } + + return { + passed: true, + message: 'PR/issue links are present (validation deferred to Phase 3)', + details: { + linksChecked: allMatches.length, + linksValid: allMatches.length, + validationDeferred: true, + }, + }; + } + + async checkEntry(entry, context = {}) { + return this.ruleEngine.validateEntry(entry, context); + } + + async checkEntries(entries, context = {}) { + return this.ruleEngine.validateEntries(entries, context); + } +} + +export default ComplianceChecker; diff --git a/.github/validation/changelog/lib/error-handler.js b/.github/validation/changelog/lib/error-handler.js new file mode 100644 index 0000000000..c8e76f0738 --- /dev/null +++ b/.github/validation/changelog/lib/error-handler.js @@ -0,0 +1,240 @@ +class ValidationError extends Error { + constructor(message, code, context = {}) { + super(message); + this.name = 'ValidationError'; + this.code = code; + this.context = context; + this.timestamp = new Date().toISOString(); + } + + toJSON() { + return { + error: this.message, + error_code: this.code, + context: this.context, + timestamp: this.timestamp, + }; + } +} + +class ErrorHandler { + static handleFileNotFound(filePath) { + return new ValidationError(`File not found at ${filePath}`, 'FILE_NOT_FOUND', { filePath }); + } + + static handleParsingError(message, line = null) { + return new ValidationError( + `Parsing error: ${message}${line ? ` (line ${line})` : ''}`, + 'PARSE_ERROR', + { message, line } + ); + } + + static handleValidationTimeout() { + return new ValidationError( + 'Validation exceeded maximum time limit of 10 seconds', + 'VALIDATION_TIMEOUT', + { maxSeconds: 10 } + ); + } + + static handleGitHubAPIError(statusCode, message) { + return new ValidationError(`GitHub API error: ${statusCode} - ${message}`, 'GITHUB_API_ERROR', { + statusCode, + message, + }); + } + + static handleRateLimitExceeded(resetTime) { + return new ValidationError( + `GitHub API rate limit exceeded. Resets at ${resetTime}`, + 'RATE_LIMIT_EXCEEDED', + { resetTime } + ); + } + + static handleConfigError(message, configPath = null) { + return new ValidationError( + `Configuration error: ${message}${configPath ? ` in ${configPath}` : ''}`, + 'CONFIG_ERROR', + { message, configPath } + ); + } + + static handleMissingEnvironmentVariable(varName) { + return new ValidationError( + `Required environment variable missing: ${varName}`, + 'MISSING_ENV_VAR', + { varName } + ); + } + + static handleInvalidEntry(entryId, reason) { + return new ValidationError(`Invalid entry ${entryId}: ${reason}`, 'INVALID_ENTRY', { + entryId, + reason, + }); + } + + static async withRetry(fn, options = {}) { + const maxAttempts = options.maxAttempts || 3; + const delayMs = options.initialDelayMs || 1000; + const backoffMultiplier = options.backoffMultiplier || 2; + const maxDelayMs = options.maxDelayMs || 30000; + + let lastError; + let currentDelay = delayMs; + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + try { + return await fn(); + } catch (error) { + lastError = error; + + if (attempt < maxAttempts) { + await ErrorHandler.delay(Math.min(currentDelay, maxDelayMs)); + currentDelay *= backoffMultiplier; + } + } + } + + throw lastError; + } + + static delay(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + static async withTimeout(promise, timeoutMs) { + return Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(ErrorHandler.handleValidationTimeout()), timeoutMs) + ), + ]); + } + + static formatError(error, outputFormat = 'text') { + if (outputFormat === 'json') { + if (error instanceof ValidationError) { + return JSON.stringify(error.toJSON(), null, 2); + } + + return JSON.stringify( + { + error: error.message || 'Unknown error', + error_code: 'UNKNOWN_ERROR', + timestamp: new Date().toISOString(), + }, + null, + 2 + ); + } + + if (error instanceof ValidationError) { + return `${error.name} [${error.code}]: ${error.message}`; + } + + return `Error: ${error.message || String(error)}`; + } + + static getRecoverySteps(error) { + const steps = []; + + if (error instanceof ValidationError) { + switch (error.code) { + case 'FILE_NOT_FOUND': + steps.push('1. Verify CHANGELOG.md exists in the repository root'); + steps.push('2. Run git status to confirm file is tracked'); + steps.push('3. Check file permissions'); + break; + + case 'PARSE_ERROR': + steps.push('1. Verify CHANGELOG.md follows Keep a Changelog 1.1.0 format'); + steps.push('2. Check for syntax errors near the line mentioned in the error'); + steps.push('3. Ensure [Unreleased] section header is properly formatted'); + break; + + case 'GITHUB_API_ERROR': + steps.push('1. Check GitHub API status at https://www.githubstatus.com'); + steps.push('2. Verify GITHUB_TOKEN is valid and not expired'); + steps.push('3. Check network connectivity'); + break; + + case 'RATE_LIMIT_EXCEEDED': + steps.push('1. Wait for the rate limit to reset'); + steps.push('2. Use cached link validation results if available'); + steps.push('3. Reduce concurrent validation runs'); + break; + + case 'VALIDATION_TIMEOUT': + steps.push('1. Check system performance and available memory'); + steps.push('2. Reduce the number of changelog entries to validate'); + steps.push('3. Run validation locally to debug performance'); + break; + + case 'MISSING_ENV_VAR': + steps.push(`1. Set the missing environment variable: ${error.context.varName}`); + steps.push('2. Check CI/CD configuration for variable definition'); + steps.push('3. Verify secrets are properly configured'); + break; + + case 'CONFIG_ERROR': + steps.push('1. Check configuration file syntax (YAML/JSON)'); + steps.push('2. Verify all required configuration keys are present'); + steps.push('3. Review configuration against schema'); + break; + + default: + steps.push('1. Review error message for specific details'); + steps.push('2. Check logs for additional context'); + steps.push('3. Contact support if issue persists'); + } + } else { + steps.push('1. Check error message for details'); + steps.push('2. Review application logs'); + steps.push('3. Retry the operation'); + } + + return steps; + } + + static logError(error, context = {}) { + const timestamp = new Date().toISOString(); + const errorInfo = { + timestamp, + error: error instanceof ValidationError ? error.code : 'UNKNOWN', + message: error.message, + context, + }; + + console.error(JSON.stringify(errorInfo)); + + return errorInfo; + } + + static createErrorReport(error, context = {}) { + const report = { + error: { + type: error instanceof ValidationError ? error.code : 'UNKNOWN_ERROR', + message: error.message, + timestamp: new Date().toISOString(), + }, + context, + recovery_steps: ErrorHandler.getRecoverySteps(error), + }; + + if (error instanceof ValidationError) { + report.error.details = error.context; + } + + if (process.env.DEBUG) { + report.error.stack = error.stack; + } + + return report; + } +} + +export { ValidationError, ErrorHandler }; +export default ErrorHandler; diff --git a/.github/validation/changelog/lib/metrics-aggregator.js b/.github/validation/changelog/lib/metrics-aggregator.js new file mode 100644 index 0000000000..0ef4167cd5 --- /dev/null +++ b/.github/validation/changelog/lib/metrics-aggregator.js @@ -0,0 +1,290 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +class MetricsAggregator { + constructor(metricsPath = null) { + this.metricsPath = metricsPath || path.join(__dirname, '..', 'data', 'metrics.json'); + this.ensureDataDirectory(); + } + + ensureDataDirectory() { + const dataDir = path.dirname(this.metricsPath); + if (!fs.existsSync(dataDir)) { + fs.mkdirSync(dataDir, { recursive: true }); + } + } + + aggregateValidation(validationResult) { + const { total_entries, passed, failed, validations } = validationResult; + + const snapshot = { + snapshot_id: `snapshot-${Date.now()}`, + timestamp: new Date().toISOString(), + snapshot_period: 'daily', + total_entries, + compliant_entries: passed, + compliance_percent: total_entries > 0 ? ((passed / total_entries) * 100).toFixed(1) : 0, + length_distribution: this.calculateLengthDistribution(validations), + impl_detail_rate: this.calculateImplDetailRate(validations), + pr_link_coverage: this.calculatePRLinkCoverage(validations), + issues_found: this.categorizeViolations(validations), + trend_vs_previous: this.calculateTrend(passed, failed), + }; + + return snapshot; + } + + calculateLengthDistribution(validations) { + const buckets = { + '0_to_100': 0, + '100_to_250': 0, + '250_to_500': 0, + '500_plus': 0, + }; + + for (const validation of validations) { + if (!validation.entry_metadata) { + continue; + } + + const contentLength = validation.entry_metadata.contentLength || 0; + + if (contentLength <= 100) { + buckets['0_to_100']++; + } else if (contentLength <= 250) { + buckets['100_to_250']++; + } else if (contentLength <= 500) { + buckets['250_to_500']++; + } else { + buckets['500_plus']++; + } + } + + const total = Object.values(buckets).reduce((sum, count) => sum + count, 0); + + if (total === 0) { + return buckets; + } + + return { + '0_to_100': ((buckets['0_to_100'] / total) * 100).toFixed(1), + '100_to_250': ((buckets['100_to_250'] / total) * 100).toFixed(1), + '250_to_500': ((buckets['250_to_500'] / total) * 100).toFixed(1), + '500_plus': ((buckets['500_plus'] / total) * 100).toFixed(1), + }; + } + + calculateImplDetailRate(validations) { + let entriesWithImplDetails = 0; + + for (const validation of validations) { + if (!validation.violations) { + continue; + } + + const hasImplDetailViolation = validation.violations.some( + (v) => v.rule_id === 'CHK_NO_IMPL_DETAILS' && !v.passed + ); + + if (hasImplDetailViolation) { + entriesWithImplDetails++; + } + } + + const total = validations.length || 1; + return ((entriesWithImplDetails / total) * 100).toFixed(1); + } + + calculatePRLinkCoverage(validations) { + let entriesWithPRLink = 0; + + for (const validation of validations) { + if (!validation.violations) { + continue; + } + + const hasPRLinkViolation = validation.violations.some( + (v) => v.rule_id === 'CHK_HAS_PR_LINK' && v.passed === true + ); + + if (hasPRLinkViolation || validation.entry_metadata?.hasPRLink) { + entriesWithPRLink++; + } + } + + const total = validations.length || 1; + return ((entriesWithPRLink / total) * 100).toFixed(1); + } + + categorizeViolations(validations) { + const violations = {}; + + for (const validation of validations) { + if (!validation.violations || validation.violations.length === 0) { + continue; + } + + for (const violation of validation.violations) { + const ruleId = violation.rule_id; + if (!violations[ruleId]) { + violations[ruleId] = { + rule_id: ruleId, + count: 0, + severity: violation.severity, + examples: [], + }; + } + + violations[ruleId].count++; + + if (violations[ruleId].examples.length < 3) { + violations[ruleId].examples.push({ + entry_id: validation.entry_id, + message: violation.message, + }); + } + } + } + + return Object.values(violations); + } + + calculateTrend(currentPassed, currentFailed) { + try { + const previousMetrics = this.getLatestMetrics(); + + if (!previousMetrics) { + return { + compliance_delta: 0, + entries_added: currentPassed + currentFailed, + entries_refactored: 0, + }; + } + + const previousCompliance = parseFloat(previousMetrics.compliance_percent); + const currentTotal = currentPassed + currentFailed; + const previousTotal = previousMetrics.total_entries; + + const complianceDelta = ((currentPassed / currentTotal) * 100 - previousCompliance).toFixed( + 1 + ); + + return { + compliance_delta: parseFloat(complianceDelta), + entries_added: Math.max(0, currentTotal - previousTotal), + entries_refactored: Math.max(0, previousTotal - currentTotal), + }; + } catch (error) { + return { + compliance_delta: 0, + entries_added: currentPassed + currentFailed, + entries_refactored: 0, + }; + } + } + + saveMetrics(snapshot) { + try { + let metricsData = { snapshots: [] }; + + if (fs.existsSync(this.metricsPath)) { + const existing = fs.readFileSync(this.metricsPath, 'utf-8'); + metricsData = JSON.parse(existing); + } + + metricsData.snapshots.push(snapshot); + + metricsData.snapshots = this.retainLastDaysMetrics(metricsData.snapshots, 90); + + metricsData.last_updated = new Date().toISOString(); + metricsData.latest_snapshot = snapshot; + + fs.writeFileSync(this.metricsPath, JSON.stringify(metricsData, null, 2)); + + return { + success: true, + path: this.metricsPath, + snapshot_id: snapshot.snapshot_id, + }; + } catch (error) { + return { + success: false, + error: error.message, + }; + } + } + + retainLastDaysMetrics(snapshots, days) { + const cutoffTime = Date.now() - days * 24 * 60 * 60 * 1000; + + return snapshots.filter((snapshot) => { + const snapshotTime = new Date(snapshot.timestamp).getTime(); + return snapshotTime >= cutoffTime; + }); + } + + getLatestMetrics() { + try { + if (!fs.existsSync(this.metricsPath)) { + return null; + } + + const content = fs.readFileSync(this.metricsPath, 'utf-8'); + const data = JSON.parse(content); + + return data.latest_snapshot || null; + } catch (error) { + return null; + } + } + + getAllMetrics() { + try { + if (!fs.existsSync(this.metricsPath)) { + return { snapshots: [] }; + } + + const content = fs.readFileSync(this.metricsPath, 'utf-8'); + return JSON.parse(content); + } catch (error) { + console.error('Failed to read metrics:', error.message); + return { snapshots: [], error: error.message }; + } + } + + getMetricsSummary() { + const allMetrics = this.getAllMetrics(); + + if (!allMetrics.latest_snapshot) { + return { + status: 'no_data', + message: 'No metrics recorded yet', + }; + } + + const latest = allMetrics.latest_snapshot; + + return { + status: 'healthy', + compliance_percent: latest.compliance_percent, + total_entries: latest.total_entries, + compliant_entries: latest.compliant_entries, + compliance_status: + latest.compliance_percent >= 90 + ? 'excellent' + : latest.compliance_percent >= 75 + ? 'good' + : 'needs_improvement', + length_distribution: latest.length_distribution, + impl_detail_rate: latest.impl_detail_rate, + pr_link_coverage: latest.pr_link_coverage, + top_issues: (latest.issues_found || []).sort((a, b) => b.count - a.count).slice(0, 3), + last_updated: latest.timestamp, + }; + } +} + +export default MetricsAggregator; diff --git a/.github/validation/changelog/lib/parser.js b/.github/validation/changelog/lib/parser.js new file mode 100644 index 0000000000..c0a9d61050 --- /dev/null +++ b/.github/validation/changelog/lib/parser.js @@ -0,0 +1,139 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +class ChangelogParser { + constructor(changelogPath = null) { + this.changelogPath = changelogPath || path.join(__dirname, '..', '..', '..', 'CHANGELOG.md'); + } + + parseChangelog() { + try { + const content = fs.readFileSync(this.changelogPath, 'utf-8'); + return this.parseContent(content); + } catch (error) { + console.error(`Failed to read changelog at ${this.changelogPath}:`, error.message); + return { entries: [], errors: [error.message] }; + } + } + + parseContent(content) { + const lines = content.split('\n'); + const entries = []; + const errors = []; + + let inUnreleasedSection = false; + let currentSection = null; + let currentEntry = null; + let entryLineStart = null; + let entryId = 1; + + for (let lineIndex = 0; lineIndex < lines.length; lineIndex++) { + const line = lines[lineIndex]; + const trimmedLine = line.trim(); + + if (trimmedLine.match(/^##\s+\[Unreleased\]/i)) { + inUnreleasedSection = true; + continue; + } + + if (trimmedLine.match(/^##\s+\[/) && inUnreleasedSection) { + if (currentEntry) { + entries.push(this.finializeEntry(currentEntry, entryId, entryLineStart)); + entryId++; + currentEntry = null; + } + inUnreleasedSection = false; + currentSection = null; + continue; + } + + if (!inUnreleasedSection) { + continue; + } + + if (trimmedLine.match(/^###\s+(Added|Changed|Deprecated|Removed|Fixed|Security)/i)) { + if (currentEntry) { + entries.push(this.finializeEntry(currentEntry, entryId, entryLineStart)); + entryId++; + } + currentSection = trimmedLine.replace(/^###\s+/, '').toLowerCase(); + currentEntry = null; + continue; + } + + if (trimmedLine.match(/^-\s+/) && currentSection) { + if (currentEntry) { + entries.push(this.finializeEntry(currentEntry, entryId, entryLineStart)); + entryId++; + } + entryLineStart = lineIndex + 1; + const entryContent = trimmedLine.replace(/^-\s+/, '').trim(); + currentEntry = { + content: entryContent, + lines: [entryContent], + section: currentSection, + startLine: lineIndex + 1, + }; + } else if ( + currentEntry && + trimmedLine && + !trimmedLine.match(/^###/) && + !trimmedLine.match(/^##/) + ) { + currentEntry.lines.push(trimmedLine); + currentEntry.content = currentEntry.lines.join(' '); + } + } + + if (currentEntry) { + entries.push(this.finializeEntry(currentEntry, entryId, entryLineStart)); + } + + return { + entries, + errors: errors.length > 0 ? errors : null, + totalEntries: entries.length, + unreleasedSectionFound: entries.length > 0 || !inUnreleasedSection, + }; + } + + finializeEntry(entryData, entryId, lineStart) { + const { content, section } = entryData; + + const prMatches = content.match(/#(\d+)/g) || []; + const issueMatches = content.match(/issues\/#(\d+)/g) || []; + + const prNumbers = prMatches.map((m) => parseInt(m.slice(1))); + const issueNumbers = issueMatches.map((m) => parseInt(m.match(/\d+/)[0])); + + const entry = { + id: `unreleased-${entryId}`, + content: content.trim(), + line_number: lineStart, + version_section: 'Unreleased', + section: section, + pr_numbers: prNumbers, + issue_numbers: issueNumbers, + references: [...prMatches, ...issueMatches], + }; + + return entry; + } + + filterBySection(entries, sectionName) { + return entries.filter((e) => e.section === sectionName.toLowerCase()); + } + + getEntriesWithPRLink(entries) { + return entries.filter((e) => e.pr_numbers.length > 0); + } + + getEntriesWithoutPRLink(entries) { + return entries.filter((e) => e.pr_numbers.length === 0); + } +} + +export default ChangelogParser; diff --git a/.github/validation/changelog/lib/report-formatter.js b/.github/validation/changelog/lib/report-formatter.js new file mode 100644 index 0000000000..32a15f0edd --- /dev/null +++ b/.github/validation/changelog/lib/report-formatter.js @@ -0,0 +1,280 @@ +class ReportFormatter { + static formatJSON(report) { + return JSON.stringify(report, null, 2); + } + + static formatText(report) { + let output = ''; + + output += '\n'; + output += '════════════════════════════════════════════════════════════\n'; + output += ' CHANGELOG QUALITY VALIDATION REPORT\n'; + output += '════════════════════════════════════════════════════════════\n'; + output += '\n'; + + // Summary + const summary = report.summary || {}; + output += `Total Entries: ${summary.total_entries || 0}\n`; + output += `Compliant: ${summary.passed || 0} (${summary.pass_rate || 0}%)\n`; + output += `Non-Compliant: ${summary.failed || 0}\n`; + output += '\n'; + + // Gate result + const gateResult = report.ci_gate_result || 'unknown'; + const recommendation = report.recommendation || 'unknown'; + const gateIcon = gateResult === 'pass' ? '✓' : gateResult === 'warning' ? '⚠' : '✗'; + + output += `Gate Result: ${gateIcon} ${gateResult.toUpperCase()}\n`; + output += `Recommendation: ${recommendation}\n`; + output += '\n'; + + // Violations by rule + const violations = report.violations_by_rule || []; + if (violations.length > 0) { + output += '════════════════════════════════════════════════════════════\n'; + output += 'ISSUES FOUND\n'; + output += '════════════════════════════════════════════════════════════\n'; + output += '\n'; + + for (const violation of violations) { + output += `${violation.rule_id} [${violation.severity.toUpperCase()}]: ${violation.count} violation(s)\n`; + + for (const entry of violation.entries || []) { + output += ` • ${entry.entry_id}: ${entry.message}\n`; + } + + output += '\n'; + } + } + + // Timestamp and trigger + output += '════════════════════════════════════════════════════════════\n'; + output += `Timestamp: ${report.timestamp || 'unknown'}\n`; + output += `Trigger: ${report.trigger || 'manual'}\n`; + + if (report.ci_context) { + output += `CI System: ${report.ci_context.ci_system || 'local'}\n`; + output += `Branch: ${report.ci_context.branch || 'unknown'}\n`; + if (report.ci_context.pr_number) { + output += `PR Number: ${report.ci_context.pr_number}\n`; + } + } + + output += '════════════════════════════════════════════════════════════\n'; + output += '\n'; + + return output; + } + + static formatMarkdown(report) { + let output = ''; + + const summary = report.summary || {}; + const gateResult = report.ci_gate_result || 'unknown'; + const recommendation = report.recommendation || 'unknown'; + const violations = report.violations_by_rule || []; + + // Header + output += '## 📋 Changelog Quality Validation\n\n'; + + // Summary table + output += '### Summary\n\n'; + output += '| Metric | Value |\n'; + output += '|--------|-------|\n'; + output += `| Total Entries | ${summary.total_entries || 0} |\n`; + output += `| ✅ Compliant | ${summary.passed || 0} (${summary.pass_rate || 0}%) |\n`; + output += `| ❌ Non-Compliant | ${summary.failed || 0} |\n`; + output += '\n'; + + // Status + const statusIcon = gateResult === 'pass' ? '✅' : gateResult === 'warning' ? '⚠️' : '❌'; + const statusText = + gateResult === 'pass' ? 'PASSED' : gateResult === 'warning' ? 'WARNING' : 'FAILED'; + + output += '### Status\n\n'; + output += `${statusIcon} **Validation ${statusText}**\n\n`; + + if (gateResult === 'fail') { + output += + 'Please fix the issues below before merging. All changelog entries must pass validation.\n\n'; + } else if (gateResult === 'warning') { + output += 'Review the warnings below and consider addressing them for better quality.\n\n'; + } else { + output += 'All entries meet quality standards! ✨\n\n'; + } + + output += `**Recommendation:** ${recommendation}\n\n`; + + // Violations + if (violations.length > 0) { + output += '### Issues Found\n\n'; + + for (const violation of violations) { + const severityBadge = + violation.severity === 'critical' + ? '🔴' + : violation.severity === 'high' + ? '🟠' + : violation.severity === 'medium' + ? '🟡' + : '🔵'; + + output += `#### ${severityBadge} ${violation.rule_id}\n\n`; + output += `**Severity:** ${violation.severity}\n`; + output += `**Count:** ${violation.count} violation(s)\n\n`; + + output += '**Examples:**\n\n'; + for (const entry of violation.entries || []) { + output += `- _${entry.entry_id}_: ${entry.message}\n`; + } + + output += '\n'; + } + } + + // How to fix + output += '### How to Fix\n\n'; + output += 'Run validation locally:\n\n'; + output += '```bash\n'; + output += 'cd .github/validation/changelog\n'; + output += 'node bin/validate.js --changelog-path ../../../CHANGELOG.md\n'; + output += '```\n\n'; + + // Footer + output += `_Report generated at ${report.timestamp || new Date().toISOString()}_\n`; + + return output; + } + + static formatCSV(report) { + const summary = report.summary || {}; + const violations = report.violations_by_rule || []; + + let output = ''; + + // Summary section + output += 'SUMMARY\n'; + output += 'Metric,Value\n'; + output += `Total Entries,${summary.total_entries || 0}\n`; + output += `Compliant,${summary.passed || 0}\n`; + output += `Non-Compliant,${summary.failed || 0}\n`; + output += `Pass Rate,${summary.pass_rate || 0}%\n`; + output += `Gate Result,${report.ci_gate_result || 'unknown'}\n`; + output += '\n'; + + // Violations section + if (violations.length > 0) { + output += 'VIOLATIONS\n'; + output += 'Rule ID,Severity,Count,Entry IDs\n'; + + for (const violation of violations) { + const entryIds = violation.entries?.map((e) => e.entry_id).join('; ') || ''; + + output += `"${violation.rule_id}","${violation.severity}",${violation.count},"${entryIds}"\n`; + } + } + + return output; + } + + static formatHTML(report) { + const summary = report.summary || {}; + const gateResult = report.ci_gate_result || 'unknown'; + const violations = report.violations_by_rule || []; + + const statusClass = + gateResult === 'pass' + ? 'status-pass' + : gateResult === 'warning' + ? 'status-warning' + : 'status-fail'; + + let html = ''; + + html += '\n'; + html += '\n'; + html += '\n'; + html += '\n'; + html += '\n'; + html += 'Changelog Validation Report\n'; + html += '\n'; + html += '\n'; + html += '\n'; + + html += '
\n'; + html += '

📋 Changelog Quality Validation Report

\n'; + + // Summary + html += '

Summary

\n'; + html += '\n'; + html += `\n`; + html += `\n`; + html += `\n`; + html += '
Total Entries${summary.total_entries || 0}
Compliant${summary.passed || 0} (${summary.pass_rate || 0}%)
Non-Compliant${summary.failed || 0}
\n'; + + // Status + html += `
Status: ${gateResult.toUpperCase()}
\n`; + + // Violations + if (violations.length > 0) { + html += '

Issues Found

\n'; + + for (const violation of violations) { + html += `
\n`; + html += `

${violation.rule_id} [${violation.severity}]

\n`; + html += `

Count: ${violation.count} violation(s)

\n`; + html += '

Examples:

\n'; + html += '\n'; + html += '
\n'; + } + } + + html += `

Generated: ${report.timestamp || new Date().toISOString()}

\n`; + html += '
\n'; + html += '\n'; + html += '\n'; + + return html; + } + + static format(report, format = 'text') { + switch (format) { + case 'json': + return ReportFormatter.formatJSON(report); + case 'markdown': + case 'md': + return ReportFormatter.formatMarkdown(report); + case 'csv': + return ReportFormatter.formatCSV(report); + case 'html': + return ReportFormatter.formatHTML(report); + case 'text': + default: + return ReportFormatter.formatText(report); + } + } +} + +export default ReportFormatter; diff --git a/.github/validation/changelog/lib/rule-engine.js b/.github/validation/changelog/lib/rule-engine.js new file mode 100644 index 0000000000..1546bbfaab --- /dev/null +++ b/.github/validation/changelog/lib/rule-engine.js @@ -0,0 +1,122 @@ +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const RULES_PATH = path.join(__dirname, '..', 'rules.json'); + +class RuleEngine { + constructor() { + this.rules = this.loadRules(); + this.ruleImplementations = new Map(); + } + + loadRules() { + try { + const rulesContent = fs.readFileSync(RULES_PATH, 'utf-8'); + const rulesData = JSON.parse(rulesContent); + return rulesData.rules || []; + } catch (error) { + console.error(`Failed to load rules from ${RULES_PATH}:`, error.message); + return []; + } + } + + registerRuleImplementation(ruleId, implementation) { + this.ruleImplementations.set(ruleId, implementation); + } + + async validateEntry(entry, context = {}) { + const violations = []; + const results = []; + + for (const rule of this.rules) { + const { rule_id, severity, error_message } = rule; + + try { + const implementation = this.ruleImplementations.get(rule_id); + + if (!implementation) { + console.warn(`Warning: No implementation for rule ${rule_id}`); + continue; + } + + const result = await implementation(entry, context); + + if (!result.passed) { + violations.push({ + rule_id, + severity, + message: error_message || result.message || `Failed: ${rule_id}`, + details: result.details || null, + }); + } + + results.push({ + rule_id, + passed: result.passed, + severity, + }); + } catch (error) { + console.error(`Error executing rule ${rule_id}:`, error.message); + violations.push({ + rule_id, + severity: 'critical', + message: `Error executing rule: ${error.message}`, + }); + } + } + + return { + entry_id: entry.id, + violations, + results, + passed: violations.length === 0, + failed_count: violations.filter((v) => v.severity === 'critical').length, + warning_count: violations.filter((v) => v.severity !== 'critical').length, + }; + } + + async validateEntries(entries, context = {}) { + const validations = []; + let totalPassed = 0; + let totalFailed = 0; + + for (const entry of entries) { + const validation = await this.validateEntry(entry, context); + validations.push(validation); + + if (validation.passed) { + totalPassed++; + } else { + totalFailed++; + } + } + + return { + total_entries: entries.length, + passed: totalPassed, + failed: totalFailed, + pass_rate: entries.length > 0 ? ((totalPassed / entries.length) * 100).toFixed(1) : 0, + validations, + }; + } + + getRuleById(ruleId) { + return this.rules.find((r) => r.rule_id === ruleId); + } + + getRulesByDimension(dimension) { + return this.rules.filter((r) => r.dimension === dimension); + } + + getRulesBySeverity(severity) { + return this.rules.filter((r) => r.severity === severity); + } + + getAllRules() { + return this.rules; + } +} + +export default RuleEngine; diff --git a/.github/validation/changelog/lib/validation-workflow.js b/.github/validation/changelog/lib/validation-workflow.js new file mode 100644 index 0000000000..a4edb8ab02 --- /dev/null +++ b/.github/validation/changelog/lib/validation-workflow.js @@ -0,0 +1,197 @@ +import ChangelogParser from './parser.js'; +import ComplianceChecker from './compliance-checker.js'; +import MetricsAggregator from './metrics-aggregator.js'; +import CIContext from './ci-context.js'; + +class ValidationWorkflow { + constructor(options = {}) { + this.changelogPath = options.changelogPath || 'CHANGELOG.md'; + this.metricsPath = options.metricsPath || '.github/validation/changelog/data/metrics.json'; + this.githubToken = options.githubToken || process.env.GITHUB_TOKEN; + this.verbose = options.verbose || false; + this.saveMetrics = options.saveMetrics !== false; + + this.parser = new ChangelogParser(this.changelogPath); + this.checker = new ComplianceChecker(); + this.aggregator = new MetricsAggregator(this.metricsPath); + this.ciContext = new CIContext(process.env); + } + + async validate() { + try { + this.log('Starting changelog validation workflow...'); + + // Parse changelog + this.log('Parsing changelog...'); + const parseResult = this.parser.parseChangelog(); + + if (parseResult.errors && parseResult.errors.length > 0) { + return this.createErrorResult('Parsing failed', parseResult.errors); + } + + const entries = parseResult.entries || []; + + if (entries.length === 0) { + this.log('No entries found in [Unreleased] section'); + return this.createEmptyResult(); + } + + this.log(`Found ${entries.length} entries`); + + // Check entries for compliance + this.log('Validating entries...'); + const validationContext = { + githubToken: this.githubToken, + allEntries: entries, + validateLinks: false, + }; + + const validationResult = await this.checker.checkEntries(entries, validationContext); + + this.log( + `Validation complete: ${validationResult.passed}/${validationResult.total_entries} passed` + ); + + // Aggregate metrics + this.log('Aggregating metrics...'); + let snapshot = null; + if (this.saveMetrics) { + snapshot = this.aggregator.aggregateValidation(validationResult); + const metricsSaveResult = this.aggregator.saveMetrics(snapshot); + this.log(`Metrics saved: ${metricsSaveResult.success}`); + } + + // Build report + const report = this.buildReport(validationResult, snapshot); + + this.log('Validation workflow completed'); + + return { + success: true, + report, + validationResult, + snapshot, + }; + } catch (error) { + return this.createErrorResult('Validation failed', [error.message]); + } + } + + buildReport(validationResult, snapshot) { + const { total_entries, passed, failed, pass_rate } = validationResult; + + let gateResult = 'pass'; + let recommendation = 'approved'; + + const passRate = parseFloat(pass_rate); + + if (passRate === 100) { + // gateResult stays 'pass', recommendation stays 'approved' + } else if (passRate >= 90) { + gateResult = 'warning'; + recommendation = 'review'; + } else if (passRate >= 75) { + gateResult = 'warning'; + recommendation = 'review_required'; + } else { + gateResult = 'fail'; + recommendation = 'blocked'; + } + + const violationsByRule = this.categorizeViolations(validationResult.validations || []); + + return { + summary: { + total_entries, + passed, + failed, + pass_rate, + }, + violations_by_rule: violationsByRule, + ci_gate_result: gateResult, + recommendation, + timestamp: new Date().toISOString(), + ci_context: this.ciContext.getSummary(), + metrics_snapshot: snapshot ? snapshot.snapshot_id : null, + trigger: this.ciContext.getValidationTrigger(), + }; + } + + categorizeViolations(validations) { + const byRule = {}; + + for (const validation of validations) { + if (!validation.violations || validation.violations.length === 0) { + continue; + } + + for (const violation of validation.violations) { + const ruleId = violation.rule_id; + if (!byRule[ruleId]) { + byRule[ruleId] = { + rule_id: ruleId, + severity: violation.severity, + count: 0, + entries: [], + }; + } + + byRule[ruleId].count++; + if (byRule[ruleId].entries.length < 5) { + byRule[ruleId].entries.push({ + entry_id: validation.entry_id, + message: violation.message, + }); + } + } + } + + return Object.values(byRule).sort((a, b) => { + const severityOrder = { critical: 0, high: 1, medium: 2, low: 3 }; + return severityOrder[a.severity] - severityOrder[b.severity] || b.count - a.count; + }); + } + + createErrorResult(title, errors) { + return { + success: false, + report: { + error: title, + errors, + timestamp: new Date().toISOString(), + ci_context: this.ciContext.getSummary(), + }, + }; + } + + createEmptyResult() { + return { + success: true, + report: { + summary: { + total_entries: 0, + passed: 0, + failed: 0, + pass_rate: 0, + }, + violations_by_rule: [], + ci_gate_result: 'pass', + recommendation: 'no_action_needed', + timestamp: new Date().toISOString(), + ci_context: this.ciContext.getSummary(), + }, + }; + } + + log(message) { + if (this.verbose) { + console.error(`[ValidationWorkflow] ${message}`); + } + } + + getReport() { + return this.report; + } +} + +export default ValidationWorkflow; diff --git a/.github/validation/changelog/metrics-schema.json b/.github/validation/changelog/metrics-schema.json new file mode 100644 index 0000000000..d5fe67c248 --- /dev/null +++ b/.github/validation/changelog/metrics-schema.json @@ -0,0 +1,151 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "Changelog Metrics Snapshot", + "description": "Schema for changelog compliance metrics captured at a point in time", + "type": "object", + "required": [ + "snapshot_id", + "timestamp", + "snapshot_period", + "total_entries", + "compliant_entries", + "compliance_percent", + "length_distribution", + "impl_detail_rate", + "pr_link_coverage", + "issues_found", + "trend_vs_previous" + ], + "properties": { + "snapshot_id": { + "type": "string", + "description": "Unique identifier for this snapshot (ISO 8601 timestamp format)", + "example": "2026-09-17T00:00:00Z" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "When metrics were captured (ISO 8601)" + }, + "snapshot_period": { + "type": "string", + "enum": ["daily", "weekly", "monthly"], + "description": "Frequency of metric capture" + }, + "total_entries": { + "type": "integer", + "minimum": 0, + "description": "Total count of entries in [Unreleased] section" + }, + "compliant_entries": { + "type": "integer", + "minimum": 0, + "description": "Count of entries passing all validation rules" + }, + "compliance_percent": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "(compliant_entries / total_entries) * 100" + }, + "length_distribution": { + "type": "object", + "description": "Distribution of entry lengths across buckets", + "required": ["0_to_100", "100_to_250", "250_to_500", "500_plus"], + "properties": { + "0_to_100": { + "type": "integer", + "minimum": 0, + "description": "Entries 0-100 characters" + }, + "100_to_250": { + "type": "integer", + "minimum": 0, + "description": "Entries 100-250 characters (target zone)" + }, + "250_to_500": { + "type": "integer", + "minimum": 0, + "description": "Entries 250-500 characters (exceeds limit)" + }, + "500_plus": { + "type": "integer", + "minimum": 0, + "description": "Entries 500+ characters (far exceeds limit)" + } + } + }, + "impl_detail_rate": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Percentage of entries flagged with implementation details" + }, + "pr_link_coverage": { + "type": "number", + "minimum": 0, + "maximum": 100, + "description": "Percentage of entries with valid PR/issue links" + }, + "issues_found": { + "type": "object", + "description": "Violation counts grouped by rule ID", + "additionalProperties": { + "type": "integer", + "minimum": 0 + }, + "example": { + "CHK_MAX_LENGTH": 2, + "CHK_NO_IMPL_DETAILS": 5, + "CHK_HAS_PR_LINK": 1 + } + }, + "trend_vs_previous": { + "type": "object", + "description": "Change metrics compared to previous snapshot", + "required": ["compliance_delta", "entries_added", "entries_refactored"], + "properties": { + "compliance_delta": { + "type": "number", + "description": "Change in compliance percentage (can be negative)" + }, + "entries_added": { + "type": "integer", + "minimum": 0, + "description": "New entries added since last snapshot" + }, + "entries_refactored": { + "type": "integer", + "minimum": 0, + "description": "Entries modified to fix compliance issues" + } + } + } + }, + "example": { + "snapshot_id": "2026-09-17T00:00:00Z", + "timestamp": "2026-09-17T00:00:00Z", + "snapshot_period": "daily", + "total_entries": 185, + "compliant_entries": 176, + "compliance_percent": 95.1, + "length_distribution": { + "0_to_100": 12, + "100_to_250": 164, + "250_to_500": 8, + "500_plus": 1 + }, + "impl_detail_rate": 3.2, + "pr_link_coverage": 98.9, + "issues_found": { + "CHK_MAX_LENGTH": 1, + "CHK_NO_IMPL_DETAILS": 6, + "CHK_HAS_PR_LINK": 2 + }, + "trend_vs_previous": { + "compliance_delta": 2.1, + "entries_added": 8, + "entries_refactored": 3 + } + } +} diff --git a/.github/validation/changelog/package-lock.json b/.github/validation/changelog/package-lock.json new file mode 100644 index 0000000000..f270ac9857 --- /dev/null +++ b/.github/validation/changelog/package-lock.json @@ -0,0 +1,1613 @@ +{ + "name": "@lightspeedwp/changelog-validation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@lightspeedwp/changelog-validation", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "yargs": "^17.7.2" + }, + "bin": { + "assess": "bin/assess.js", + "auto-link": "bin/auto-link.js", + "metrics": "bin/metrics.js", + "validate": "bin/validate.js" + }, + "devDependencies": { + "c8": "^8.0.1", + "eslint": "^8.48.0", + "prettier": "^3.0.3" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", + "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.10.1.tgz", + "integrity": "sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", + "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^9.6.0", + "globals": "^13.19.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.0", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.1.tgz", + "integrity": "sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@humanwhocodes/config-array": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.13.0.tgz", + "integrity": "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==", + "deprecated": "Use @eslint/config-array instead", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanwhocodes/object-schema": "^2.0.3", + "debug": "^4.3.1", + "minimatch": "^3.0.5" + }, + "engines": { + "node": ">=10.10.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/object-schema": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz", + "integrity": "sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==", + "deprecated": "Use @eslint/object-schema instead", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", + "integrity": "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@types/istanbul-lib-coverage": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz", + "integrity": "sha512-2QF/t/auWm0lsy8XtKVPG19v3sSOQlJe/YHZgfjb/KBBHOGSV+J2q/S671rcq9uTBrLAXmZpqJiaQbMT+zNU1w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz", + "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/acorn": { + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", + "integrity": "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/c8": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/c8/-/c8-8.0.1.tgz", + "integrity": "sha512-EINpopxZNH1mETuI0DzRA4MZpAUH+IFiRhnmFD3vFr3vdrgxqi3VfE3KL0AIL+zDq8rC9bZqwM/VDmmoe04y7w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@bcoe/v8-coverage": "^0.2.3", + "@istanbuljs/schema": "^0.1.3", + "find-up": "^5.0.0", + "foreground-child": "^2.0.0", + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.1.6", + "rimraf": "^3.0.2", + "test-exclude": "^6.0.0", + "v8-to-istanbul": "^9.0.0", + "yargs": "^17.7.2", + "yargs-parser": "^21.1.1" + }, + "bin": { + "c8": "bin/c8.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", + "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "8.57.1", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.1.tgz", + "integrity": "sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==", + "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.6.1", + "@eslint/eslintrc": "^2.1.4", + "@eslint/js": "8.57.1", + "@humanwhocodes/config-array": "^0.13.0", + "@humanwhocodes/module-importer": "^1.0.1", + "@nodelib/fs.walk": "^1.2.8", + "@ungap/structured-clone": "^1.2.0", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.2", + "debug": "^4.3.2", + "doctrine": "^3.0.0", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^7.2.2", + "eslint-visitor-keys": "^3.4.3", + "espree": "^9.6.1", + "esquery": "^1.4.2", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^6.0.1", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "globals": "^13.19.0", + "graphemer": "^1.4.0", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "is-path-inside": "^3.0.3", + "js-yaml": "^4.1.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.4.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3", + "strip-ansi": "^6.0.1", + "text-table": "^0.2.0" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-scope": { + "version": "7.2.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", + "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", + "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.9.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^3.4.1" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.3.tgz", + "integrity": "sha512-XKv5nnLs6nLF71NgiKJLIZFLkPyIEuOselLG7ujZnGrRfQK8HpvY+WqKhAJUAdLomwVHErVS4LfxFlPq0/FTAw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", + "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^3.0.4" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-3.2.0.tgz", + "integrity": "sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.3", + "rimraf": "^3.0.2" + }, + "engines": { + "node": "^10.12.0 || >=12.0.0" + } + }, + "node_modules/flatted": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.4.tgz", + "integrity": "sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", + "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "13.24.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", + "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "type-fest": "^0.20.2" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graphemer": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", + "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", + "dev": true, + "license": "MIT" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-path-inside": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", + "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.9.7", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.7.tgz", + "integrity": "sha512-T3mF5P7HFzZVLDQuCUNtJj6WK1pcpLMweMNUVwGb01bLNkH9AkKxtZvmInw8K6bnn2O3d6/5RHl6zSHiBAD0Og==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/test-exclude": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz", + "integrity": "sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^7.1.4", + "minimatch": "^3.0.4" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", + "dev": true, + "license": "MIT" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/type-fest": { + "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", + "dev": true, + "license": "(MIT OR CC0-1.0)", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/v8-to-istanbul": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", + "dev": true, + "license": "ISC", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", + "@types/istanbul-lib-coverage": "^2.0.1", + "convert-source-map": "^2.0.0" + }, + "engines": { + "node": ">=10.12.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yargs": { + "version": "17.7.3", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", + "integrity": "sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==", + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/.github/validation/changelog/package.json b/.github/validation/changelog/package.json new file mode 100644 index 0000000000..6b3677048a --- /dev/null +++ b/.github/validation/changelog/package.json @@ -0,0 +1,51 @@ +{ + "name": "@lightspeedwp/changelog-validation", + "version": "1.0.0", + "description": "Changelog quality validation framework for Keep a Changelog 1.1.0 format", + "type": "module", + "main": "lib/index.js", + "bin": { + "validate": "bin/validate.js", + "auto-link": "bin/auto-link.js", + "metrics": "bin/metrics.js", + "assess": "bin/assess.js" + }, + "scripts": { + "validate": "node bin/validate.js", + "test": "NODE_OPTIONS='--experimental-vm-modules' node --test 'test/**/*.test.js'", + "test:unit": "NODE_OPTIONS='--experimental-vm-modules' node --test 'test/unit/**/*.test.js'", + "test:integration": "NODE_OPTIONS='--experimental-vm-modules' node --test 'test/integration/**/*.test.js'", + "test:coverage": "node --experimental-vm-modules node_modules/.bin/c8 --reporter text --reporter html node --test 'test/**/*.test.js'", + "lint": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "validate:changelog": "node bin/validate.js --changelog-path CHANGELOG.md" + }, + "keywords": [ + "changelog", + "keep-a-changelog", + "validation", + "quality", + "ci-cd", + "github-actions" + ], + "author": "LightSpeedWP", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "dependencies": { + "yargs": "^17.7.2" + }, + "devDependencies": { + "eslint": "^8.48.0", + "prettier": "^3.0.3", + "c8": "^8.0.1" + }, + "repository": { + "type": "git", + "url": "https://github.com/lightspeedwp/.github.git", + "directory": ".github/validation/changelog" + } +} diff --git a/.github/validation/changelog/rules.json b/.github/validation/changelog/rules.json new file mode 100644 index 0000000000..37b17a2d51 --- /dev/null +++ b/.github/validation/changelog/rules.json @@ -0,0 +1,232 @@ +{ + "version": "1.0.0", + "framework": "Keep a Changelog 1.1.0", + "rules": [ + { + "rule_id": "CHK_MAX_LENGTH", + "rule_name": "Maximum entry length", + "description": "Changelog entries must not exceed 250 characters", + "dimension": "completeness", + "severity": "critical", + "validation_logic": "length(content) <= 250", + "error_message": "Entry exceeds 250 character limit (constraint from research phase). Current: {actual} chars. Refactor to be more concise and user-focused.", + "examples": { + "pass": [ + "Fixed user authentication timeout in login flow (#3350)", + "Added support for dark mode in dashboard UI (#3351)", + "Improved database query performance for reporting (optimised from 5s to 200ms) (#3352)" + ], + "fail": [ + "Fixed a critical issue in the authentication middleware where the JWT token validation was incorrectly checking the expiration timestamp against the system clock instead of the token's internal exp field, which caused valid tokens to be rejected after exactly 24 hours even though they should have been valid for 72 hours according to the issue tracker, resolving the user reports in #3350", + "Refactored the entire component structure to use React hooks instead of class components, moving all state management logic into custom hooks for better reusability and testability, implementing proper error boundaries, and adding comprehensive unit tests for all 47 new hook implementations (#3351)" + ] + } + }, + { + "rule_id": "CHK_NO_IMPL_DETAILS", + "rule_name": "No implementation details", + "description": "Entries must be user-focused; flag code-specific terms only. Architectural verbs (refactored, optimised, deployed, etc.) are PERMITTED.", + "dimension": "clarity", + "severity": "critical", + "validation_logic": "detect code-specific keywords from banned_list; exclude architectural_verbs", + "error_message": "Entry contains code-specific implementation details: {violations}. Rewrite from user perspective (user-facing change, not internal implementation). Permitted verbs: refactored, optimised, deployed, migrated, fixed, updated, implemented, patched.", + "banned_keywords": [ + "function", + "method", + "class", + "variable", + "module", + "middleware", + "hook", + "component", + "REST API", + "GraphQL", + "database", + "query", + "cache", + "transaction", + "endpoint", + "algorithm", + "logic", + "framework", + "package", + "dependency" + ], + "architectural_verbs": [ + "refactored", + "optimised", + "optimized", + "deployed", + "migrated", + "restructured", + "reorganised", + "reorganized", + "fixed", + "updated", + "changed", + "implemented", + "patched" + ], + "examples": { + "pass": [ + "Refactored authentication flow for better performance (#3350)", + "Fixed user profile display issue when email is missing (#3351)", + "Optimised database queries to reduce page load time (#3352)", + "Implemented automatic backup every 6 hours (#3353)" + ], + "fail": [ + "Refactored the React component hierarchy using custom hooks for state management (#3350)", + "Fixed memory leak in the database connection pool module (#3351)", + "Optimised REST API endpoint response time by implementing query caching (#3352)", + "Added transaction logging to the middleware function (#3353)" + ] + } + }, + { + "rule_id": "CHK_HAS_PR_LINK", + "rule_name": "PR link present", + "description": "Entries must reference at least one PR or issue number", + "dimension": "measurability", + "severity": "critical", + "validation_logic": "regex match #\\d+ or issues/#\\d+", + "error_message": "Entry must reference a PR or issue number (e.g., #1234 or issues/#5678). Add reference before changelog can be released.", + "examples": { + "pass": [ + "Fixed authentication timeout (#3350)", + "Added dark mode support (issues/#2890 #3351)", + "Improved performance (#3352 #3353)" + ], + "fail": [ + "Fixed authentication timeout", + "Added dark mode support", + "Improved database performance" + ] + } + }, + { + "rule_id": "CHK_FORMAT_MARKDOWN", + "rule_name": "Valid markdown", + "description": "Entries must have consistent markdown formatting and no raw HTML", + "dimension": "consistency", + "severity": "high", + "validation_logic": "no raw HTML tags; valid markdown syntax (balanced **, __, [], etc.)", + "error_message": "Entry has invalid markdown syntax or raw HTML. Check for balanced brackets, valid links, no raw HTML tags.", + "examples": { + "pass": [ + "Added **bold** and *italic* support (#3350)", + "Fixed issue with [link text](https://example.com) (#3351)", + "Updated `code` formatting in documentation (#3352)" + ], + "fail": [ + "Added bold support (raw HTML)", + "Fixed issue with [broken link](#3350", + "Updated code formatting in **documentation (#3352)" + ] + } + }, + { + "rule_id": "CHK_CONSISTENT_TENSE", + "rule_name": "Consistent verb tense", + "description": "Changelog entries must use consistent tense (typically past tense for completed work)", + "dimension": "consistency", + "severity": "medium", + "validation_logic": "detect tense changes within entry set; flag mixed past/present/future", + "error_message": "Entry uses inconsistent verb tense. Keep all entries in past tense for completed changes (e.g., 'Fixed', 'Added', 'Improved', not 'Fix', 'Add', 'Improve').", + "examples": { + "pass": [ + "Fixed authentication and improved performance", + "Added dark mode and updated documentation", + "Refactored API endpoints and deployed to production" + ], + "fail": [ + "Fixed authentication and improve performance (mixed past/present)", + "Added dark mode and update documentation", + "Refactor API endpoints and deploy to production" + ] + } + }, + { + "rule_id": "CHK_NO_ABBREVIATIONS", + "rule_name": "No unexplained abbreviations", + "description": "Flag uncommon 2+ uppercase consecutive letter sequences (except known acronyms)", + "dimension": "clarity", + "severity": "medium", + "validation_logic": "regex (?90% match) are flagged", + "dimension": "consistency", + "severity": "high", + "validation_logic": "compare content hashes; flag near-duplicates via Levenshtein distance >90% similarity", + "error_message": "Entry is very similar to existing entry. Ensure each changelog entry describes a unique change.", + "examples": { + "pass": [ + "Fixed authentication timeout issue (#3350)", + "Added dark mode toggle in settings (#3351)", + "Improved database query performance (#3352)" + ], + "fail": [ + "Fixed authentication timeout issue (#3350)", + "Fixed the authentication timeout problem (#3351) [duplicate of #3350]" + ] + } + }, + { + "rule_id": "CHK_LINK_VALIDITY", + "rule_name": "All links resolve", + "description": "PR/issue references must be valid and accessible; validates via GitHub API", + "dimension": "measurability", + "severity": "high", + "validation_logic": "GitHub API v3 GET /repos/lightspeedwp/.github/pulls/{number} or /issues/{number}; retry exponential backoff (2s, 4s, 8s); cache 24 hours", + "error_message": "Link '{reference}' could not be validated. PR/issue may be deleted, inaccessible, or incorrect. Verify reference and update if needed.", + "examples": { + "pass": [ + "Fixed auth timeout (#3350) [PR exists and returns 200 OK]", + "Added dark mode (#3351) [Issue exists and returns 200 OK]" + ], + "fail": [ + "Fixed auth timeout (#9999) [PR does not exist - 404]", + "Added feature (#3350) [PR deleted]" + ] + } + } + ] +} diff --git a/.github/validation/changelog/test/fixtures/changelog-entries.json b/.github/validation/changelog/test/fixtures/changelog-entries.json new file mode 100644 index 0000000000..ddf680c651 --- /dev/null +++ b/.github/validation/changelog/test/fixtures/changelog-entries.json @@ -0,0 +1,286 @@ +{ + "compliant_entries": [ + { + "id": "compliant-1", + "content": "Fixed user authentication timeout in login flow (#3350)", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [3350], + "issue_numbers": [] + }, + { + "id": "compliant-2", + "content": "Added support for dark mode in dashboard UI (#3351)", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [3351], + "issue_numbers": [] + }, + { + "id": "compliant-3", + "content": "Improved database query performance for reporting (optimised from 5s to 200ms) (#3352)", + "section": "Changed", + "version_section": "Unreleased", + "pr_numbers": [3352], + "issue_numbers": [] + }, + { + "id": "compliant-4", + "content": "Refactored authentication flow for better performance (#3353)", + "section": "Changed", + "version_section": "Unreleased", + "pr_numbers": [3353], + "issue_numbers": [] + }, + { + "id": "compliant-5", + "content": "Fixed user profile display issue when email is missing (#3354)", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [3354], + "issue_numbers": [] + }, + { + "id": "compliant-6", + "content": "Optimised REST API endpoint response time by implementing caching (#3355)", + "section": "Changed", + "version_section": "Unreleased", + "pr_numbers": [3355], + "issue_numbers": [] + }, + { + "id": "compliant-7", + "content": "Added **bold** and *italic* support in markdown formatting (#3356)", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [3356], + "issue_numbers": [] + }, + { + "id": "compliant-8", + "content": "Implemented automatic backup every 6 hours (#3357)", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [3357], + "issue_numbers": [] + }, + { + "id": "compliant-9", + "content": "Fixed authentication timeout and improved performance (#3358)", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [3358], + "issue_numbers": [] + }, + { + "id": "compliant-10", + "content": "Added dark mode support (issues/#2890 #3359)", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [3359], + "issue_numbers": [2890] + } + ], + "non_compliant_entries": [ + { + "id": "fail-max-length", + "content": "Fixed a critical issue in the authentication middleware where the JWT token validation was incorrectly checking the expiration timestamp against the system clock instead of the token's internal exp field, which caused valid tokens to be rejected after exactly 24 hours even though they should have been valid for 72 hours according to the issue tracker, resolving the user reports in #3350", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [3350], + "issue_numbers": [], + "expectedViolations": ["CHK_MAX_LENGTH"] + }, + { + "id": "fail-impl-details-function", + "content": "Refactored the React component hierarchy using custom hooks for state management (#3351)", + "section": "Changed", + "version_section": "Unreleased", + "pr_numbers": [3351], + "issue_numbers": [], + "expectedViolations": ["CHK_NO_IMPL_DETAILS"] + }, + { + "id": "fail-impl-details-database", + "content": "Fixed memory leak in the database connection pool module (#3352)", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [3352], + "issue_numbers": [], + "expectedViolations": ["CHK_NO_IMPL_DETAILS"] + }, + { + "id": "fail-no-pr-link", + "content": "Fixed authentication timeout", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [], + "issue_numbers": [], + "expectedViolations": ["CHK_HAS_PR_LINK"] + }, + { + "id": "fail-no-pr-link-added", + "content": "Added dark mode support", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [], + "issue_numbers": [], + "expectedViolations": ["CHK_HAS_PR_LINK"] + }, + { + "id": "fail-raw-html", + "content": "Added bold support in text formatting (#3353)", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [3353], + "issue_numbers": [], + "expectedViolations": ["CHK_FORMAT_MARKDOWN"] + }, + { + "id": "fail-broken-link", + "content": "Fixed issue with [broken link](#3354", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [3354], + "issue_numbers": [], + "expectedViolations": ["CHK_FORMAT_MARKDOWN"] + }, + { + "id": "fail-unbalanced-bold", + "content": "Updated code formatting in **documentation (#3355)", + "section": "Changed", + "version_section": "Unreleased", + "pr_numbers": [3355], + "issue_numbers": [], + "expectedViolations": ["CHK_FORMAT_MARKDOWN"] + }, + { + "id": "fail-mixed-tense", + "content": "Fixed authentication and improve performance (#3356)", + "section": "Changed", + "version_section": "Unreleased", + "pr_numbers": [3356], + "issue_numbers": [], + "expectedViolations": ["CHK_CONSISTENT_TENSE"] + }, + { + "id": "fail-present-tense", + "content": "Fix authentication and improve performance (#3357)", + "section": "Changed", + "version_section": "Unreleased", + "pr_numbers": [3357], + "issue_numbers": [], + "expectedViolations": ["CHK_CONSISTENT_TENSE"] + }, + { + "id": "fail-abbreviation-unexplained", + "content": "Fixed RATELIMIT issue in backend SVR (#3358)", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [3358], + "issue_numbers": [], + "expectedViolations": ["CHK_NO_ABBREVIATIONS"] + }, + { + "id": "fail-abbreviation-rbac", + "content": "Updated USR permissions for new RBAC system (#3359)", + "section": "Changed", + "version_section": "Unreleased", + "pr_numbers": [3359], + "issue_numbers": [], + "expectedViolations": ["CHK_NO_ABBREVIATIONS"] + }, + { + "id": "fail-duplicate", + "content": "Fixed authentication timeout issue (#3360)", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [3360], + "issue_numbers": [], + "isDuplicate": true, + "duplicateOf": "compliant-1" + }, + { + "id": "fail-impl-details-middleware", + "content": "Added transaction logging to the middleware function (#3361)", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [3361], + "issue_numbers": [], + "expectedViolations": ["CHK_NO_IMPL_DETAILS"] + }, + { + "id": "fail-impl-details-endpoint", + "content": "Optimised REST API endpoint response time by implementing query caching (#3362)", + "section": "Changed", + "version_section": "Unreleased", + "pr_numbers": [3362], + "issue_numbers": [], + "expectedViolations": ["CHK_NO_IMPL_DETAILS"] + } + ], + "edge_cases": [ + { + "id": "edge-empty", + "content": "", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [], + "issue_numbers": [], + "description": "Empty entry" + }, + { + "id": "edge-whitespace-only", + "content": " ", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [], + "issue_numbers": [], + "description": "Whitespace-only entry" + }, + { + "id": "edge-very-long", + "content": "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat (#9999)", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [9999], + "issue_numbers": [], + "description": "Very long entry exceeding 250 char limit" + }, + { + "id": "edge-multiple-pr-links", + "content": "Fixed auth timeout in PR #3360 and #3361 (#3362)", + "section": "Fixed", + "version_section": "Unreleased", + "pr_numbers": [3360, 3361, 3362], + "issue_numbers": [], + "description": "Entry with multiple PR references" + }, + { + "id": "edge-mixed-reference-types", + "content": "Added feature from issue #2890 implemented in PR #3363 (issues/#2891)", + "section": "Added", + "version_section": "Unreleased", + "pr_numbers": [3363], + "issue_numbers": [2890, 2891], + "description": "Entry with mixed PR and issue reference formats" + } + ], + "metadata": { + "version": "1.0.0", + "framework": "Keep a Changelog 1.1.0", + "total_compliant": 10, + "total_non_compliant": 15, + "total_edge_cases": 5, + "coverage": { + "CHK_MAX_LENGTH": 1, + "CHK_NO_IMPL_DETAILS": 5, + "CHK_HAS_PR_LINK": 2, + "CHK_FORMAT_MARKDOWN": 3, + "CHK_CONSISTENT_TENSE": 2, + "CHK_NO_ABBREVIATIONS": 2, + "CHK_UNIQUE_CONTENT": 1, + "CHK_LINK_VALIDITY": 0 + } + } +} diff --git a/.github/validation/changelog/validate.sh b/.github/validation/changelog/validate.sh new file mode 100755 index 0000000000..e64f49d615 --- /dev/null +++ b/.github/validation/changelog/validate.sh @@ -0,0 +1,139 @@ +#!/bin/bash + +# Changelog Validation Wrapper Script +# Framework: Keep a Changelog 1.1.0 +# Usage: ./validate.sh [--changelog-path PATH] [--trigger TRIGGER] [--pr-number NUMBER] + +set -euo pipefail + +# Color output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Defaults +CHANGELOG_PATH="CHANGELOG.md" +TRIGGER="manual" +PR_NUMBER="" +BRANCH="" +GITHUB_TOKEN="" +OUTPUT_FORMAT="text" +VERBOSE="false" + +# Parse arguments +while [[ $# -gt 0 ]]; do + case $1 in + --changelog-path) + CHANGELOG_PATH="$2" + shift 2 + ;; + --trigger) + TRIGGER="$2" + shift 2 + ;; + --pr-number) + PR_NUMBER="$2" + shift 2 + ;; + --branch) + BRANCH="$2" + shift 2 + ;; + --github-token) + GITHUB_TOKEN="$2" + shift 2 + ;; + --output) + OUTPUT_FORMAT="$2" + shift 2 + ;; + --verbose) + VERBOSE="true" + shift + ;; + --help) + print_usage + exit 0 + ;; + *) + echo -e "${RED}Unknown option: $1${NC}" + print_usage + exit 1 + ;; + esac +done + +print_usage() { + cat << EOF +Usage: ./validate.sh [OPTIONS] + +Options: + --changelog-path PATH Path to CHANGELOG.md (default: CHANGELOG.md) + --trigger TRIGGER Trigger type: manual, pr_submission, scheduled_audit (default: manual) + --pr-number NUMBER GitHub PR number (for pr_submission trigger) + --branch BRANCH Git branch name + --github-token TOKEN GitHub API token (for link validation) + --output FORMAT Output format: text, json (default: text) + --verbose Enable verbose output + --help Show this help message + +Examples: + ./validate.sh + ./validate.sh --changelog-path CHANGELOG.md --output json + ./validate.sh --trigger pr_submission --pr-number 3350 --github-token \${{ secrets.GITHUB_TOKEN }} +EOF +} + +# Validate changelog file exists +if [ ! -f "$CHANGELOG_PATH" ]; then + if [ "$OUTPUT_FORMAT" = "json" ]; then + echo "{\"error\": \"CHANGELOG.md not found at $CHANGELOG_PATH\", \"exit_code\": 1}" + else + echo -e "${RED}✗ CHANGELOG.md not found at $CHANGELOG_PATH${NC}" + fi + exit 1 +fi + +# Validate [Unreleased] section exists +if ! grep -q "^\## \[Unreleased\]" "$CHANGELOG_PATH"; then + if [ "$OUTPUT_FORMAT" = "json" ]; then + echo "{\"error\": \"[Unreleased] section not found in CHANGELOG.md\", \"exit_code\": 1}" + else + echo -e "${RED}✗ [Unreleased] section not found in CHANGELOG.md${NC}" + fi + exit 1 +fi + +# Call Node.js validation engine +SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" + +if [ "$VERBOSE" = "true" ]; then + echo -e "${BLUE}Calling Node.js validation engine...${NC}" + echo " Changelog: $CHANGELOG_PATH" + echo " Trigger: $TRIGGER" + echo " Output: $OUTPUT_FORMAT" +fi + +# Run validation (create empty bin/validate.js stub for now) +if [ -f "$SCRIPT_DIR/bin/validate.js" ]; then + node "$SCRIPT_DIR/bin/validate.js" \ + --changelog-path "$CHANGELOG_PATH" \ + --trigger "$TRIGGER" \ + $([ -n "$PR_NUMBER" ] && echo "--pr-number $PR_NUMBER" || true) \ + $([ -n "$BRANCH" ] && echo "--branch $BRANCH" || true) \ + $([ -n "$GITHUB_TOKEN" ] && echo "--github-token $GITHUB_TOKEN" || true) \ + --output "$OUTPUT_FORMAT" + EXIT_CODE=$? +else + # Fallback: basic validation until Node.js engine is ready + if [ "$OUTPUT_FORMAT" = "json" ]; then + echo '{"summary": {"total_entries": 0, "passed": 0, "failed": 0, "pass_rate": 0}, "ci_gate_result": "warning", "recommendation": "review"}' + else + echo -e "${YELLOW}⚠ Validation engine not yet initialized (Phase 2 implementation in progress)${NC}" + fi + EXIT_CODE=0 +fi + +exit $EXIT_CODE diff --git a/.github/workflows/changelog-validation.yml b/.github/workflows/changelog-validation.yml index 5c70077e21..0db02c29f5 100644 --- a/.github/workflows/changelog-validation.yml +++ b/.github/workflows/changelog-validation.yml @@ -3,9 +3,11 @@ name: Changelog Quality Validation on: pull_request: paths: - - 'CHANGELOG.yml' - 'CHANGELOG.md' - - 'agents/changelog/**' + - '.github/validation/changelog/**' + branches: + - develop + - main permissions: contents: read @@ -30,28 +32,29 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version-file: '.nvmrc' + node-version: '18' cache: 'npm' - cache-dependency-path: 'agents/changelog/package-lock.json' + cache-dependency-path: '.github/validation/changelog/package.json' - name: Install dependencies - run: npm ci --prefix agents/changelog + run: | + cd .github/validation/changelog + npm ci - name: Detect changed files id: changed-files uses: tj-actions/changed-files@v46 with: files: | - CHANGELOG.yml CHANGELOG.md - name: Validate changelog entries id: validate run: | - cd agents/changelog + cd .github/validation/changelog if [ "${{ steps.changed-files.outputs.any_changed }}" == "false" ]; then - echo "passed=true" >> $GITHUB_OUTPUT + echo "passed=0" >> $GITHUB_OUTPUT echo "failed=0" >> $GITHUB_OUTPUT echo "warnings=0" >> $GITHUB_OUTPUT echo "result=No changelog files modified in this PR" >> $GITHUB_OUTPUT @@ -59,24 +62,46 @@ jobs: fi # Run validation and capture results - RESULT=$(npm run validate:changelog -- --json 2>&1 || true) - - # Extract counts - PASSED=$(echo "$RESULT" | grep -o '"passed": *[0-9]*' | head -1 | grep -o '[0-9]*' || echo "0") - FAILED=$(echo "$RESULT" | grep -o '"failed": *[0-9]*' | head -1 | grep -o '[0-9]*' || echo "0") - WARNINGS=$(echo "$RESULT" | grep -o '"warnings": *[0-9]*' | head -1 | grep -o '[0-9]*' || echo "0") - - echo "passed=$PASSED" >> $GITHUB_OUTPUT - echo "failed=$FAILED" >> $GITHUB_OUTPUT - echo "warnings=$WARNINGS" >> $GITHUB_OUTPUT - - # Determine if validation passed - if [ "$FAILED" -gt 0 ]; then - echo "result=FAILED" >> $GITHUB_OUTPUT - exit 1 + node bin/validate.js \ + --changelog-path ../../../CHANGELOG.md \ + --trigger pr_submission \ + --pr-number ${{ github.event.pull_request.number }} \ + --branch ${{ github.head_ref }} \ + --github-token ${{ secrets.GITHUB_TOKEN }} \ + --output json --metrics > validation-report.json 2>validation-stderr.log || true + + # Parse report + if [ -f validation-report.json ]; then + PASSED=$(jq '.summary.passed' validation-report.json 2>/dev/null || echo "0") + FAILED=$(jq '.summary.failed' validation-report.json 2>/dev/null || echo "0") + RESULT=$(jq -r '.recommendation' validation-report.json 2>/dev/null || echo "UNKNOWN") + GATE_RESULT=$(jq -r '.ci_gate_result' validation-report.json 2>/dev/null || echo "UNKNOWN") + + echo "passed=$PASSED" >> $GITHUB_OUTPUT + echo "failed=$FAILED" >> $GITHUB_OUTPUT + echo "warnings=0" >> $GITHUB_OUTPUT + echo "result=$RESULT" >> $GITHUB_OUTPUT + echo "gate_result=$GATE_RESULT" >> $GITHUB_OUTPUT + + # Save full report for audit + cp validation-report.json ../../../.github/reports/validation-audits/latest-report.json 2>/dev/null || true + + # Exit based on gate result + if [ "$GATE_RESULT" == "pass" ]; then + exit 0 + elif [ "$GATE_RESULT" == "warning" ]; then + exit 0 + else + exit 1 + fi else - echo "result=PASSED" >> $GITHUB_OUTPUT - exit 0 + echo "passed=0" >> $GITHUB_OUTPUT + echo "failed=0" >> $GITHUB_OUTPUT + echo "warnings=0" >> $GITHUB_OUTPUT + echo "result=ERROR" >> $GITHUB_OUTPUT + echo "gate_result=UNKNOWN" >> $GITHUB_OUTPUT + [ -f validation-stderr.log ] && cat validation-stderr.log + exit 1 fi continue-on-error: true @@ -119,11 +144,11 @@ jobs: echo "" >> /tmp/comment.md echo "Run locally:" >> /tmp/comment.md echo "\`\`\`bash" >> /tmp/comment.md - echo "cd agents/changelog" >> /tmp/comment.md - echo "npm run validate:changelog" >> /tmp/comment.md + echo "cd .github/validation/changelog" >> /tmp/comment.md + echo "node bin/validate.js --changelog-path CHANGELOG.md" >> /tmp/comment.md echo "\`\`\`" >> /tmp/comment.md echo "" >> /tmp/comment.md - echo "See [Changelog Quality Audit](../docs/CHANGELOG_QUALITY_AUDIT.md) for details." >> /tmp/comment.md + echo "See [Changelog Quality Audit](.github/validation/changelog/docs/DEVELOPER_GUIDE.md) for details." >> /tmp/comment.md # Save comment content COMMENT_BODY=$(cat /tmp/comment.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74e3425169..7318f7a4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Changelog pre-release validation tools** — Added automated validator script and release manager checklist for pre-release changelog quality audits. (#3350) - **Branch Cleanup Audit** — Added spec for auditing and safely cleaning up 300+ stale branches, with KEEP/DELETE/DISCUSS categorisation and safety checks before deletion. ([PR #3128](https://github.com/lightspeedwp/.github/pull/3128)) - **Branch Naming Strategy Phase 3 — Configuration & Validation** — Completed Phase 3 of the Branch Naming Strategy & Enforcement feature by generating comprehensive configuration files and validating against canonical infrastructure. Deliverables: (1) **Branch Types Configuration** (`.github/branch-types.yml`) — Single source of truth mapping all 24 authorized branch types (feat, fix, hotfix, release, refactor, chore, task, docs, test, perf, ci, build, deps, security, design, a11y, ux, i18n, ops, proto, ds, audit, codex, revert, research) to their corresponding PR template files with descriptions and examples for each type. All 24 types routed to 16 existing PR template files with fallback template specified (pr_feature.md). Validation: All 16 referenced templates verified to exist in `.github/PULL_REQUEST_TEMPLATE/` (16/16 ✓). (2) **Branch Labels Configuration** (`.github/branch-labels.yml`) — Single source of truth mapping all 24 branch types to canonical default labels and area detection keywords, enabling automatic label application on PR creation. Default labels include type labels (type:feature, type:bug, etc.) and priority labels (priority:critical for hotfix/security) with 26 total labels verified against canonical set. Area keyword detection maps scope keywords to area labels (e.g., "api" → area:integration, "auth" → area:security) across 9 area categories. All 26 labels verified to exist in `.github/labels.yml` (26/26 ✓). (3) **Phase 1 Design Completion** — Finalized Phase 1 design tasks (T007-T017) including audit reports, PR template routing verification, and label validation; marked complete in tasks.md. (4) **Enforcement Documentation Completion** — Finalized Phase 1 enforcement documentation tasks (T044-T045) with comprehensive guides for branch validation, error handling, and troubleshooting. (5) **Configuration Validation** — Executed comprehensive validation pipeline: Template file inventory audit confirming all 16 routing targets exist; Label audit confirming all 26 referenced labels exist in canonical set with proper family prefixes (type:, area:, priority:); Cross-reference validation between branch-types.yml and branch-labels.yml ensuring consistency. **Impact:** Enables Phase 3 workflow implementation (T052-T059) and Phase 3 integration testing (T060-T063); provides single source of truth for PR template and label routing across all 24 branch types; supports automated enforcement via GitHub Actions workflows. See [Branch Naming Strategy Spec](.github/specs/004-branch-naming-strategy/spec.md), [PR Template Routing Audit](.github/projects/active/branch-naming-strategy/PR_TEMPLATE_ROUTING_AUDIT.md), and [Label Audit Report](.github/projects/active/branch-naming-strategy/LABEL_AUDIT_REPORT.md). - **Workflow Consolidation Phase 2 — Unified Workflow Implementation & Branch Strategy** — Comprehensive implementation of unified GitHub Actions workflows consolidating 71 archived workflows into 5 unified consolidated workflows, reducing maintenance burden by 82% and GitHub Actions minutes by ≥15% (hard requirement: ≤2,125/month). Phase 2 Phase 1-2 completion includes: (1) **Phase 1 Infrastructure** — Established baseline measurement (`BASELINE_METRICS.md`), rollback procedure (`PHASE2_ROLLBACK.md`), metrics tracking script (`measure-actions-minutes.sh`), and Phase 1 completion with 71 workflows archived and verified. (2) **Phase 2 Foundational Deliverables** — Implemented 4 composite actions (`apply-labels`, `validate-check`, `aggregate-tests`, `collect-metrics`) with full API contracts and error handling; created workflow test harness (`workflow-harness.yml`) with multi-workflow validation; established error isolation test framework (`error-isolation-test.yml`); documented consolidation matrix mapping 71→5 workflows; defined per-workflow GitHub Actions minute budgets (labeling: ≤46min, validation: ≤62min, testing: ≤490min, linting: ≤30min, quality-gates: ≤100min). (3) **Specification & Planning** — Created comprehensive feature specification with success criteria, clarifications log, and acceptance gates; documented 7-phase implementation plan (Setup, Foundational, US1-US5, Integration) with critical dependencies and parallel execution paths (US2 & US3 parallel); tracked 78 tasks across all phases (T001-T078). (4) **Branch Strategy Documentation** — Defined explicit single-feature-branch strategy (`refactor/workflow-consolidation-phase-2`) with merge-commit preservation of phase history, parallel commit coordination for US2/US3, upstream change handling (merge develop when needed), hard approval gates (≥15% reduction + CI ≥3 passes before merge), and comprehensive rollback procedures. **Deliverables**: 5 unified workflows (labeling, validation, testing, linting, quality-gates), 4 composite actions with full contracts, 8 documentation files (performance targets, consolidation matrix, composite action specs, rollback guides), test harness and error isolation framework, 78 tracked tasks. **Success Criteria**: Phase 1-2 complete; Phase 3-7 planned and tracked; hard requirements documented (≥15% reduction, error isolation, rollback capability, CI validation). See specification: `.github/specs/011-workflow-consolidation-phase-2/spec.md`; plan: `plan.md`; tasks: `tasks.md`. ([PR #3308](https://github.com/lightspeedwp/.github/pull/3308)) diff --git a/docs/CHANGELOG_PRE_RELEASE_CHECKLIST.md b/docs/CHANGELOG_PRE_RELEASE_CHECKLIST.md new file mode 100644 index 0000000000..6fb92b4430 --- /dev/null +++ b/docs/CHANGELOG_PRE_RELEASE_CHECKLIST.md @@ -0,0 +1,304 @@ +--- +title: Changelog Pre-Release Audit Checklist +description: Step-by-step process for finalizing and validating changelog entries before release +version: 1.0.0 +date: 2026-09-17 +--- + +# Changelog Pre-Release Audit Checklist + +**Release Manager:** _________________ +**Release Date:** _________________ +**Target Version:** _________________ + +--- + +## Overview + +This checklist ensures changelog entries meet quality standards before release: + +- **250 character maximum** per entry +- **PR links** present and valid +- **Issue links** present where applicable +- **No implementation details** (technical jargon, code references) +- **User-focused language** (what customer/user gets, not how it was built) + +**Current Status:** Use `node scripts/changelog-pre-release-validator.js` to generate baseline report + +--- + +## Phase 1: Pre-Audit Preparation + +- [ ] **1.1** Run baseline validation report: + + ```bash + node scripts/changelog-pre-release-validator.js --changelog CHANGELOG.md --format text + ``` + + Save output to: `_baseline-report.txt` + +- [ ] **1.2** Document baseline metrics: + - Total entries: __________ + - Compliant entries: __________ + - Compliance %: __________ + - Critical issues: __________ + +- [ ] **1.3** Review CHANGELOG.md structure: + - [ ] `[Unreleased]` section exists + - [ ] Section headers present (Added, Changed, Fixed, etc.) + - [ ] All entries formatted as markdown list items (`-`) + +--- + +## Phase 2: Entry-by-Entry Audit + +### Instructions + +For each entry in the [Unreleased] section, complete the following checks: + +| # | Entry Text | Length | ✓ PR Link | ✓ Issue Link | Impl Details? | Action | Status | +|---|---|---|---|---|---|---|---| +| 1 | | / 250 | [ ] | [ ] | [ ] | | | +| 2 | | / 250 | [ ] | [ ] | [ ] | | | +| 3 | | / 250 | [ ] | [ ] | [ ] | | | +| 4 | | / 250 | [ ] | [ ] | [ ] | | | +| 5 | | / 250 | [ ] | [ ] | [ ] | | | +| 6 | | / 250 | [ ] | [ ] | [ ] | | | +| 7 | | / 250 | [ ] | [ ] | [ ] | | | +| 8 | | / 250 | [ ] | [ ] | [ ] | | | + +**Action codes:** + +- `OK` — Entry compliant, no changes needed +- `EDIT` — Entry requires refactoring +- `ADD-LINK` — Entry needs issue link added +- `REWRITE` — Entry exceeds 250 chars or contains implementation details + +--- + +## Phase 3: Detailed Refactoring + +For each entry marked `EDIT` or `REWRITE`, complete one refactoring task: + +### Task: Refactor Entry #_____ + +**Original entry:** + +``` +[paste full entry text here] +``` + +**Issues to fix:** + +- [ ] Length: ____ chars (over by ____ chars) +- [ ] Missing PR link: ____ +- [ ] Missing issue link: ____ +- [ ] Implementation keywords: ____________________ + +**Refactoring instructions:** + +1. **Identify the user impact** — What does the user/customer benefit from this change? + + *Answer:* + +2. **Remove implementation details** — Delete technical jargon (code files, method names, framework-specific terms, library references): + + Original: ___________________________________________________________________ + + Cleaned: ____________________________________________________________________ + +3. **Add PR link** if missing: + + ``` + [GitHub PR](https://github.com/lightspeedwp/.github/pull/NNNN) + ``` + +4. **Add issue link** if missing (check related PR for linked issues): + + ``` + [GitHub Issue](https://github.com/lightspeedwp/.github/issues/NNNN) + ``` + +5. **Refactored entry** (must be ≤ 250 chars): + + ``` + [new entry text with links] + ``` + +6. **Verification:** + - [ ] Length ≤ 250 chars: ____ chars + - [ ] Contains PR link: [ ] + - [ ] Contains issue link: [ ] + - [ ] No implementation keywords: [ ] + - [ ] User-focused language: [ ] + +--- + +## Phase 4: Add Missing Issue Links + +Some entries may have PR links but missing issue links. Follow this workflow: + +### Task: Add Issue Link to Entry #_____ + +**Current entry:** + +``` +[entry text] +``` + +**Workflow:** + +1. Extract PR number from entry: + + ``` + PR #: ____ + ``` + +2. Fetch PR details to find linked issues: + + ```bash + gh pr view NNNN --json body,title,commits + ``` + +3. Parse PR body for issue references (look for `closes #NNNN` or `fixes #NNNN`): + + ``` + Issue(s) found: #___, #___, #___ + ``` + +4. If no issues found, search for related issues by keyword: + + ```bash + gh issue list --search "KEYWORD" + ``` + +5. Add issue link to entry: + + ```markdown + - [User-facing description](#NNNN, [Issue #NNNN](https://github.com/lightspeedwp/.github/issues/NNNN)) + ``` + +6. **Verification:** + - [ ] Issue link is valid (returns GitHub issue page) + - [ ] Issue is semantically related to the change + - [ ] Entry length still ≤ 250 chars + +--- + +## Phase 5: Final Validation + +- [ ] **5.1** Run final validation report: + + ```bash + node scripts/changelog-pre-release-validator.js --changelog CHANGELOG.md --format text + ``` + + Save output to: `_final-report.txt` + +- [ ] **5.2** Document final metrics: + - Total entries: __________ + - Compliant entries: __________ + - Compliance %: __________ + - Issues remaining: __________ + +- [ ] **5.3** Compliance gates: + - [ ] Compliance ≥ 95% **OR** executive approval for < 95% + - [ ] All error-level violations resolved + - [ ] All warning-level violations reviewed + - [ ] Zero entries with implementation details + +- [ ] **5.4** Release decision: + - [ ] **✓ APPROVED** — Proceed to version bump and release + - [ ] **⚠ CONDITIONAL** — Approved with documented exceptions: ________________________ + - [ ] **✗ BLOCKED** — Cannot proceed; issues must be resolved first + +--- + +## Phase 6: Release Handoff + +- [ ] **6.1** Copy final validated CHANGELOG.md to release notes template + +- [ ] **6.2** Generate release notes in GitHub: + + ```bash + # Automated (planned for Phase 5): + node agents/changelog/changelog.agent.js processChangelog ./CHANGELOG.md VERSION DATE + ``` + +- [ ] **6.3** Verify release notes: + - [ ] All entries appear correctly + - [ ] PR links are clickable and valid + - [ ] Issue links are clickable and valid + - [ ] Formatting is consistent (em-dashes, capitalization) + +- [ ] **6.4** Publish release to GitHub Releases + +--- + +## Reference: Implementation Keyword Blocklist + +The validator flags these keywords as implementation details (rewrite entry if present): + +``` +refactored, optimised, optimized, patched, implemented, deployed, +migrated, restructured, reorganised, reorganized, logic, algorithm, +framework, component, module, hook, middleware, REST API, GraphQL, +database, query, cache, transaction, service, endpoint, function, +class, method, refactor, update, fix, modify, change +``` + +If your entry needs one of these words, reframe it from the **user perspective**: + +| ❌ Don't Say | ✅ Say Instead | +|---|---| +| "Refactored authentication logic" | "Improved login reliability and speed" | +| "Fixed database query performance" | "Faster data loading in reports" | +| "Updated REST API response format" | "Cleaner integration responses" | +| "Implemented caching mechanism" | "Faster page loads and reduced server load" | +| "Migrated to GraphQL" | "Simplified API with flexible queries" | + +--- + +## Sign-Off + +**Release Manager:** _________________________ **Date:** _________ + +**Release Lead:** _________________________ **Date:** _________ + +**QA Sign-off:** _________________________ **Date:** _________ + +--- + +## Appendix A: Automated Validation Script + +**Location:** `scripts/changelog-pre-release-validator.js` + +**Usage:** + +```bash +# Text report (colored terminal output) +node scripts/changelog-pre-release-validator.js --changelog CHANGELOG.md --format text + +# JSON report (for parsing by other tools) +node scripts/changelog-pre-release-validator.js --changelog CHANGELOG.md --format json +``` + +**Exit codes:** + +- `0` — Compliance ≥ 95% (release approved) +- `1` — Compliance < 95% (release blocked, requires review) + +--- + +## Appendix B: Related Resources + +- **[Phase 5 Changelog Quality Audit Spec](../.github/specs/003-changelog-quality-audit/spec.md)** — Full specification with 7-week implementation roadmap +- **[Changelog Agent README](../agents/changelog/README.md)** — API reference and validation rules +- **[Keep a Changelog Standard](https://keepachangelog.com/en/1.1.0/)** — Format specification +- **[GitHub PR Template](.github/PULL_REQUEST_TEMPLATE/)** — Template structure for PR links + +--- + +*Maintained by 🚀 LightSpeedWP Automation Team — Changelog Quality Assurance* + +*Last updated: 2026-09-17* diff --git a/scripts/changelog-pre-release-validator.js b/scripts/changelog-pre-release-validator.js new file mode 100755 index 0000000000..975989867d --- /dev/null +++ b/scripts/changelog-pre-release-validator.js @@ -0,0 +1,350 @@ +#!/usr/bin/env node + +/** + * Pre-Release Changelog Validator + * + * Validates changelog entries before release: + * - Entry length (max 250 chars) + * - PR link presence + * - Issue link detection + * - Implementation keyword detection + * - Generates compliance report for release manager + * + * Usage: node scripts/changelog-pre-release-validator.js [--changelog CHANGELOG.md] [--format json|text] + */ + +import fs from 'fs'; + +// Configuration +const CONFIG = { + MAX_ENTRY_LENGTH: 250, + IMPLEMENTATION_KEYWORDS: [ + 'refactored', + 'optimised', + 'optimized', + 'patched', + 'implemented', + 'deployed', + 'migrated', + 'restructured', + 'reorganised', + 'reorganized', + 'logic', + 'algorithm', + 'framework', + 'component', + 'module', + 'hook', + 'middleware', + 'REST API', + 'GraphQL', + 'database', + 'query', + 'cache', + 'transaction', + 'service', + 'endpoint', + 'function', + 'class', + 'method', + 'refactor', + 'update', + 'fix', + 'modify', + 'change', + ], +}; + +// ANSI colors for terminal output +const COLORS = { + reset: '\x1b[0m', + red: '\x1b[31m', + green: '\x1b[32m', + yellow: '\x1b[33m', + blue: '\x1b[34m', + cyan: '\x1b[36m', +}; + +class ChangelogValidator { + constructor(changelogPath = 'CHANGELOG.md') { + this.changelogPath = changelogPath; + this.entries = []; + this.report = { + totalEntries: 0, + compliantEntries: 0, + issues: [], + warnings: [], + timestamp: new Date().toISOString(), + }; + } + + /** + * Read and parse changelog file + */ + parseChangelog() { + try { + const content = fs.readFileSync(this.changelogPath, 'utf8'); + this.entries = this._extractEntries(content); + this.report.totalEntries = this.entries.length; + return true; + } catch (err) { + this.report.issues.push(`Failed to read changelog: ${err.message}`); + return false; + } + } + + /** + * Extract entries from changelog content + */ + _extractEntries(content) { + const entries = []; + const unreleasedMatch = content.match(/## \[Unreleased\]([\s\S]*?)(?=## \[|$)/); + + if (!unreleasedMatch) { + this.report.issues.push('No [Unreleased] section found in changelog'); + return entries; + } + + const unreleasedContent = unreleasedMatch[1]; + const lines = unreleasedContent.split('\n'); + + let currentCategory = null; + for (const line of lines) { + const categoryMatch = line.match(/^### (Added|Changed|Fixed|Deprecated|Removed|Security)/); + if (categoryMatch) { + currentCategory = categoryMatch[1]; + continue; + } + + if (line.match(/^- /)) { + const text = line.replace(/^- /, '').trim(); + if (text) { + entries.push({ + text, + category: currentCategory || 'Uncategorized', + lineNumber: lines.indexOf(line), + }); + } + } + } + + return entries; + } + + /** + * Validate all entries + */ + validateAll() { + this.report.compliantEntries = 0; + this.report.issues = []; + this.report.warnings = []; + + for (let i = 0; i < this.entries.length; i++) { + const entry = this.entries[i]; + const violations = this._validateEntry(entry, i); + + if (violations.length === 0) { + this.report.compliantEntries++; + } else { + this.report.issues.push({ + entryIndex: i, + text: entry.text, + category: entry.category, + violations, + }); + } + } + + this.report.compliancePercent = + this.report.totalEntries > 0 + ? Math.round((this.report.compliantEntries / this.report.totalEntries) * 100) + : 100; + + return this.report; + } + + /** + * Validate single entry + */ + _validateEntry(entry, index) { + const violations = []; + + // Check length + if (entry.text.length > CONFIG.MAX_ENTRY_LENGTH) { + violations.push({ + code: 'ENTRY_TOO_LONG', + severity: 'error', + message: `Entry exceeds 250 chars (${entry.text.length} chars)`, + suggestion: 'Rewrite entry as user-focused summary without implementation details', + }); + } + + // Check for PR link + const prMatch = entry.text.match(/#\d+/); + if (!prMatch) { + violations.push({ + code: 'MISSING_PR_LINK', + severity: 'error', + message: 'No PR link found (expected #NNNN format)', + suggestion: 'Add PR reference: #NNNN', + }); + } + + // Check for issue link + const issueMatch = entry.text.match(/issue\s*#?\d+|#\d+\s*\(issue\)/i); + if (!issueMatch && !entry.text.match(/\[#\d+\]\(.*\/issues\/\d+\)/)) { + // Don't fail, but warn + this.report.warnings.push({ + entryIndex: index, + code: 'MISSING_ISSUE_LINK', + severity: 'warning', + message: 'No issue link detected', + suggestion: 'Add issue reference or link to related GitHub issue', + }); + } + + // Check for implementation keywords + const foundKeywords = CONFIG.IMPLEMENTATION_KEYWORDS.filter((kw) => + entry.text.toLowerCase().includes(kw.toLowerCase()) + ); + if (foundKeywords.length > 0) { + violations.push({ + code: 'IMPLEMENTATION_DETAILS', + severity: 'error', + message: `Entry contains implementation keywords: ${foundKeywords.join(', ')}`, + suggestion: 'Rewrite from user perspective, avoid technical implementation details', + }); + } + + // Check for minimum content + if (entry.text.trim().length < 10) { + violations.push({ + code: 'ENTRY_TOO_SHORT', + severity: 'error', + message: 'Entry text too short', + suggestion: 'Provide clear, user-focused description of change', + }); + } + + return violations; + } + + /** + * Format report as text + */ + formatAsText() { + let output = '\n'; + output += `${COLORS.cyan}════════════════════════════════════════${COLORS.reset}\n`; + output += `${COLORS.cyan} CHANGELOG PRE-RELEASE VALIDATION REPORT${COLORS.reset}\n`; + output += `${COLORS.cyan}════════════════════════════════════════${COLORS.reset}\n\n`; + + // Summary + const compliance = this.report.compliancePercent; + const complianceColor = + compliance >= 95 ? COLORS.green : compliance >= 80 ? COLORS.yellow : COLORS.red; + + output += `${COLORS.blue}Summary:${COLORS.reset}\n`; + output += ` Total Entries: ${this.report.totalEntries}\n`; + output += ` Compliant Entries: ${this.report.compliantEntries}\n`; + output += ` Compliance: ${complianceColor}${compliance}%${COLORS.reset}\n`; + output += ` Warnings: ${this.report.warnings.length}\n\n`; + + // Issues + if (this.report.issues.length > 0) { + output += `${COLORS.red}${this.report.issues.length} Non-Compliant Entries:${COLORS.reset}\n`; + output += '─'.repeat(70) + '\n'; + + for (const issue of this.report.issues) { + output += `\n${COLORS.yellow}Entry ${issue.entryIndex + 1} [${issue.category}]:${COLORS.reset}\n`; + output += ` "${issue.text}"\n`; + output += ` ${COLORS.red}Issues:${COLORS.reset}\n`; + + for (const violation of issue.violations) { + output += ` • [${violation.code}] ${violation.message}\n`; + output += ` → ${violation.suggestion}\n`; + } + } + } else { + output += `${COLORS.green}✓ All entries are compliant!${COLORS.reset}\n`; + } + + // Warnings + if (this.report.warnings.length > 0) { + output += `\n${COLORS.yellow}Warnings (${this.report.warnings.length}):${COLORS.reset}\n`; + output += '─'.repeat(70) + '\n'; + + for (const warning of this.report.warnings) { + output += ` • [${warning.code}] Entry ${warning.entryIndex + 1}\n`; + output += ` ${warning.message}\n`; + } + } + + // Release decision + output += '\n' + '═'.repeat(70) + '\n'; + if (compliance >= 95) { + output += `${COLORS.green}✓ RELEASE APPROVED${COLORS.reset} - Changelog meets quality standards\n`; + } else if (compliance >= 80) { + output += `${COLORS.yellow}⚠ RELEASE WITH CAUTION${COLORS.reset} - ${100 - compliance}% of entries need review\n`; + } else { + output += `${COLORS.red}✗ RELEASE BLOCKED${COLORS.reset} - Changelog does not meet quality standards\n`; + } + output += '═'.repeat(70) + '\n\n'; + + return output; + } + + /** + * Format report as JSON + */ + formatAsJson() { + return JSON.stringify(this.report, null, 2); + } + + /** + * Run full validation + */ + run() { + if (!this.parseChangelog()) { + return this.report; + } + return this.validateAll(); + } +} + +// Main execution +async function main() { + const args = process.argv.slice(2); + let changelogPath = 'CHANGELOG.md'; + let format = 'text'; + + // Parse arguments + for (let i = 0; i < args.length; i++) { + if (args[i] === '--changelog' && args[i + 1]) { + changelogPath = args[i + 1]; + i++; + } else if (args[i] === '--format' && args[i + 1]) { + format = args[i + 1]; + i++; + } + } + + const validator = new ChangelogValidator(changelogPath); + const report = validator.run(); + + if (format === 'json') { + console.log(validator.formatAsJson()); + } else { + console.log(validator.formatAsText()); + } + + // Exit with appropriate code + const complianceOK = report.compliancePercent >= 95; + process.exit(complianceOK ? 0 : 1); +} + +main().catch((err) => { + console.error(`${COLORS.red}Error:${COLORS.reset}`, err.message); + process.exit(1); +}); + +export { ChangelogValidator };