Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .husky/pre-commit
Original file line number Diff line number Diff line change
@@ -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
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
159 changes: 159 additions & 0 deletions tools/copyright/sync-header-years.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
#!/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);
}

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[/\\]/,
];

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;
}

const MAYBE_PING_COPYRIGHT_LINE_REGEX =
/(?:©\s*|©\s*)?Copyright(?:\s*\(c\))?.*Ping Identity(?: Corporation)?/i;
const HEADER_COMMENT_LINE_REGEX = /^\s*(?:\/\*+|\*+|\/\/+|#+|<!--)\s*/;
const VALID_PING_COPYRIGHT_LINE_REGEX =
/^.*(?:©\s*|&copy;\s*)?Copyright(?:\s*\(c\))?\s+\d{4}(?:[ \t]*-[ \t]*\d{4})?\s+Ping Identity(?: Corporation)?\b.*$/i;

if (isCliExecution()) {
run();
}
102 changes: 102 additions & 0 deletions tools/copyright/sync-header-years.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import assert from 'node:assert/strict';
import test from 'node:test';

import {
hasInvalidPingCopyrightHeader,
isExcluded,
updateCopyrightYears,
} from './sync-header-years.mjs';

test('updates stale range end year and keeps start year', () => {
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 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 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, input);
});

test('supports © and &copy; variants', () => {
const input = [
'/* © Copyright 2020-2024 Ping Identity. */',
'<!-- &copy; Copyright 2020-2024 Ping Identity. -->',
].join('\n');
const actual = updateCopyrightYears(input, 2026);
assert.equal(
actual,
[
'/* © Copyright 2020 - 2026 Ping Identity. */',
'<!-- &copy; 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('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('does not exclude regular source files', () => {
assert.equal(isExcluded('src/foo.ts'), false);
assert.equal(isExcluded('packages/sdk/src/index.ts'), false);
});
Loading