Skip to content

Commit ea6dd41

Browse files
committed
chore(wheelhouse): cascade template@8a15af1b
1 parent 5655811 commit ea6dd41

28 files changed

Lines changed: 1776 additions & 230 deletions

File tree

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# changelog-entry-shape-nudge
2+
3+
PreToolUse(Edit|Write) hook, non-blocking. Nudges when a `CHANGELOG.md` edit
4+
adds a top-level entry bullet that links no detail into
5+
`docs/agents.md/{fleet,repo}/<topic>.md`.
6+
7+
## What it catches
8+
9+
A `CHANGELOG.md` Write (full content) or Edit (new_string) that adds a
10+
column-0 `- ` / `* ` entry bullet with no `docs/agents.md/` link. Indented
11+
sub-bullets, headings, and blank lines are ignored.
12+
13+
## Why
14+
15+
A CHANGELOG entry is a one-line bullet stating the user-visible change, with the
16+
rationale and mechanism linked to an agents.md doc:
17+
18+
- <user-visible change> ([`topic`](docs/agents.md/fleet/<topic>.md))
19+
20+
The doc is the source of truth; the changelog stays a scannable index, the same
21+
diet pattern the CLAUDE.md reference card uses (detail defers to
22+
`docs/agents.md/`). Inline prose duplicates the doc and drifts from it.
23+
24+
This is a NUDGE, not a guard: a short bullet without a doc yet is common
25+
mid-work. Prose quality (`prose-antipattern-guard`) and impl-detail
26+
(`Allow changelog-impl-detail bypass`) are the separate hard gates.
27+
28+
## Bypass
29+
30+
None — it never blocks. Rewrite the entry as a bullet + agents.md link.
31+
32+
## Exit codes
33+
34+
- `0` — always (warning only). Fails open on any internal error.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
#!/usr/bin/env node
2+
// Claude Code PreToolUse(Edit|Write) hook — changelog-entry-shape-nudge.
3+
//
4+
// NUDGES (non-blocking, exit 0) when a CHANGELOG.md edit adds an entry bullet
5+
// that carries no link into docs/agents.md/{fleet,repo}/<topic>.md. The fleet
6+
// rule (CLAUDE.md "Prose authoring"): a CHANGELOG entry is a one-line bullet
7+
// stating the user-visible change, with the detail linked to an agents.md doc —
8+
// `- <change> ([`topic`](docs/agents.md/fleet/<topic>.md))`. The doc is the
9+
// source of truth; the changelog stays a scannable index, same diet pattern as
10+
// the CLAUDE.md reference card.
11+
//
12+
// A NUDGE, not a guard: short bullets without a doc yet are common mid-work, so
13+
// this reminds rather than blocks. The shape is a preference; prose quality is
14+
// the separate hard gate (prose-antipattern-guard) and impl-detail another.
15+
//
16+
// Only the ADDED content matters: a Write's full content, or an Edit's
17+
// new_string. We flag a `- ` entry bullet that has no `docs/agents.md/` link
18+
// and isn't a sub-bullet / heading / blank.
19+
//
20+
// No bypass phrase (it never blocks). Exit 0 always.
21+
22+
import path from 'node:path'
23+
import process from 'node:process'
24+
25+
import { getDefaultLogger } from '@socketsecurity/lib-stable/logger/default'
26+
import { normalizePath } from '@socketsecurity/lib-stable/paths/normalize'
27+
28+
import { withEditGuard } from '../_shared/payload.mts'
29+
30+
const CHANGELOG_RE = /(?:^|\/)CHANGELOG\.md$/
31+
const AGENTS_DOC_LINK = 'docs/agents.md/'
32+
33+
// A top-level changelog entry bullet: `- ` or `* ` at column 0 (not indented
34+
// sub-bullets, which elaborate a parent entry and need no own link).
35+
export function entryBulletsMissingDocLink(content: string): string[] {
36+
const out: string[] = []
37+
const lines = content.split('\n')
38+
for (let i = 0, { length } = lines; i < length; i += 1) {
39+
const line = lines[i]!
40+
if (!/^[-*] +\S/.test(line)) {
41+
continue
42+
}
43+
if (line.includes(AGENTS_DOC_LINK)) {
44+
continue
45+
}
46+
out.push(line.trim())
47+
}
48+
return out
49+
}
50+
51+
await withEditGuard((filePath, content, _payload) => {
52+
if (content === undefined) {
53+
return
54+
}
55+
if (!CHANGELOG_RE.test(normalizePath(filePath))) {
56+
return
57+
}
58+
const missing = entryBulletsMissingDocLink(content)
59+
if (!missing.length) {
60+
return
61+
}
62+
const logger = getDefaultLogger()
63+
const rel = path.basename(filePath)
64+
logger.warn(
65+
`[changelog-entry-shape-nudge] ${missing.length} CHANGELOG entr${missing.length === 1 ? 'y' : 'ies'} in ${rel} link no agents.md doc:`,
66+
)
67+
const shown = Math.min(missing.length, 5)
68+
for (let i = 0; i < shown; i += 1) {
69+
logger.warn(` • ${missing[i]}`)
70+
}
71+
logger.warn('')
72+
logger.warn(
73+
'A CHANGELOG entry is a one-line bullet linking the detail to an agents.md',
74+
)
75+
logger.warn(
76+
'doc — `- <change> ([`topic`](docs/agents.md/fleet/<topic>.md))`. Put the',
77+
)
78+
logger.warn(
79+
'rationale + mechanism in the doc; keep the changelog a scannable index.',
80+
)
81+
// Non-blocking: this is a NUDGE. Exit 0 so the edit proceeds.
82+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "hook-changelog-entry-shape-nudge",
3+
"private": true,
4+
"type": "module",
5+
"main": "./index.mts",
6+
"exports": {
7+
".": "./index.mts"
8+
},
9+
"scripts": {
10+
"test": "node --test test/*.test.mts"
11+
},
12+
"devDependencies": {
13+
"@socketsecurity/lib-stable": "catalog:",
14+
"@types/node": "catalog:"
15+
}
16+
}
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
// prefer-async-spawn: streaming-stdio-required — spawns the hook subprocess and
2+
// pipes an Edit/Write payload on stdin, asserting on exit (always 0) + stderr.
3+
import { spawn } from '@socketsecurity/lib-stable/process/spawn/child'
4+
import { mkdtempSync, writeFileSync } from 'node:fs'
5+
import os from 'node:os'
6+
import path from 'node:path'
7+
import { fileURLToPath } from 'node:url'
8+
import { test } from 'node:test'
9+
import assert from 'node:assert/strict'
10+
11+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
12+
const HOOK = path.resolve(__dirname, '..', 'index.mts')
13+
14+
function changelogPath(): string {
15+
const dir = mkdtempSync(path.join(os.tmpdir(), 'changelog-shape-test-'))
16+
const p = path.join(dir, 'CHANGELOG.md')
17+
writeFileSync(p, '# Changelog\n')
18+
return p
19+
}
20+
21+
function runWrite(
22+
filePath: string,
23+
content: string,
24+
): Promise<{ code: number; stderr: string }> {
25+
return new Promise((resolve, reject) => {
26+
const child = spawn(process.execPath, [HOOK], {
27+
stdio: ['pipe', 'ignore', 'pipe'],
28+
})
29+
void child.catch(() => undefined)
30+
let stderr = ''
31+
child.process.stderr!.on('data', d => {
32+
stderr += d.toString()
33+
})
34+
child.process.on('error', reject)
35+
child.process.on('exit', code => {
36+
resolve({ code: code ?? -1, stderr })
37+
})
38+
child.stdin!.end(
39+
JSON.stringify({
40+
tool_name: 'Write',
41+
tool_input: { file_path: filePath, content },
42+
}),
43+
)
44+
})
45+
}
46+
47+
test('nudges a bullet with no agents.md link (exit 0, warns)', async () => {
48+
const { code, stderr } = await runWrite(
49+
changelogPath(),
50+
'## 1.2.0\n\n- Added a new flag for verbose output\n',
51+
)
52+
assert.equal(code, 0, 'always non-blocking')
53+
assert.match(stderr, /changelog-entry-shape-nudge/)
54+
})
55+
56+
test('quiet when the bullet links an agents.md doc', async () => {
57+
const { code, stderr } = await runWrite(
58+
changelogPath(),
59+
'## 1.2.0\n\n- Added a verbose flag ([`verbose`](docs/agents.md/repo/verbose.md))\n',
60+
)
61+
assert.equal(code, 0)
62+
assert.doesNotMatch(stderr, /changelog-entry-shape-nudge/)
63+
})
64+
65+
test('ignores indented sub-bullets', async () => {
66+
const { code, stderr } = await runWrite(
67+
changelogPath(),
68+
'## 1.2.0\n\n- Feature ([`x`](docs/agents.md/fleet/x.md))\n - detail one\n - detail two\n',
69+
)
70+
assert.equal(code, 0)
71+
assert.doesNotMatch(stderr, /changelog-entry-shape-nudge/)
72+
})
73+
74+
test('a non-CHANGELOG file is ignored', async () => {
75+
const dir = mkdtempSync(path.join(os.tmpdir(), 'changelog-shape-test-'))
76+
const p = path.join(dir, 'NOTES.md')
77+
writeFileSync(p, '# notes\n')
78+
const { code, stderr } = await runWrite(p, '- a bare bullet with no link\n')
79+
assert.equal(code, 0)
80+
assert.doesNotMatch(stderr, /changelog-entry-shape-nudge/)
81+
})
82+
83+
test('headings and blank lines do not trigger', async () => {
84+
const { code, stderr } = await runWrite(
85+
changelogPath(),
86+
'# Changelog\n\n## 2.0.0\n\n',
87+
)
88+
assert.equal(code, 0)
89+
assert.doesNotMatch(stderr, /changelog-entry-shape-nudge/)
90+
})
91+
92+
test('non-Edit/Write tool passes silently', async () => {
93+
const child = spawn(process.execPath, [HOOK], {
94+
stdio: ['pipe', 'ignore', 'pipe'],
95+
})
96+
void child.catch(() => undefined)
97+
const code = await new Promise<number>(resolve => {
98+
child.process.on('exit', c => resolve(c ?? -1))
99+
child.stdin!.end(
100+
JSON.stringify({ tool_name: 'Bash', tool_input: { command: 'ls' } }),
101+
)
102+
})
103+
assert.equal(code, 0)
104+
})
105+
106+
test('malformed payload fails open (exit 0)', async () => {
107+
const child = spawn(process.execPath, [HOOK], {
108+
stdio: ['pipe', 'ignore', 'pipe'],
109+
})
110+
void child.catch(() => undefined)
111+
const code = await new Promise<number>(resolve => {
112+
child.process.on('exit', c => resolve(c ?? -1))
113+
child.stdin!.end('{ not json')
114+
})
115+
assert.equal(code, 0)
116+
})
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"compilerOptions": {
3+
"declarationMap": false,
4+
"erasableSyntaxOnly": true,
5+
"module": "nodenext",
6+
"moduleResolution": "nodenext",
7+
"noEmit": true,
8+
"rewriteRelativeImportExtensions": true,
9+
"skipLibCheck": true,
10+
"sourceMap": false,
11+
"strict": true,
12+
"target": "esnext",
13+
"types": ["node"],
14+
"verbatimModuleSyntax": true
15+
}
16+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# no-direct-linter-guard
2+
3+
PreToolUse(Bash) hook that blocks invoking a linter or formatter binary
4+
directly. The fleet runs lint/format only through the repo scripts (`pnpm run
5+
lint` / `fix` / `check` / `format`) and the `scripts/fleet/*` wrappers — those
6+
own the explicit `-c .config/fleet/<oxlintrc|oxfmtrc>` flag and the ignore set.
7+
8+
## What it catches
9+
10+
A Bash command whose resolved binary is one of `oxlint`, `oxfmt`, `eslint`,
11+
`prettier`, `biome`, `dprint`, `rustfmt`, or `gofmt` (including the
12+
`node_modules/.bin/<tool>` path form), or a `cargo fmt` / `cargo clippy`
13+
subcommand. Detected by AST-parsing the command
14+
(`shell-command.mts`/`findInvocation`), so it matches across pipes, `&&` chains,
15+
and leading env vars and never false-matches a substring. `pnpm run …` and a
16+
`node scripts/fleet/…` invocation pass; non-format `cargo` subcommands
17+
(`cargo build`, `cargo test`) pass.
18+
19+
## Why
20+
21+
A bare formatter run is a double hazard. Configless `oxfmt`/`oxlint` falls back
22+
to its own defaults (double-quote + semicolon) and corrupts fleet files; the
23+
scripts always pass `-c .config/fleet/…`. A bare formatter also has no ignore
24+
scoping and will reformat vendored `upstream/` trees the fleet must never touch
25+
(the fleet `oxlintrc`/`oxfmtrc` ignore lists exclude `upstream/`,
26+
`third_party/`, `vendor/`, `external/`). `eslint` / `prettier` / `biome` /
27+
`dprint` are not fleet tools at all (see `no-other-linters-guard`); `cargo fmt`
28+
/ `rustfmt` / `gofmt` reflow hand-formatted code. Reaching past the scripts
29+
re-introduces every one of these. The committed-state companion is
30+
`scripts/fleet/check/only-oxlint-oxfmt.mts`; the source-ref companion is
31+
`socket/no-other-linters-guard`.
32+
33+
The scripts' own internal `node_modules/.bin/oxlint` spawns are child processes,
34+
not Claude Bash invocations, so this hook never sees them — only a top-level
35+
direct call is blocked.
36+
37+
## Bypass
38+
39+
Type `Allow direct-linter bypass` in a recent turn (for a genuine one-off).
40+
41+
## Exit codes
42+
43+
- `0` — pass (not Bash, a script wrapper, a non-format command, or bypassed)
44+
- `2` — block
45+
- Fails open on any internal error.

0 commit comments

Comments
 (0)