Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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