From cbfc87bbe5d8e1406859aab961f495df248c2b52 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Mon, 2 Mar 2026 08:45:46 -0800 Subject: [PATCH 1/4] feat: add copyright date check & fix automation --- .husky/pre-commit | 2 +- package.json | 2 + tools/copyright/sync-header-years.mjs | 138 +++++++++++++++++++++ tools/copyright/sync-header-years.test.mjs | 70 +++++++++++ 4 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 tools/copyright/sync-header-years.mjs create mode 100644 tools/copyright/sync-header-years.test.mjs diff --git a/.husky/pre-commit b/.husky/pre-commit index e75ce0cb6..6a163a88e 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1,4 +1,4 @@ #!/bin/sh . "$(dirname "$0")/_/husky.sh" -npx lint-staged && npx nx affected:lint && npx nx affected:build +node tools/copyright/sync-header-years.mjs && npx lint-staged && npx nx affected:lint && npx nx affected:build diff --git a/package.json b/package.json index 76ce17a3e..bd3868bfb 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "ci:version": "changeset version && pnpm install --no-frozen-lockfile && pnpm nx format:write --uncommitted", "changeset": "changeset", "commit": "git cz", + "copyright:check": "node ./tools/copyright/sync-header-years.mjs --check", + "copyright:sync": "node ./tools/copyright/sync-header-years.mjs", "docs": "nx affected --target=typedoc", "e2e": "CI=true nx affected:e2e", "format:staged": "pretty-quick --staged", diff --git a/tools/copyright/sync-header-years.mjs b/tools/copyright/sync-header-years.mjs new file mode 100644 index 000000000..9079f1cd6 --- /dev/null +++ b/tools/copyright/sync-header-years.mjs @@ -0,0 +1,138 @@ +#!/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)) { + 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); + } + + 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); +} + +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) || end >= year) { + return `${prefix}${startYear}${endYear ? `${separator}${endYear}` : ''}${suffix}`; + } + if (!endYear) { + return `${prefix}${startYear} - ${year}${suffix}`; + } + return `${prefix}${startYear}${separator}${year}${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; +} + +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 - 2026 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('updates Ping Identity Corporation stale single year with (c) to range', () => { + const input = '/* Copyright (c) 2025 - 2026 Ping Identity Corporation. All right reserved. */'; + const actual = updateCopyrightYears(input, 2026); + assert.equal( + actual, + '/* Copyright (c) 2025 - 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('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); +}); From 2c7f8b333eabd0685e2d2f55cd10899213a143d9 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Tue, 17 Mar 2026 11:49:27 -0700 Subject: [PATCH 2/4] feat: validate space around dates; update tests; --- tools/copyright/sync-header-years.mjs | 15 ++++++++-- tools/copyright/sync-header-years.test.mjs | 34 +++++++++++++++------- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/tools/copyright/sync-header-years.mjs b/tools/copyright/sync-header-years.mjs index 9079f1cd6..b6ae0e026 100644 --- a/tools/copyright/sync-header-years.mjs +++ b/tools/copyright/sync-header-years.mjs @@ -101,13 +101,22 @@ export function updateCopyrightYears(content, year) { const start = Number.parseInt(startYear, 10); const end = endYear ? Number.parseInt(endYear, 10) : start; - if (Number.isNaN(start) || Number.isNaN(end) || end >= year) { + if (Number.isNaN(start) || Number.isNaN(end)) { return `${prefix}${startYear}${endYear ? `${separator}${endYear}` : ''}${suffix}`; } + + const resolvedEnd = end >= year ? end : year; + if (!endYear) { - return `${prefix}${startYear} - ${year}${suffix}`; + // Single year already current — no range needed + if (resolvedEnd === start) { + return `${prefix}${startYear}${suffix}`; + } + return `${prefix}${startYear} - ${resolvedEnd}${suffix}`; } - return `${prefix}${startYear}${separator}${year}${suffix}`; + + // Always normalize separator to ' - ' and bump end year when stale + return `${prefix}${startYear} - ${resolvedEnd}${suffix}`; }); } diff --git a/tools/copyright/sync-header-years.test.mjs b/tools/copyright/sync-header-years.test.mjs index f4f21ee1b..9697be391 100644 --- a/tools/copyright/sync-header-years.test.mjs +++ b/tools/copyright/sync-header-years.test.mjs @@ -4,28 +4,40 @@ import test from 'node:test'; import { hasInvalidPingCopyrightHeader, updateCopyrightYears } from './sync-header-years.mjs'; test('updates stale range end year and keeps start year', () => { - const input = '/* Copyright 2020-2026 Ping Identity. All Rights Reserved */'; + const input = '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'; const actual = updateCopyrightYears(input, 2026); - assert.equal(actual, '/* Copyright 2020-2026 Ping Identity. All Rights Reserved */'); + assert.equal(actual, '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'); }); -test('updates stale single year to a range preserving start year', () => { +test('normalizes separator on an already-current range', () => { + const input = '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'; + const actual = updateCopyrightYears(input, 2026); + assert.equal(actual, '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'); +}); + +test('expands stale single year to a range preserving start year', () => { + const input = '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'; + const actual = updateCopyrightYears(input, 2026); + assert.equal(actual, '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'); +}); + +test('does not change an already-current spaced range', () => { const input = '/* Copyright 2025 - 2026 Ping Identity. All Rights Reserved */'; const actual = updateCopyrightYears(input, 2026); - assert.equal(actual, '/* Copyright 2025 - 2026 Ping Identity. All Rights Reserved */'); + assert.equal(actual, input); }); test('supports © and © variants', () => { const input = [ - '/* © Copyright 2020-2026 Ping Identity. */', - '', + '/* © Copyright 2020 - 2026 Ping Identity. */', + '', ].join('\n'); const actual = updateCopyrightYears(input, 2026); assert.equal( actual, [ - '/* © Copyright 2020-2026 Ping Identity. */', - '', + '/* © Copyright 2020 - 2026 Ping Identity. */', + '', ].join('\n'), ); }); @@ -45,12 +57,12 @@ test('updates Ping Identity Corporation ranges with spaces and (c)', () => { ); }); -test('updates Ping Identity Corporation stale single year with (c) to range', () => { - const input = '/* Copyright (c) 2025 - 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 - 2026 Ping Identity Corporation. All right reserved. */'; const actual = updateCopyrightYears(input, 2026); assert.equal( actual, - '/* Copyright (c) 2025 - 2026 Ping Identity Corporation. All right reserved. */', + '/* Copyright (c) 2023 - 2026 Ping Identity Corporation. All right reserved. */', ); }); From 30eab18d67d198704f690c1ac525b5b1ecff1fc2 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 11:49:34 -0700 Subject: [PATCH 3/4] fix: update test input dates From b780c49c91bfd81600d5bf081149ceb93fa5c1e3 Mon Sep 17 00:00:00 2001 From: Gabriel Stein Date: Wed, 18 Mar 2026 11:59:19 -0700 Subject: [PATCH 4/4] fix: add exclude paths to pre-commit date update; update test input dates; --- tools/copyright/sync-header-years.mjs | 14 ++++++++- tools/copyright/sync-header-years.test.mjs | 36 +++++++++++++++++----- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/tools/copyright/sync-header-years.mjs b/tools/copyright/sync-header-years.mjs index b6ae0e026..7036f33f5 100644 --- a/tools/copyright/sync-header-years.mjs +++ b/tools/copyright/sync-header-years.mjs @@ -20,7 +20,7 @@ function run() { const changedFiles = []; for (const file of stagedFiles) { - if (!isFile(file)) { + if (!isFile(file) || isExcluded(file)) { continue; } const absolutePath = resolve(process.cwd(), file); @@ -77,6 +77,18 @@ function getStagedFiles() { 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[/\\]/, +]; + function isFile(filePath) { try { return statSync(filePath).isFile(); diff --git a/tools/copyright/sync-header-years.test.mjs b/tools/copyright/sync-header-years.test.mjs index 9697be391..67b6cd3d2 100644 --- a/tools/copyright/sync-header-years.test.mjs +++ b/tools/copyright/sync-header-years.test.mjs @@ -1,22 +1,26 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { hasInvalidPingCopyrightHeader, updateCopyrightYears } from './sync-header-years.mjs'; +import { + hasInvalidPingCopyrightHeader, + isExcluded, + updateCopyrightYears, +} from './sync-header-years.mjs'; test('updates stale range end year and keeps start year', () => { - const input = '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'; + const input = '/* Copyright 2020-2024 Ping Identity. All Rights Reserved */'; const actual = updateCopyrightYears(input, 2026); assert.equal(actual, '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'); }); test('normalizes separator on an already-current range', () => { - const input = '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'; + const input = '/* Copyright 2020-2026 Ping Identity. All Rights Reserved */'; const actual = updateCopyrightYears(input, 2026); assert.equal(actual, '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'); }); test('expands stale single year to a range preserving start year', () => { - const input = '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'; + const input = '/* Copyright 2020 Ping Identity. All Rights Reserved */'; const actual = updateCopyrightYears(input, 2026); assert.equal(actual, '/* Copyright 2020 - 2026 Ping Identity. All Rights Reserved */'); }); @@ -29,8 +33,8 @@ test('does not change an already-current spaced range', () => { test('supports © and © variants', () => { const input = [ - '/* © Copyright 2020 - 2026 Ping Identity. */', - '', + '/* © Copyright 2020-2024 Ping Identity. */', + '', ].join('\n'); const actual = updateCopyrightYears(input, 2026); assert.equal( @@ -49,7 +53,7 @@ test('does not update non-Ping headers', () => { }); test('updates Ping Identity Corporation ranges with spaces and (c)', () => { - const input = '/* Copyright (c) 2023 - 2026 Ping Identity Corporation. All right reserved. */'; + const input = '/* Copyright (c) 2023 - 2024 Ping Identity Corporation. All right reserved. */'; const actual = updateCopyrightYears(input, 2026); assert.equal( actual, @@ -58,7 +62,7 @@ test('updates Ping Identity Corporation ranges with spaces and (c)', () => { }); test('expands stale single year with (c) to a range for Ping Identity Corporation', () => { - const input = '/* Copyright (c) 2023 - 2026 Ping Identity Corporation. All right reserved. */'; + const input = '/* Copyright (c) 2023 Ping Identity Corporation. All right reserved. */'; const actual = updateCopyrightYears(input, 2026); assert.equal( actual, @@ -80,3 +84,19 @@ 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('does not exclude regular source files', () => { + assert.equal(isExcluded('src/foo.ts'), false); + assert.equal(isExcluded('packages/sdk/src/index.ts'), false); +});