From 27deab1a0d35711843de73524ec76a2ab3c97cfe Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 4 May 2026 10:06:06 -0700 Subject: [PATCH] chore(copyright): add sync-header-years script and tests chore(copyright): wire sync script into lefthook and add package scripts feat(eslint): add inline ping-copyright rule to eslint.config.mjs --- eslint.config.mjs | 44 ++++- lefthook.yml | 3 + package.json | 3 +- pnpm-lock.yaml | 91 +++++----- tools/copyright/sync-header-years.mjs | 191 +++++++++++++++++++++ tools/copyright/sync-header-years.test.mjs | 158 +++++++++++++++++ 6 files changed, 449 insertions(+), 41 deletions(-) create mode 100644 tools/copyright/sync-header-years.mjs create mode 100644 tools/copyright/sync-header-years.test.mjs diff --git a/eslint.config.mjs b/eslint.config.mjs index b38bd77ab9..aa87fd3a59 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Ping Identity Corporation. All rights reserved. + * Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved. * * This software may be modified and distributed under the terms * of the MIT license. See the LICENSE file for details. @@ -33,6 +33,48 @@ export default [ '**/test-output', ], }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], + ignores: [ + '**/*.test.*', + '**/*.spec.*', + '**/vite.config.*', + '**/vitest.config.*', + '**/vitest.setup.*', + '**/eslint.config.*', + ], + plugins: { + 'local-rules': { + rules: { + 'ping-copyright': { + meta: { + type: 'suggestion', + messages: { missing: 'Missing Ping Identity copyright header.' }, + }, + create(context) { + return { + Program(node) { + const src = context.getSourceCode(); + const comments = src.getAllComments(); + const first = comments[0]; + if ( + !first || + first.range[0] > 0 || + !/Copyright[\s\S]*Ping Identity/i.test(first.value) + ) { + context.report({ node, messageId: 'missing' }); + } + }, + }; + }, + }, + }, + }, + }, + rules: { + 'local-rules/ping-copyright': 'warn', + }, + }, ...compat.extends('plugin:@typescript-eslint/recommended', 'plugin:prettier/recommended'), { plugins: { diff --git a/lefthook.yml b/lefthook.yml index 39a02c602d..109be39aef 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -2,6 +2,9 @@ pre-commit: commands: nx-sync: run: pnpm nx sync + copyright-sync: + run: node tools/copyright/sync-header-years.mjs --fix + stage_fixed: true nx-check: run: pnpm nx affected -t typecheck lint build api-report --tui=false stage_fixed: true diff --git a/package.json b/package.json index 47c92e2bfa..aa65ebe6b3 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "circular-dep-check": "madge --circular .", "clean": "shx rm -rf ./{coverage,dist,docs,node_modules,tmp}/ ./{packages,e2e}/*/{dist,node_modules}/ ./e2e/node_modules/ && git clean -fX -e \"!.env*,nx-cloud.env\" -e \"!**/GEMINI.md\"", "commit": "git cz", + "copyright:check": "node tools/copyright/sync-header-years.mjs --check", + "copyright:sync": "node tools/copyright/sync-header-years.mjs --fix", "commitlint": "commitlint --edit", "create-package": "nx g @nx/js:library", "format": "pnpm nx format:write", @@ -45,7 +47,6 @@ "path": "./node_modules/cz-conventional-changelog" } }, - "dependencies": {}, "devDependencies": { "@changesets/changelog-github": "^0.6.0", "@changesets/cli": "^2.27.9", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b0fdeec512..9740c91512 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,46 +5,15 @@ settings: excludeLinksFromLockfile: false catalogs: - default: - '@reduxjs/toolkit': - specifier: ^2.8.2 - version: 2.10.1 - immer: - specifier: ^10.1.1 - version: 10.2.0 - msw: - specifier: ^2.5.1 - version: 2.12.1 effect: '@effect/cli': specifier: ^0.69.0 version: 0.69.2 - '@effect/language-service': - specifier: ^0.35.2 - version: 0.35.2 - '@effect/opentelemetry': - specifier: ^0.56.1 - version: 0.56.6 - '@effect/platform': - specifier: ^0.90.0 - version: 0.90.10 - '@effect/platform-node': - specifier: 0.94.2 - version: 0.94.2 - '@effect/vitest': - specifier: ^0.27.0 - version: 0.27.0 - effect: - specifier: ^3.20.0 - version: 3.20.0 vite: vite: specifier: ^7.3.2 version: 7.3.2 vitest: - '@vitest/coverage-v8': - specifier: ^3.2.0 - version: 3.2.4 vitest: specifier: ^3.2.0 version: 3.2.4 @@ -598,10 +567,10 @@ importers: version: 28.0.0 tsx: specifier: ^4.20.0 - version: 4.21.0 + version: 4.20.6 vitest: specifier: catalog:vitest - version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + version: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) devDependencies: '@forgerock/javascript-sdk': specifier: 4.9.0 @@ -8223,6 +8192,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-compile-cache-lib@3.0.1: @@ -11974,7 +11944,7 @@ snapshots: std-env: 3.10.0 test-exclude: 7.0.1 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) transitivePeerDependencies: - supports-color @@ -11995,14 +11965,14 @@ snapshots: msw: 2.12.1(@types/node@24.9.2)(typescript@5.8.3) vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) - '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1))': + '@vitest/mocker@3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.1(@types/node@24.9.2)(typescript@5.9.3) - vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) '@vitest/pretty-format@3.2.4': dependencies: @@ -12033,7 +12003,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.15 tinyrainbow: 2.0.0 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) '@vitest/utils@3.2.4': dependencies: @@ -17511,7 +17481,7 @@ snapshots: dependencies: cssfontparser: 1.2.1 moo-color: 1.0.3 - vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1) + vitest: 3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.8.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1): dependencies: @@ -17556,11 +17526,54 @@ snapshots: - tsx - yaml + vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1): + dependencies: + '@types/chai': 5.2.3 + '@vitest/expect': 3.2.4 + '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1)) + '@vitest/pretty-format': 3.2.4 + '@vitest/runner': 3.2.4 + '@vitest/snapshot': 3.2.4 + '@vitest/spy': 3.2.4 + '@vitest/utils': 3.2.4 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.2.2 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + vite-node: 3.2.4(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.9.2 + '@vitest/ui': 3.2.4(vitest@3.2.4) + jsdom: 27.4.0(@noble/hashes@1.8.0) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + vitest@3.2.4(@types/node@24.9.2)(@vitest/ui@3.2.4)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.21.0)(yaml@2.8.1)) + '@vitest/mocker': 3.2.4(msw@2.12.1(@types/node@24.9.2)(typescript@5.9.3))(vite@7.3.2(@types/node@24.9.2)(jiti@2.6.1)(terser@5.46.2)(tsx@4.20.6)(yaml@2.8.1)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 diff --git a/tools/copyright/sync-header-years.mjs b/tools/copyright/sync-header-years.mjs new file mode 100644 index 0000000000..0a07d731cd --- /dev/null +++ b/tools/copyright/sync-header-years.mjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node + +import { execFileSync } from 'node:child_process'; +import { readFileSync, statSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { pathToFileURL } from 'node:url'; + +function isCliExecution() { + return process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +} + +function run() { + const args = new Set(process.argv.slice(2)); + const checkOnly = args.has('--check'); + const currentYear = new Date().getFullYear(); + + const stagedFiles = getStagedFiles(); + const stagedFileData = []; + const invalidFiles = []; + const changedFiles = []; + + for (const file of stagedFiles) { + if (!isFile(file) || isExcluded(file)) { + continue; + } + const absolutePath = resolve(process.cwd(), file); + const original = safeReadUtf8(absolutePath); + if (original === null) { + continue; + } + stagedFileData.push({ file, absolutePath, original }); + if (hasInvalidPingCopyrightHeader(original)) { + invalidFiles.push(file); + } + } + + if (invalidFiles.length > 0) { + console.error('Invalid Ping copyright header year format in staged files:'); + for (const file of invalidFiles) { + console.error(`- ${file}`); + } + process.exit(1); + } + + const missingHeaderFiles = []; + for (const { file, original } of stagedFileData) { + if (SOURCE_FILE_PATTERN.test(file) && !hasPingCopyrightHeader(original)) { + missingHeaderFiles.push(file); + } + } + + if (checkOnly && missingHeaderFiles.length > 0) { + console.error('Missing Ping copyright header in staged files:'); + for (const file of missingHeaderFiles) { + console.error(`- ${file}`); + } + process.exit(1); + } + + for (const { file, absolutePath, original } of stagedFileData) { + const updated = updateCopyrightYears(original, currentYear); + if (updated === original) { + continue; + } + changedFiles.push(file); + if (!checkOnly) { + writeFileSync(absolutePath, updated, 'utf8'); + } + } + + if (!checkOnly && changedFiles.length > 0) { + execFileSync('git', ['add', '--', ...changedFiles], { stdio: 'inherit' }); + } + + if (checkOnly && changedFiles.length > 0) { + console.error('Stale Ping copyright years found in staged files:'); + for (const file of changedFiles) { + console.error(`- ${file}`); + } + process.exit(1); + } +} + +function getStagedFiles() { + const output = execFileSync('git', ['diff', '--cached', '--name-only', '--diff-filter=ACMR'], { + encoding: 'utf8', + }).trim(); + + if (!output) { + return []; + } + return output.split('\n').filter(Boolean); +} + +export function isExcluded(filePath) { + return EXCLUDE_PATTERNS.some((pattern) => pattern.test(filePath)); +} + +const EXCLUDE_PATTERNS = [ + /\.test\.[cm]?[jt]sx?$/i, + /\.spec\.[cm]?[jt]sx?$/i, + /(^|[/\\])dist[/\\]/, + /(^|[/\\])vendor[/\\]/, + /(^|[/\\])node_modules[/\\]/, + /(^|[/\\])tools[/\\]/, + /(^|[/\\])_polyfills[/\\]/, + /(^|[/\\])vite[^/\\]*\.config\.[cm]?[jt]sx?$/i, + /(^|[/\\])vitest\.setup\.[cm]?[jt]sx?$/i, + /(^|[/\\])playwright\.config\.[cm]?[jt]sx?$/i, +]; + +function isFile(filePath) { + try { + return statSync(filePath).isFile(); + } catch { + return false; + } +} + +function safeReadUtf8(filePath) { + try { + return readFileSync(filePath, 'utf8'); + } catch { + return null; + } +} + +export function updateCopyrightYears(content, year) { + const regex = + /(^.*(?:©\s*|©\s*)?Copyright(?:\s*\(c\))?\s+)(\d{4})(?:([ \t]*-[ \t]*)(\d{4}))?(\s+Ping Identity(?: Corporation)?\b.*$)/gim; + + return content.replace(regex, (_, prefix, startYear, separator, endYear, suffix) => { + const start = Number.parseInt(startYear, 10); + const end = endYear ? Number.parseInt(endYear, 10) : start; + + if (Number.isNaN(start) || Number.isNaN(end)) { + return `${prefix}${startYear}${endYear ? `${separator}${endYear}` : ''}${suffix}`; + } + + const resolvedEnd = end >= year ? end : year; + + if (!endYear) { + // Single year already current — no range needed + if (resolvedEnd === start) { + return `${prefix}${startYear}${suffix}`; + } + return `${prefix}${startYear} - ${resolvedEnd}${suffix}`; + } + + // Always normalize separator to ' - ' and bump end year when stale + return `${prefix}${startYear} - ${resolvedEnd}${suffix}`; + }); +} + +export function hasInvalidPingCopyrightHeader(content) { + const lines = content.split(/\r?\n/); + for (const line of lines) { + if (!MAYBE_PING_COPYRIGHT_LINE_REGEX.test(line)) { + continue; + } + if (!HEADER_COMMENT_LINE_REGEX.test(line)) { + continue; + } + if (!VALID_PING_COPYRIGHT_LINE_REGEX.test(line)) { + return true; + } + } + return false; +} + +export function hasPingCopyrightHeader(content) { + const lines = content.split(/\r?\n/); + for (const line of lines) { + if (MAYBE_PING_COPYRIGHT_LINE_REGEX.test(line) && HEADER_COMMENT_LINE_REGEX.test(line)) { + return true; + } + } + return false; +} + +const SOURCE_FILE_PATTERN = /\.[cm]?[jt]sx?$/i; + +const MAYBE_PING_COPYRIGHT_LINE_REGEX = + /(?:©\s*|©\s*)?Copyright(?:\s*\(c\))?.*Ping Identity(?: Corporation)?/i; +const HEADER_COMMENT_LINE_REGEX = /^\s*(?:\/\*+|\*+|\/\/+|#+|', + ].join('\n'); + const actual = updateCopyrightYears(input, 2026); + assert.equal( + actual, + [ + '/* © Copyright 2020 - 2026 Ping Identity. */', + '', + ].join('\n'), + ); +}); + +test('does not update non-Ping headers', () => { + const input = '/* Copyright 2020-2025 Example Corp. */'; + const actual = updateCopyrightYears(input, 2026); + assert.equal(actual, input); +}); + +test('updates Ping Identity Corporation ranges with spaces and (c)', () => { + const input = '/* Copyright (c) 2023 - 2024 Ping Identity Corporation. All right reserved. */'; + const actual = updateCopyrightYears(input, 2026); + assert.equal( + actual, + '/* Copyright (c) 2023 - 2026 Ping Identity Corporation. All right reserved. */', + ); +}); + +test('expands stale single year with (c) to a range for Ping Identity Corporation', () => { + const input = '/* Copyright (c) 2023 Ping Identity Corporation. All right reserved. */'; + const actual = updateCopyrightYears(input, 2026); + assert.equal( + actual, + '/* Copyright (c) 2023 - 2026 Ping Identity Corporation. All right reserved. */', + ); +}); + +test('flags Ping headers without a valid year', () => { + const input = '/* Copyright Ping Identity Corporation. All right reserved. */'; + assert.equal(hasInvalidPingCopyrightHeader(input), true); +}); + +test('flags Ping headers with a placeholder', () => { + const input = + '/* Copyright (c) Ping Identity Corporation. All rights reserved. */'; + assert.equal(hasInvalidPingCopyrightHeader(input), true); +}); + +test('does not flag valid Ping headers', () => { + const input = '/* Copyright (c) 2020 - 2026 Ping Identity Corporation. */'; + assert.equal(hasInvalidPingCopyrightHeader(input), false); +}); + +test('does not flag non-header Ping copyright text', () => { + const input = 'This document is Copyright Ping Identity Corporation.'; + assert.equal(hasInvalidPingCopyrightHeader(input), false); +}); + +test('excludes test files from processing', () => { + assert.equal(isExcluded('src/foo.test.ts'), true); + assert.equal(isExcluded('src/foo.test.mjs'), true); + assert.equal(isExcluded('src/foo.spec.js'), true); +}); + +test('excludes dist and vendor paths from processing', () => { + assert.equal(isExcluded('dist/foo.js'), true); + assert.equal(isExcluded('vendor/lib.js'), true); +}); + +test('excludes vite.config and vitest.setup files from processing', () => { + assert.equal(isExcluded('vite.config.ts'), true); + assert.equal(isExcluded('packages/foo/vite.config.ts'), true); + assert.equal(isExcluded('e2e/token-vault-app/vite.interceptor.config.ts'), true); + assert.equal(isExcluded('vitest.setup.ts'), true); + assert.equal(isExcluded('packages/foo/vitest.setup.ts'), true); +}); + +test('excludes playwright.config files from processing', () => { + assert.equal(isExcluded('e2e/davinci-suites/playwright.config.ts'), true); + assert.equal(isExcluded('e2e/oidc-suites/playwright.config.ts'), true); +}); + +test('excludes _polyfills/ directory from processing', () => { + assert.equal(isExcluded('e2e/autoscript-apps/src/_polyfills/fast-text-encoder.js'), true); +}); + +test('excludes tools/ directory from processing', () => { + assert.equal(isExcluded('tools/copyright/sync-header-years.mjs'), true); + assert.equal(isExcluded('tools/release/local.mjs'), true); +}); + +test('does not exclude regular source files', () => { + assert.equal(isExcluded('src/foo.ts'), false); + assert.equal(isExcluded('packages/sdk/src/index.ts'), false); +}); + +test('hasPingCopyrightHeader detects valid block comment header', () => { + const input = '/* Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. */'; + assert.equal(hasPingCopyrightHeader(input), true); +}); + +test('hasPingCopyrightHeader detects header in multi-line block comment', () => { + const input = `/* + * @ping-identity/sdk + * + * Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. + */`; + assert.equal(hasPingCopyrightHeader(input), true); +}); + +test('hasPingCopyrightHeader returns false when no Ping copyright present', () => { + const input = `/* + * Some other library header + */ +export const x = 1;`; + assert.equal(hasPingCopyrightHeader(input), false); +}); + +test('hasPingCopyrightHeader returns false for non-comment Ping copyright text', () => { + const input = 'This document is Copyright Ping Identity Corporation.'; + assert.equal(hasPingCopyrightHeader(input), false); +});