From b6aa4a32dda6afbee243500cee30cab7cc9ae715 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 fix(eslint): allow copyright header enforcement on test and spec files refactor(copyright): merge header checks and collapse staged-file loop in sync-header-years --- eslint.config.mjs | 37 ++++- lefthook.yml | 3 + package.json | 2 + pnpm-lock.yaml | 4 +- tools/copyright/sync-header-years.mjs | 172 +++++++++++++++++++++ tools/copyright/sync-header-years.test.mjs | 157 +++++++++++++++++++ 6 files changed, 372 insertions(+), 3 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..a2c14760ae 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,41 @@ export default [ '**/test-output', ], }, + { + files: ['**/*.ts', '**/*.tsx', '**/*.js', '**/*.jsx', '**/*.mjs', '**/*.cjs'], + ignores: ['**/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 ef3b5e05ce..88bc8d5168 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 6f247d8d8b..015c013ee2 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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d6b464eb45..11167a3527 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5333,7 +5333,7 @@ packages: git-raw-commits@4.0.0: resolution: {integrity: sha512-ICsMM1Wk8xSGMowkOmPrzo2Fgmfo4bMHLNX6ytHjajRJUqvHOw/TFapQ+QG75c3X/tTDDhOSRPGC52dDbNM8FQ==} engines: {node: '>=16'} - deprecated: Deprecated and no longer maintained. Use @conventional-changelog/git-client instead. + deprecated: This package is no longer maintained. For the JavaScript API, please use @conventional-changelog/git-client instead. hasBin: true glob-parent@5.1.2: @@ -13463,7 +13463,7 @@ snapshots: estree-walker@3.0.3: dependencies: - '@types/estree': 1.0.8 + '@types/estree': 1.0.9 esutils@2.0.3: {} diff --git a/tools/copyright/sync-header-years.mjs b/tools/copyright/sync-header-years.mjs new file mode 100644 index 0000000000..da8308473a --- /dev/null +++ b/tools/copyright/sync-header-years.mjs @@ -0,0 +1,172 @@ +#!/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 stagedFileData = []; + const invalidFiles = []; + const missingHeaderFiles = []; + const changedFiles = []; + + for (const file of getStagedFiles()) { + if (isExcluded(file)) { + continue; + } + + let original; + try { + if (!statSync(resolve(process.cwd(), file)).isFile()) { + continue; + } + original = readFileSync(resolve(process.cwd(), file), 'utf8'); + } catch { + continue; + } + + const absolutePath = resolve(process.cwd(), file); + const { present, invalid } = inspectPingCopyrightHeader(original); + + if (invalid) { + invalidFiles.push(file); + } + if (SOURCE_FILE_PATTERN.test(file) && !present) { + missingHeaderFiles.push(file); + } + + const updated = updateCopyrightYears(original, currentYear); + if (updated !== original) { + changedFiles.push(file); + } + stagedFileData.push({ file, absolutePath, updated }); + } + + 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); + } + + if (checkOnly) { + if (missingHeaderFiles.length > 0) { + console.error('Missing Ping copyright header in staged files:'); + for (const file of missingHeaderFiles) { + console.error(`- ${file}`); + } + process.exit(1); + } + if (changedFiles.length > 0) { + console.error('Stale Ping copyright years found in staged files:'); + for (const file of changedFiles) { + console.error(`- ${file}`); + } + process.exit(1); + } + return; + } + + for (const { file, absolutePath, updated } of stagedFileData) { + if (changedFiles.includes(file)) { + writeFileSync(absolutePath, updated, 'utf8'); + } + } + + if (changedFiles.length > 0) { + execFileSync('git', ['add', '--', ...changedFiles], { stdio: 'inherit' }); + } +} + +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 = [ + /(^|[/\\])dist[/\\]/, + /(^|[/\\])vendor[/\\]/, + /(^|[/\\])node_modules[/\\]/, + /(^|[/\\])tools[/\\]/, + /(^|[/\\])_polyfills[/\\]/, + /(^|[/\\])vite[^/\\]*\.config\.[cm]?[jt]sx?$/i, + /(^|[/\\])vitest\.setup\.[cm]?[jt]sx?$/i, + /(^|[/\\])playwright\.config\.[cm]?[jt]sx?$/i, +]; + +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 inspectPingCopyrightHeader(content) { + const lines = content.split(/\r?\n/); + let present = false; + let invalid = false; + + for (const line of lines) { + if (!MAYBE_PING_COPYRIGHT_LINE_REGEX.test(line) || !HEADER_COMMENT_LINE_REGEX.test(line)) { + continue; + } + present = true; + if (!VALID_PING_COPYRIGHT_LINE_REGEX.test(line)) { + invalid = true; + } + } + + return { present, invalid }; +} + +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.deepEqual(inspectPingCopyrightHeader(input), { present: true, invalid: true }); +}); + +test('flags Ping headers with a placeholder', () => { + const input = + '/* Copyright (c) Ping Identity Corporation. All rights reserved. */'; + assert.deepEqual(inspectPingCopyrightHeader(input), { present: true, invalid: true }); +}); + +test('does not flag valid Ping headers', () => { + const input = '/* Copyright (c) 2020 - 2026 Ping Identity Corporation. */'; + assert.deepEqual(inspectPingCopyrightHeader(input), { present: true, invalid: false }); +}); + +test('does not flag non-header Ping copyright text', () => { + const input = 'This document is Copyright Ping Identity Corporation.'; + assert.deepEqual(inspectPingCopyrightHeader(input), { present: false, invalid: false }); +}); + +test('does not exclude test/spec files from processing', () => { + assert.equal(isExcluded('src/foo.test.ts'), false); + assert.equal(isExcluded('src/foo.test.mjs'), false); + assert.equal(isExcluded('src/foo.spec.js'), false); +}); + +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('inspectPingCopyrightHeader detects valid block comment header', () => { + const input = '/* Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. */'; + assert.deepEqual(inspectPingCopyrightHeader(input), { present: true, invalid: false }); +}); + +test('inspectPingCopyrightHeader detects header in multi-line block comment', () => { + const input = `/* + * @ping-identity/sdk + * + * Copyright (c) 2020 - 2026 Ping Identity Corporation. All rights reserved. + */`; + assert.deepEqual(inspectPingCopyrightHeader(input), { present: true, invalid: false }); +}); + +test('inspectPingCopyrightHeader returns not present when no Ping copyright exists', () => { + const input = `/* + * Some other library header + */ +export const x = 1;`; + assert.deepEqual(inspectPingCopyrightHeader(input), { present: false, invalid: false }); +}); + +test('inspectPingCopyrightHeader returns not present for non-comment Ping copyright text', () => { + const input = 'This document is Copyright Ping Identity Corporation.'; + assert.deepEqual(inspectPingCopyrightHeader(input), { present: false, invalid: false }); +});