Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .essentials-sync-jargon.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"allow": {
"paths": [
"tools/typescript/essentials-sync/src/jargon-list.ts",
"tools/typescript/essentials-sync/tests/fixtures/dirty-package/**",
"tools/typescript/essentials-sync/tests/scanners.test.ts"
]
}
}
27 changes: 26 additions & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ repos:
always_run: true
stages: [pre-push]

# Forbidden jargon in commit messages and added lines. The wordlist lives in
# essentials-sync; exceptions live in .essentials-sync-jargon.json. The
# pre-push hook rescans every unpublished commit, so it also catches commits
# made without hooks (git commit-tree, --no-verify).
- repo: local
hooks:
- id: jargon-commit-msg
name: jargon (commit message)
entry: node --disable-warning=ExperimentalWarning tools/typescript/essentials-sync/src/jargon-check.ts --message-file
language: system
always_run: true
stages: [commit-msg]
- id: jargon-push
name: jargon (unpublished commits)
entry: node --disable-warning=ExperimentalWarning tools/typescript/essentials-sync/src/jargon-check.ts
language: system
pass_filenames: false
always_run: true
stages: [pre-push]

# Trailing whitespace and end of file fixes
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
Expand All @@ -61,18 +81,23 @@ repos:
- id: debug-statements

# Global hooks configuration
default_install_hook_types: [pre-commit, pre-push, commit-msg]
# Hooks without an explicit `stages` run on commit and push, not on commit-msg.
default_stages: [pre-commit, pre-push]
default_language_version:
python: python3.12

