Files
compiler-explorer/etc/scripts/check-license-headers.js
Matt Godbolt (bot acct) 3d5a62fe6b Re-add CI check enforcing license header banners (#8883)
## What & why

We used to enforce the BSD-2-Clause license banner on every source file
via `eslint-plugin-header`. That check was lost when we migrated from
ESLint to Biome (#7033), which has no equivalent rule. This PR
reinstates it as a standalone node script, modelled on the existing
`etc/scripts/check-frontend-imports.js`.

### Why a script rather than a Biome rule
Biome 2.x has no built-in license/header rule and no plugin equivalent
to `eslint-plugin-header`; its experimental GritQL plugins aren't suited
to whole-file-prefix matching. A script needs zero new dependencies and
gives full control over scope and exemptions.

## The check (`etc/scripts/check-license-headers.js`)

A file "has an appropriate banner" if, ignoring an optional shebang, it
opens with a `// Copyright (c) …` line **and** contains the BSD-2-Clause
disclaimer body. The year and copyright holder are intentionally **not**
constrained — the tree legitimately has many holders (Compiler Explorer
Authors, Arm, Microsoft, HRT, individuals). The `(c)`/`(C)` marker is
matched case-insensitively.

**Scope:** `.ts/.js/.mjs/.cjs` under `lib/ static/ shared/ types/ test/
cypress/`.

**Exempt:** generated files (`lib/asm-docs/generated`), vendored
(`docenizer/vendor`), `.d.ts`, and three third-party ports that carry
their own upstream license — `static/ansi-to-html.ts` (MIT),
`lib/node-graceful.ts` (MIT), `shared/rison.ts` (Nanonid/rison port).

**Wired into:** CI (`test-and-deploy.yml`), `npm run check`, the `make
pre-commit` target, the husky pre-commit hook, and `lint-staged` (per
staged file).

Usage:
```
node ./etc/scripts/check-license-headers.js            # scan the tracked tree
node ./etc/scripts/check-license-headers.js <files...> # scan specific files (lint-staged)
```

## Backfill

The check surfaced **42 CE-authored files** missing the banner. This PR
backfills them all with the standard `Copyright (c) <year>, Compiler
Explorer Authors` banner, using each file's **git creation year**
(added-at-this-path, so no `--follow` rename artifacts).

## Verification

- `check-license-headers` → clean (was 42 failures)
- `biome check` on all source → no fixes needed (banner format matches
existing convention)
- `tsc` backend + frontend + tests → clean
- pre-commit gauntlet (lint, ts-check, related tests: 528 passed) ran on
commit

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: mattgodbolt-molty <mattgodbolt-molty@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 19:13:21 +01:00

105 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// Check that every source file starts with the BSD-2-Clause license banner.
//
// We used to enforce this via eslint-plugin-header; that check was lost in the
// migration to Biome (which has no equivalent rule). This is a standalone
// replacement, run in CI, in the husky pre-commit hook, and by `make pre-commit`.
//
// Usage:
// node ./etc/scripts/check-license-headers.js # scan the tracked tree
// node ./etc/scripts/check-license-headers.js <files...> # scan just these files
import {execSync} from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
// Directories/globs whose source files must carry the banner.
const INCLUDE_DIRS = ['lib', 'static', 'shared', 'types', 'test', 'cypress'];
const INCLUDE_EXTENSIONS = ['.ts', '.js', '.mjs', '.cjs'];
// Files/paths that are exempt (generated, vendored, or third-party).
const EXCLUDE_PATTERNS = [
/(^|\/)node_modules\//,
/(^|\/)out\//,
/^lib\/asm-docs\/generated\//,
/^etc\/scripts\/docenizer\/vendor\//,
/\.d\.ts$/,
// Third-party code carrying its own upstream license (not CE's BSD-2 banner).
/^static\/ansi-to-html\.ts$/, // MIT, Rob Burns
/^lib\/node-graceful\.ts$/, // MIT, node-graceful
/^shared\/rison\.ts$/, // Nanonid/rison port
// CE-authored files that pre-date this check and carry an MIT header rather than
// the repo-standard BSD-2. Exempt for now; relicensing them to BSD-2 is a separate
// call for a maintainer.
/^lib\/stack-usage-transformer\.ts$/,
/^test\/stack-usage-transformer-test\.ts$/,
/^test\/library-tests\.ts$/,
];
// A file "has an appropriate banner" if, ignoring an optional shebang, it opens
// with a `// Copyright (c) ...` line and contains the BSD-2-Clause disclaimer.
// The year and copyright holder are intentionally not constrained: the tree has
// many holders (Compiler Explorer Authors, Arm Ltd, Microsoft, individuals, ...).
// No /m flag: the copyright line must be the very first line (post-shebang), not
// merely present somewhere in the head.
const COPYRIGHT_RE = /^\/\/ Copyright \([cC]\) .+/;
// Two distinctive anchors from the BSD-2-Clause body: the opening grant and the
// disclaimer line. Requiring both (rather than a single line) avoids passing a file
// that merely happens to contain one stray phrase. We don't match the whole banner
// verbatim — that would be brittle to minor punctuation/quoting differences (e.g. a
// couple of files quote AS IS with single quotes, which is why that part is lenient).
const REDISTRIBUTION_RE = /Redistribution and use in source and binary forms/;
const DISCLAIMER_RE = /THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS ["']AS IS["']/;
// Normalise to a repo-relative POSIX path so matching works whether we're handed
// tracked paths from `git ls-files` or absolute/backslashed paths from lint-staged.
function toRepoRelative(file) {
return path.relative(process.cwd(), path.resolve(file)).split(path.sep).join('/');
}
function isIncluded(file) {
if (!INCLUDE_EXTENSIONS.some(ext => file.endsWith(ext))) return false;
if (EXCLUDE_PATTERNS.some(re => re.test(file))) return false;
return INCLUDE_DIRS.some(dir => file === dir || file.startsWith(`${dir}/`));
}
function listTrackedFiles() {
return execSync('git ls-files', {encoding: 'utf8', maxBuffer: 64 * 1024 * 1024})
.split('\n')
.filter(Boolean);
}
function hasBanner(file) {
// Only the head of the file matters; read enough to cover the banner.
let head;
try {
const buf = Buffer.alloc(4096);
const fd = fs.openSync(file, 'r');
try {
const bytesRead = fs.readSync(fd, buf, 0, buf.length, 0);
head = buf.toString('utf8', 0, bytesRead);
} finally {
fs.closeSync(fd);
}
} catch {
return true; // Unreadable/deleted file: not our problem to report here.
}
// Skip an optional shebang line so scripts like this one still qualify.
const body = head.startsWith('#!') ? head.slice(head.indexOf('\n') + 1) : head;
return COPYRIGHT_RE.test(body) && REDISTRIBUTION_RE.test(body) && DISCLAIMER_RE.test(body);
}
const args = process.argv.slice(2);
const candidates = (args.length > 0 ? args : listTrackedFiles()).map(toRepoRelative).filter(isIncluded);
const missing = candidates.filter(file => fs.existsSync(file) && !hasBanner(file));
if (missing.length > 0) {
console.error(`${missing.length} file(s) are missing the license header banner:\n`);
for (const file of missing) console.error(` ${file}`);
console.error('\nEvery source file must begin with the BSD-2-Clause banner (see any existing file for the');
console.error('exact text). The copyright year/holder line is flexible; the disclaimer body is required.');
process.exit(1);
}
process.exit(0);