# Exclude paths
#
# `\.git/` is deliberately absent: nothing under it is ever staged, but it did
# match `.git/COMMIT_EDITMSG`, the file every commit-msg hook is handed.
exclude: |
(?x)^(
archive/|
\.venv/|
venv/|
node_modules/|
__pycache__/|
\.git/|
\.pytest_cache/|
\.pulumi/|
dist/|
Expand Down
38 changes: 38 additions & 0 deletions tools/typescript/essentials-sync/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,44 @@ Drop a `.essentials-sync-jargon.json` file at the root of the original source to

Entries that start with `*.` are treated as hostname suffixes; everything else as case-insensitive word-boundary matches. Per-org employee-ID patterns belong here too -- the bundled PII scanner only flags emails, phone numbers, and SSNs.

## Git hooks

The same wordlist guards commits to the repo that hosts this tool. `src/jargon-check.ts` runs as two [pre-commit](https://pre-commit.com) hooks:

| Hook | Stage | Checks |
| --- | --- | --- |
| `jargon-commit-msg` | `commit-msg` | The message being committed, ignoring git comment lines. |
| `jargon-push` | `pre-push` | Every commit not yet on a remote: its message and the lines it adds. |

The pre-push hook is the backstop: it also catches commits that skipped the commit-msg hook (`git commit --no-verify`, `git commit-tree`). It only scans what a push would publish, so existing history never blocks a push.

Install both after cloning:

```bash
uv run pre-commit install
```

The hooks run under plain `node` (22.18 or later strips the TypeScript types), so they need no `npm install`. Run the check by hand with:

```bash
node tools/typescript/essentials-sync/src/jargon-check.ts # unpublished commits
node tools/typescript/essentials-sync/src/jargon-check.ts --to-ref <sha> # a specific commit and its unpublished ancestors
```

Exceptions live in `.essentials-sync-jargon.json` at the repo root. `allow.paths` are repo-relative globs (`*`, `**`, `?`) whose added lines are never flagged; `allow.text` entries are literal strings removed from a line before matching, so a forbidden term elsewhere on the same line is still caught:

```json
{
"terms": ["internal-codename"],
"allow": {
"paths": ["tools/typescript/essentials-sync/tests/**"],
"text": ["docs.example.com"]
}
}
```

The exception list applies to the hooks only; `essentials-sync` runs still scan synced packages against the full wordlist.

## Usage

```
Expand Down
13 changes: 2 additions & 11 deletions tools/typescript/essentials-sync/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { runScanners, formatFindings } from "./scanners/index.js";
import { planSync, composeFullPlan } from "./sync.js";
import { planExtract } from "./extract-plan.js";
import { runSyncSession } from "./agent.js";
import { parseJargonOverrides } from "./jargon-list.js";
import {
listAvailableModels,
parseModelSpec,
Expand Down Expand Up @@ -297,17 +298,7 @@ async function loadJargonOverrides(sourceAbs: string): Promise<string[]> {
const configPath = path.join(sourceAbs, ".essentials-sync-jargon.json");
try {
const raw = await fs.readFile(configPath, "utf8");
const parsed = JSON.parse(raw) as unknown;
if (Array.isArray(parsed)) {
return parsed.filter((entry): entry is string => typeof entry === "string");
}
if (parsed && typeof parsed === "object") {
const terms = (parsed as { terms?: unknown }).terms;
if (Array.isArray(terms)) {
return terms.filter((entry): entry is string => typeof entry === "string");
}
}
return [];
return parseJargonOverrides(JSON.parse(raw) as unknown).terms;
} catch (error) {
if (error instanceof Error && "code" in error && (error as { code?: string }).code === "ENOENT") {
return [];
Expand Down
231 changes: 231 additions & 0 deletions tools/typescript/essentials-sync/src/jargon-check.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
#!/usr/bin/env node
// Git hook entry point: fails when a commit message, or a line a commit adds,
// contains a forbidden jargon term. It runs under plain `node` (type stripping)
// so the hook works without `npm install`: import only node builtins and
// `./jargon-list.ts` here.
import { execFileSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import {
findJargonTerms,
isAllowedPath,
loadJargonConfig,
parseJargonOverrides,
type JargonAllowList,
type JargonConfig,
type JargonOverrides,
} from "./jargon-list.ts";

export const CONFIG_FILENAME = ".essentials-sync-jargon.json";

const SCISSORS_LINE = /^# -+ >8 -+$/;
const MAX_EXCERPT_LENGTH = 120;

export interface JargonHit {
location: string;
term: string;
excerpt: string;
}

export interface AddedLine {
file: string;
line: number;
text: string;
}

export interface CommitChange {
sha: string;
message: string;
addedLines: AddedLine[];
}

// Git hands commit-msg hooks the raw editor buffer: comment lines and, with
// `commit -v`, a diff below the scissors line. Neither is part of the message.
export function readMessageLines(rawMessage: string): string[] {
const lines: string[] = [];
for (const line of rawMessage.split(/\r?\n/)) {
if (SCISSORS_LINE.test(line)) break;
if (!line.startsWith("#")) lines.push(line);
}
return lines;
}

// Parses `git diff --unified=0` output into the lines it adds. File headers are
// only recognized between `diff --git` and the first hunk, so an added line
// that itself starts with "++ " is not mistaken for a header.
export function parseAddedLines(diff: string): AddedLine[] {
const added: AddedLine[] = [];
let file: string | null = null;
let inHeader = false;
let lineNumber = 0;
for (const raw of diff.split("\n")) {
if (raw.startsWith("diff --git ")) {
inHeader = true;
file = null;
continue;
}
if (inHeader) {
if (raw.startsWith("+++ ")) {
const target = raw.slice(4);
file = target === "/dev/null" ? null : target.replace(/^b\//, "");
}
if (!raw.startsWith("@@")) continue;
inHeader = false;
}
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(raw);
if (hunk) {
lineNumber = Number(hunk[1]);
continue;
}
if (file !== null && raw.startsWith("+")) {
added.push({ file, line: lineNumber, text: raw.slice(1) });
lineNumber += 1;
}
}
return added;
}

const RECORD_SEPARATOR = "\x1e";
const MESSAGE_END = "\x1f";

// Parses `git log -p` output produced with GIT_LOG_FORMAT into one entry per
// commit.
export function parseLog(output: string): CommitChange[] {
const commits: CommitChange[] = [];
for (const record of output.split(RECORD_SEPARATOR)) {
const end = record.indexOf(MESSAGE_END);
if (end === -1) continue;
const [sha = "", ...messageLines] = record.slice(0, end).split("\n");
commits.push({
sha,
message: messageLines.join("\n"),
addedLines: parseAddedLines(record.slice(end + 1)),
});
}
return commits;
}

const GIT_LOG_FORMAT = `--format=${RECORD_SEPARATOR}%H%n%B${MESSAGE_END}`;

const excerpt = (text: string): string => {
const trimmed = text.trim();
return trimmed.length > MAX_EXCERPT_LENGTH
? `${trimmed.slice(0, MAX_EXCERPT_LENGTH)}...`
: trimmed;
};

export function scanMessage(
lines: readonly string[],
locationPrefix: string,
config: JargonConfig,
allow: JargonAllowList,
): JargonHit[] {
const hits: JargonHit[] = [];
lines.forEach((line, index) => {
for (const pattern of findJargonTerms(line, config, allow.text)) {
hits.push({
location: `${locationPrefix} line ${index + 1}`,
term: pattern.term,
excerpt: excerpt(line),
});
}
});
return hits;
}

export function scanCommits(
commits: readonly CommitChange[],
config: JargonConfig,
allow: JargonAllowList,
): JargonHit[] {
const hits: JargonHit[] = [];
for (const commit of commits) {
const shortSha = commit.sha.slice(0, 7);
hits.push(
...scanMessage(commit.message.split("\n"), `commit ${shortSha} message`, config, allow),
);
for (const added of commit.addedLines) {
if (isAllowedPath(added.file, allow.paths)) continue;
for (const pattern of findJargonTerms(added.text, config, allow.text)) {
hits.push({
location: `commit ${shortSha} ${added.file}:${added.line}`,
term: pattern.term,
excerpt: excerpt(added.text),
});
}
}
}
return hits;
}

const git = (cwd: string, args: string[]): string =>
execFileSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });

export function loadRepoOverrides(repoRoot: string): JargonOverrides {
const configPath = path.join(repoRoot, CONFIG_FILENAME);
if (!existsSync(configPath)) {
return parseJargonOverrides(null);
}
return parseJargonOverrides(JSON.parse(readFileSync(configPath, "utf8")) as unknown);
}

// Commits the push would publish: everything reachable from `toRef` that no
// remote-tracking ref already has. That covers new branches, fast-forwards,
// and rebases alike, and never rescans history that is already public.
export function listUnpublishedCommits(repoRoot: string, toRef: string): CommitChange[] {
const output = git(repoRoot, [
"-c", "core.quotePath=false",
"log", "-p", "--unified=0", "--no-color", "--no-ext-diff", GIT_LOG_FORMAT,
toRef, "--not", "--remotes",
]);
return parseLog(output);
}

function formatHits(hits: readonly JargonHit[]): string {
const lines = hits.map((hit) => ` ${hit.location}: '${hit.term}' in: ${hit.excerpt}`);
return [
`Forbidden jargon found. Reword it, or add an exception to ${CONFIG_FILENAME}:`,
...lines,
].join("\n");
}

export function main(argv: readonly string[]): number {
const { values } = parseArgs({
args: [...argv],
options: {
"message-file": { type: "string" },
"to-ref": { type: "string" },
},
});
const repoRoot = git(process.cwd(), ["rev-parse", "--show-toplevel"]).trim();
const overrides = loadRepoOverrides(repoRoot);
const config = loadJargonConfig(overrides.terms);

const messageFile = values["message-file"];
const hits = messageFile
? scanMessage(
readMessageLines(readFileSync(messageFile, "utf8")),
"commit message",
config,
overrides.allow,
)
: scanCommits(
listUnpublishedCommits(
repoRoot,
values["to-ref"] ?? process.env.PRE_COMMIT_TO_REF ?? "HEAD",
),
config,
overrides.allow,
);

if (hits.length === 0) return 0;
console.error(formatHits(hits));
return 1;
}

const invokedPath = process.argv[1];
if (invokedPath && import.meta.url === pathToFileURL(path.resolve(invokedPath)).href) {
process.exitCode = main(process.argv.slice(2));
}
Loading
Loading