second-read 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +212 -2
- package/dist/checks.js +325 -0
- package/dist/claude.js +422 -0
- package/dist/cli.js +51 -0
- package/dist/config.js +193 -0
- package/dist/gh.js +179 -0
- package/dist/git.js +531 -0
- package/dist/main.js +433 -0
- package/dist/normalize.js +44 -0
- package/dist/shell.js +396 -0
- package/dist/toml.js +170 -0
- package/package.json +32 -4
package/dist/main.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
import { check, checkTerms, KIND_NAMES, KINDS, } from "./checks.js";
|
|
5
|
+
import { configDir, exemptMatcher, findRepoConfig, loadRepoConfig, loadUserConfig, mergeRules, USER_FILE, USER_FILE_TEMPLATE, } from "./config.js";
|
|
6
|
+
import { bashPosts, firstUnknown, isGithubTool, mcpPosts } from "./claude.js";
|
|
7
|
+
import { findRealGh, isShim, parseGh, postTexts, shimScript } from "./gh.js";
|
|
8
|
+
import { allowlistChanges, commitMessage, exitStatus, HOOKS, hooksDir, hooksInWorkTree, installHooks, installManaged, isManagedDir, pushRange, readPushed, refreshManaged, runPrevious, uninstallHooks, uninstallManaged, } from "./git.js";
|
|
9
|
+
import { UNKNOWN } from "./shell.js";
|
|
10
|
+
export const EXIT = { clean: 0, findings: 1, failed: 2 };
|
|
11
|
+
const USAGE = `Usage:
|
|
12
|
+
second-read check [--kind commit|issue|pr|comment|review] check the text on stdin
|
|
13
|
+
second-read init write your config file
|
|
14
|
+
second-read hooks install|uninstall add or remove the git hooks in this repository
|
|
15
|
+
second-read shim install|uninstall [folder] write or remove the gh shim
|
|
16
|
+
|
|
17
|
+
check exits 0 when the text is clean, 1 with findings, and 2 when the check itself fails.
|
|
18
|
+
`;
|
|
19
|
+
const FIX_TEXT = "fix the text and try again";
|
|
20
|
+
class UsageError extends Error {
|
|
21
|
+
}
|
|
22
|
+
// The commands that check text. When one fails, whatever called it refuses the post.
|
|
23
|
+
const CHECKS = ["check", "hook", "gh-shim", "claude-hook"];
|
|
24
|
+
export async function main(io) {
|
|
25
|
+
try {
|
|
26
|
+
return await run(io);
|
|
27
|
+
}
|
|
28
|
+
catch (e) {
|
|
29
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
30
|
+
if (e instanceof UsageError)
|
|
31
|
+
io.err(`second-read: ${message}\n\n${USAGE}`);
|
|
32
|
+
else if (CHECKS.includes(io.argv[0] ?? ""))
|
|
33
|
+
io.err(`second-read: the check failed: ${message}\n`);
|
|
34
|
+
else
|
|
35
|
+
io.err(`second-read: ${message}\n`);
|
|
36
|
+
return EXIT.failed;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
async function run(io) {
|
|
40
|
+
const [command, ...args] = io.argv;
|
|
41
|
+
switch (command) {
|
|
42
|
+
case "check":
|
|
43
|
+
return runCheck(io, parseCheckArgs(args));
|
|
44
|
+
case "init":
|
|
45
|
+
if (args.length > 0)
|
|
46
|
+
throw new UsageError(`init takes no arguments`);
|
|
47
|
+
return runInit(io);
|
|
48
|
+
case "hooks":
|
|
49
|
+
return runHooks(io, args);
|
|
50
|
+
case "hook":
|
|
51
|
+
return runHook(io, args);
|
|
52
|
+
case "shim":
|
|
53
|
+
return runShimSetup(io, args);
|
|
54
|
+
case "gh-shim":
|
|
55
|
+
return runShim(io, args);
|
|
56
|
+
case "claude-hook":
|
|
57
|
+
if (args.length > 0)
|
|
58
|
+
throw new UsageError(`claude-hook takes no arguments`);
|
|
59
|
+
return runClaudeHook(io);
|
|
60
|
+
case "help":
|
|
61
|
+
case "--help":
|
|
62
|
+
case "-h":
|
|
63
|
+
io.out(USAGE);
|
|
64
|
+
return EXIT.clean;
|
|
65
|
+
default:
|
|
66
|
+
throw new UsageError(command === undefined ? "no command given" : `unknown command "${command}"`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function parseCheckArgs(args) {
|
|
70
|
+
let kind;
|
|
71
|
+
for (let i = 0; i < args.length; i++) {
|
|
72
|
+
const arg = args[i];
|
|
73
|
+
let value;
|
|
74
|
+
if (arg === "--kind")
|
|
75
|
+
value = args[++i];
|
|
76
|
+
else if (arg.startsWith("--kind="))
|
|
77
|
+
value = arg.slice("--kind=".length);
|
|
78
|
+
else
|
|
79
|
+
throw new UsageError(`unknown option "${arg}"`);
|
|
80
|
+
if (value === undefined || !KINDS.includes(value)) {
|
|
81
|
+
throw new UsageError(`--kind must be one of ${KINDS.join(", ")}`);
|
|
82
|
+
}
|
|
83
|
+
kind = value;
|
|
84
|
+
}
|
|
85
|
+
return kind === undefined ? {} : { kind };
|
|
86
|
+
}
|
|
87
|
+
// Your config and the repository's allowlist, from the repository at `cwd`: the one being posted to.
|
|
88
|
+
function loadRules(io, cwd = io.cwd) {
|
|
89
|
+
const identity = io.identity();
|
|
90
|
+
const user = loadUserConfig(join(configDir(io.env, io.platform, identity.home), USER_FILE));
|
|
91
|
+
const repoPath = findRepoConfig(cwd);
|
|
92
|
+
const repo = repoPath === undefined ? undefined : loadRepoConfig(repoPath, user.terms);
|
|
93
|
+
return { identity, rules: mergeRules(user, repo), exemptFiles: repo?.exemptFiles ?? [] };
|
|
94
|
+
}
|
|
95
|
+
async function runCheck(io, { kind }) {
|
|
96
|
+
const { identity, rules } = loadRules(io);
|
|
97
|
+
const text = (await io.readStdin()).toString("utf8");
|
|
98
|
+
const findings = check(text, { identity, rules, ...(kind ? { kind } : {}) });
|
|
99
|
+
if (findings.length === 0)
|
|
100
|
+
return EXIT.clean;
|
|
101
|
+
io.out(report([{ where: kind ? KIND_NAMES[kind] : "this text", findings }], FIX_TEXT));
|
|
102
|
+
return EXIT.findings;
|
|
103
|
+
}
|
|
104
|
+
export function report(sections, fix) {
|
|
105
|
+
const total = sections.reduce((n, s) => n + s.findings.length, 0);
|
|
106
|
+
const count = total === 1 ? "1 finding" : `${total} findings`;
|
|
107
|
+
const rows = (findings) => findings.map((f) => {
|
|
108
|
+
const position = `${f.line}:${f.column}`;
|
|
109
|
+
const category = f.word ? `${f.category} (${f.word})` : f.category;
|
|
110
|
+
return ` ${position.padEnd(7)} ${category.padEnd(22)} ${JSON.stringify(f.text)}\n`;
|
|
111
|
+
});
|
|
112
|
+
if (sections.length === 1) {
|
|
113
|
+
const [only] = sections;
|
|
114
|
+
return `second-read: ${count} in ${only.where}. Nothing was changed; ${fix}.\n${rows(only.findings).join("")}`;
|
|
115
|
+
}
|
|
116
|
+
const body = sections.map((s) => `In ${s.where}:\n${rows(s.findings).join("")}`);
|
|
117
|
+
return `second-read: ${count}. Nothing was changed; ${fix}.\n${body.join("")}`;
|
|
118
|
+
}
|
|
119
|
+
function runHooks(io, args) {
|
|
120
|
+
const [action, ...rest] = args;
|
|
121
|
+
if ((action !== "install" && action !== "uninstall") || rest.length > 0) {
|
|
122
|
+
throw new UsageError(`hooks takes "install" or "uninstall"`);
|
|
123
|
+
}
|
|
124
|
+
const list = (done) => done.map((line) => ` ${line}\n`).join("");
|
|
125
|
+
if (action === "uninstall") {
|
|
126
|
+
const managed = uninstallManaged(io.cwd, io.env);
|
|
127
|
+
if (managed) {
|
|
128
|
+
io.out(`second-read: removed the hooks from this clone.\n${list(managed)}`);
|
|
129
|
+
return EXIT.clean;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const dir = hooksDir(io.cwd, io.env);
|
|
133
|
+
if (action === "install" && isManagedDir(dir, io.cwd, io.env)) {
|
|
134
|
+
const managed = refreshManaged(io.cwd, io.env, io.command);
|
|
135
|
+
io.out(`second-read: already installed in ${managed.dir}; wrote the hooks again, forwarding to ` +
|
|
136
|
+
`${managed.hooksPath}.\n${list(managed.done)}`);
|
|
137
|
+
return EXIT.clean;
|
|
138
|
+
}
|
|
139
|
+
// The installed hooks name paths on this machine, so they never go in a checked-in hooks folder.
|
|
140
|
+
if (action === "install" && hooksInWorkTree(dir, io.cwd, io.env)) {
|
|
141
|
+
const managed = installManaged(io.cwd, io.env, io.command);
|
|
142
|
+
io.out(`second-read: ${managed.hooksPath} is in the work tree, so the hooks went into ${managed.dir}, and ` +
|
|
143
|
+
`core.hooksPath points there now, for this clone only (it was ${JSON.stringify(managed.hooksPath)}).\n` +
|
|
144
|
+
list(managed.done) +
|
|
145
|
+
`Running the repository's own setup step again, if it sets core.hooksPath, turns the guards off; ` +
|
|
146
|
+
`run "second-read hooks install" after it.\n`);
|
|
147
|
+
return EXIT.clean;
|
|
148
|
+
}
|
|
149
|
+
if (action === "install")
|
|
150
|
+
mkdirSync(dir, { recursive: true });
|
|
151
|
+
const done = action === "install" ? installHooks(dir, io.command) : uninstallHooks(dir);
|
|
152
|
+
io.out(`second-read: ${dir}\n${list(done)}`);
|
|
153
|
+
return EXIT.clean;
|
|
154
|
+
}
|
|
155
|
+
// What an installed hook runs: `second-read hook <the hook's own path> <git's arguments>`.
|
|
156
|
+
async function runHook(io, args) {
|
|
157
|
+
const [hookPath, ...gitArgs] = args;
|
|
158
|
+
const name = hookPath === undefined ? undefined : basename(hookPath);
|
|
159
|
+
if (hookPath === undefined || !HOOKS.includes(name)) {
|
|
160
|
+
throw new UsageError(`hook is run by the git hooks that "second-read hooks install" adds`);
|
|
161
|
+
}
|
|
162
|
+
const { identity, rules, exemptFiles } = loadRules(io);
|
|
163
|
+
const exempt = exemptMatcher(exemptFiles);
|
|
164
|
+
let input;
|
|
165
|
+
let sections;
|
|
166
|
+
let changes = [];
|
|
167
|
+
const found = { commits: 0, tags: new Set(), tagNames: new Set(), names: [] };
|
|
168
|
+
if (name === "commit-msg") {
|
|
169
|
+
const file = gitArgs[0];
|
|
170
|
+
if (file === undefined)
|
|
171
|
+
throw new Error("commit-msg was given no message file");
|
|
172
|
+
const message = commitMessage(readFileSync(resolve(io.cwd, file), "utf8"), io.cwd, io.env);
|
|
173
|
+
sections = [{ where: KIND_NAMES.commit, findings: check(message, { identity, rules, kind: "commit" }) }];
|
|
174
|
+
}
|
|
175
|
+
else {
|
|
176
|
+
input = await io.readStdin();
|
|
177
|
+
const range = pushRange(input.toString("utf8"), io.cwd, io.env);
|
|
178
|
+
sections = [];
|
|
179
|
+
for (const ref of range.refs) {
|
|
180
|
+
const findings = checkName(ref.name, identity, rules);
|
|
181
|
+
if (findings.length === 0)
|
|
182
|
+
continue;
|
|
183
|
+
sections.push({ where: `the name of ${ref.kind} ${ref.name}`, findings });
|
|
184
|
+
found.names.push(ref);
|
|
185
|
+
}
|
|
186
|
+
const tagRefs = new Set(range.refs.filter((r) => r.kind === "tag").map((r) => r.name));
|
|
187
|
+
await readPushed(range, gitArgs[0] ?? "", io.cwd, io.env, (pushed) => {
|
|
188
|
+
// An annotated tag holds its own name, which goes with it whatever the ref is called. Either way the fix is
|
|
189
|
+
// a new tag: rewriting it with git tag -f keeps the name.
|
|
190
|
+
if (pushed.kind === "tag" && tagRefs.has(pushed.commit)) {
|
|
191
|
+
const flagged = found.names.findIndex((r) => r.kind === "tag" && r.name === pushed.commit);
|
|
192
|
+
if (flagged >= 0) {
|
|
193
|
+
found.names.splice(flagged, 1);
|
|
194
|
+
found.tagNames.add(pushed.commit);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
else if (pushed.kind === "tag") {
|
|
198
|
+
const findings = checkName(pushed.commit, identity, rules);
|
|
199
|
+
if (findings.length > 0) {
|
|
200
|
+
sections.push({ where: `the name written in tag ${pushed.commit}`, findings });
|
|
201
|
+
found.tagNames.add(pushed.commit);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const findings = checkPushed(pushed, identity, rules, exempt);
|
|
205
|
+
if (findings.length === 0)
|
|
206
|
+
return;
|
|
207
|
+
sections.push({ where: pushedWhere(pushed), findings });
|
|
208
|
+
if (pushed.kind === "tag")
|
|
209
|
+
found.tags.add(pushed.commit);
|
|
210
|
+
else
|
|
211
|
+
found.commits++;
|
|
212
|
+
});
|
|
213
|
+
changes = unreviewed(allowlistChanges(range, gitArgs[0] ?? "", io.cwd, io.env), io.env[ALLOWLIST_REVIEWED]);
|
|
214
|
+
}
|
|
215
|
+
sections = sections.filter((s) => s.findings.length > 0);
|
|
216
|
+
if (sections.length > 0) {
|
|
217
|
+
io.err(report(sections, name === "commit-msg" ? FIX_TEXT : pushFix(found)));
|
|
218
|
+
}
|
|
219
|
+
if (changes.length > 0)
|
|
220
|
+
io.err(allowlistReport(changes));
|
|
221
|
+
if (sections.length > 0 || changes.length > 0)
|
|
222
|
+
return EXIT.findings;
|
|
223
|
+
return runPrevious(resolve(io.cwd, hookPath), gitArgs, io.cwd, io.env, input);
|
|
224
|
+
}
|
|
225
|
+
// Names the versions of .second-read.toml someone has read, by blob id, and the commits that delete one, so a
|
|
226
|
+
// push that brings only those goes through. An id stands for that change only: a later change is refused again,
|
|
227
|
+
// even with the variable left set.
|
|
228
|
+
export const ALLOWLIST_REVIEWED = "SECOND_READ_ALLOWLIST_REVIEWED";
|
|
229
|
+
function unreviewed(changes, reviewed) {
|
|
230
|
+
const ids = new Set((reviewed ?? "").toLowerCase().split(/[\s,]+/));
|
|
231
|
+
return changes.filter((c) => !ids.has(c.id.toLowerCase()));
|
|
232
|
+
}
|
|
233
|
+
// The allowlist decides what the check lets through, and the push is checked against the version it brings.
|
|
234
|
+
function allowlistReport(changes) {
|
|
235
|
+
const rows = changes.map((c) => c.deleted ? ` commit ${c.commit} deletes ${c.file}\n` : ` commit ${c.commit} changes ${c.file}, blob ${c.id}\n`);
|
|
236
|
+
const ids = [...new Set(changes.map((c) => c.id))].join(" ");
|
|
237
|
+
return (`second-read: this push changes the repository's allowlist, which decides what the check lets through.\n` +
|
|
238
|
+
rows.join("") +
|
|
239
|
+
`A person should read each change (git show <commit> -- <file>) before it's pushed. ` +
|
|
240
|
+
`If it's meant, push again with ${ALLOWLIST_REVIEWED}="${ids}"; ` +
|
|
241
|
+
`it covers only these changes.\n`);
|
|
242
|
+
}
|
|
243
|
+
function pushFix(found) {
|
|
244
|
+
// A tag that needs a new name gets a new message along with it.
|
|
245
|
+
const tags = [...found.tags].filter((t) => !found.tagNames.has(t)).length;
|
|
246
|
+
const tag = `the ${tags === 1 ? "tag" : "tags"} with git tag -f -a`;
|
|
247
|
+
const fixes = [];
|
|
248
|
+
if (found.commits > 0)
|
|
249
|
+
fixes.push(tags > 0 ? `fix the commits, and ${tag}` : "fix the commits");
|
|
250
|
+
else if (tags > 0)
|
|
251
|
+
fixes.push(`fix ${tag}`);
|
|
252
|
+
if (found.tagNames.size > 0) {
|
|
253
|
+
const noun = found.tagNames.size === 1 ? "the tag" : "the tags";
|
|
254
|
+
fixes.push(`move ${noun} to a clean name (git tag -a <new name> <commit>, then git tag -d <old name>)`);
|
|
255
|
+
}
|
|
256
|
+
if (found.names.length > 0) {
|
|
257
|
+
const kinds = new Set(found.names.map((r) => r.kind));
|
|
258
|
+
const kind = kinds.size === 1 ? [...kinds][0] : "ref";
|
|
259
|
+
const noun = found.names.length === 1 ? kind : kind === "branch" ? "branches" : `${kind}s`;
|
|
260
|
+
const how = [];
|
|
261
|
+
if (kinds.has("branch"))
|
|
262
|
+
how.push("git branch -m");
|
|
263
|
+
if (kinds.has("tag"))
|
|
264
|
+
how.push("git tag <new name> <old name>");
|
|
265
|
+
fixes.push(`push the ${noun} under a new name${how.length > 0 ? ` (${how.join(", or ")})` : ""}`);
|
|
266
|
+
}
|
|
267
|
+
if (fixes.length === 1 && fixes[0] === "fix the commits")
|
|
268
|
+
return "fix the commits and push again";
|
|
269
|
+
return `${fixes.join(", ")}, and push again`;
|
|
270
|
+
}
|
|
271
|
+
// A ref name joins its words with - _ / or . where a message has spaces, and a money phrase of more than one word
|
|
272
|
+
// matches only across spaces. So the name is checked again with those read as spaces, which keeps every column
|
|
273
|
+
// where it was. Only money comes from that pass: an email or a path in the name has to be read as it is.
|
|
274
|
+
function checkName(name, identity, rules) {
|
|
275
|
+
const findings = check(name, { identity, rules, kind: "commit" });
|
|
276
|
+
const spaced = check(name.replace(/[-_/.]/g, " "), { identity, rules, kind: "commit" }).filter((f) => f.category === "money" && !findings.some((g) => f.start < g.end && g.start < f.end));
|
|
277
|
+
if (spaced.length === 0)
|
|
278
|
+
return findings;
|
|
279
|
+
const quoted = spaced.map((f) => ({ ...f, text: name.slice(f.start, f.end) }));
|
|
280
|
+
return [...findings, ...quoted].sort((a, b) => a.start - b.start);
|
|
281
|
+
}
|
|
282
|
+
// An exempt file's added lines are checked for private terms and the user's own addresses only, which no
|
|
283
|
+
// repository can allow. Messages and tags are always checked in full.
|
|
284
|
+
function checkPushed(pushed, identity, rules, exempt) {
|
|
285
|
+
const findings = pushed.kind === "added" && pushed.file !== undefined && exempt(pushed.file)
|
|
286
|
+
? checkTerms(pushed.text, { identity, rules })
|
|
287
|
+
: check(pushed.text, { identity, rules, kind: "commit" });
|
|
288
|
+
// Added lines are reported by their line number in the file.
|
|
289
|
+
return pushed.lines ? findings.map((f) => ({ ...f, line: pushed.lines[f.line - 1] ?? f.line })) : findings;
|
|
290
|
+
}
|
|
291
|
+
function pushedWhere(pushed) {
|
|
292
|
+
if (pushed.kind === "tag")
|
|
293
|
+
return `the message of tag ${pushed.commit}`;
|
|
294
|
+
return pushed.kind === "message"
|
|
295
|
+
? `the message of commit ${pushed.commit}`
|
|
296
|
+
: `the lines commit ${pushed.commit} adds to ${pushed.file}`;
|
|
297
|
+
}
|
|
298
|
+
function runShimSetup(io, args) {
|
|
299
|
+
const [action, folder, ...rest] = args;
|
|
300
|
+
if ((action !== "install" && action !== "uninstall") || rest.length > 0) {
|
|
301
|
+
throw new UsageError(`shim takes "install" or "uninstall", and optionally a folder`);
|
|
302
|
+
}
|
|
303
|
+
if (io.platform === "win32")
|
|
304
|
+
throw new Error("the gh shim is a shell script, and Windows isn't supported yet");
|
|
305
|
+
const dir = folder === undefined ? join(configDir(io.env, io.platform, io.identity().home), "bin") : resolve(io.cwd, folder);
|
|
306
|
+
const path = join(dir, "gh");
|
|
307
|
+
const theirs = existsSync(path) && !isShim(path);
|
|
308
|
+
if (action === "uninstall") {
|
|
309
|
+
if (theirs)
|
|
310
|
+
io.out(`second-read: ${path} isn't the shim; it was left as it is.\n`);
|
|
311
|
+
else if (existsSync(path)) {
|
|
312
|
+
unlinkSync(path);
|
|
313
|
+
io.out(`second-read: removed ${path}.\n`);
|
|
314
|
+
}
|
|
315
|
+
else
|
|
316
|
+
io.out(`second-read: there's no shim at ${path}.\n`);
|
|
317
|
+
return EXIT.clean;
|
|
318
|
+
}
|
|
319
|
+
if (theirs)
|
|
320
|
+
throw new Error(`${path} is already there and isn't the shim; nothing was written`);
|
|
321
|
+
mkdirSync(dir, { recursive: true });
|
|
322
|
+
writeFileSync(path, shimScript(io.command), { mode: 0o755 });
|
|
323
|
+
io.out(`second-read: wrote ${path}.\n` +
|
|
324
|
+
`Put its folder ahead of the real gh on PATH, for example in your shell's startup file:\n\n` +
|
|
325
|
+
` export PATH=${JSON.stringify(dir)}:"$PATH"\n`);
|
|
326
|
+
return EXIT.clean;
|
|
327
|
+
}
|
|
328
|
+
// What the shim runs: `second-read gh-shim <the shim's own path> <gh's arguments>`. A posting command is
|
|
329
|
+
// checked first; every command then runs the real gh with the same arguments and input.
|
|
330
|
+
async function runShim(io, args) {
|
|
331
|
+
const [shimPath, ...ghArgs] = args;
|
|
332
|
+
if (shimPath === undefined) {
|
|
333
|
+
throw new UsageError(`gh-shim is run by the gh shim that "second-read shim install" writes`);
|
|
334
|
+
}
|
|
335
|
+
const post = parseGh(ghArgs);
|
|
336
|
+
let input;
|
|
337
|
+
if (post) {
|
|
338
|
+
const { identity, rules } = loadRules(io);
|
|
339
|
+
if (post.bodyFiles.includes("-"))
|
|
340
|
+
input = await io.readStdin();
|
|
341
|
+
const stdin = input?.toString("utf8") ?? "";
|
|
342
|
+
const texts = postTexts(post, (file) => (file === "-" ? stdin : readFileSync(resolve(io.cwd, file), "utf8")));
|
|
343
|
+
const sections = texts
|
|
344
|
+
.map(([where, text]) => ({ where, findings: check(text, { identity, rules, kind: post.kind }) }))
|
|
345
|
+
.filter((s) => s.findings.length > 0);
|
|
346
|
+
if (sections.length > 0) {
|
|
347
|
+
io.err(report(sections, FIX_TEXT));
|
|
348
|
+
return EXIT.findings;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const gh = findRealGh(io.env.PATH ?? "", io.cwd, io.platform, [dirname(resolve(io.cwd, shimPath))]);
|
|
352
|
+
if (gh === undefined) {
|
|
353
|
+
io.err(`second-read: can't find the real gh on PATH, after the shim's own folder\n`);
|
|
354
|
+
return 127;
|
|
355
|
+
}
|
|
356
|
+
const result = spawnSync(gh, ghArgs, {
|
|
357
|
+
cwd: io.cwd,
|
|
358
|
+
env: io.env,
|
|
359
|
+
stdio: [input === undefined ? "inherit" : "pipe", "inherit", "inherit"],
|
|
360
|
+
...(input === undefined ? {} : { input }),
|
|
361
|
+
});
|
|
362
|
+
if (result.error)
|
|
363
|
+
throw new Error(`can't run ${gh}: ${result.error.message}`);
|
|
364
|
+
return exitStatus(result);
|
|
365
|
+
}
|
|
366
|
+
// What the Claude Code plugin's PreToolUse hook runs, with the tool call as JSON on stdin. A post with findings, or
|
|
367
|
+
// one that can't be checked, is denied, and the reason goes to Claude. Otherwise it prints nothing and the call
|
|
368
|
+
// goes on as it would have: allowing it would skip the user's own permission prompt.
|
|
369
|
+
async function runClaudeHook(io) {
|
|
370
|
+
let reason;
|
|
371
|
+
try {
|
|
372
|
+
reason = claudeRefusal(io, JSON.parse((await io.readStdin()).toString("utf8")));
|
|
373
|
+
}
|
|
374
|
+
catch (e) {
|
|
375
|
+
reason = `second-read: the check failed, so this call was stopped: ${e instanceof Error ? e.message : String(e)}\n`;
|
|
376
|
+
}
|
|
377
|
+
if (reason !== undefined) {
|
|
378
|
+
const decision = { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: reason };
|
|
379
|
+
io.out(`${JSON.stringify({ hookSpecificOutput: decision })}\n`);
|
|
380
|
+
}
|
|
381
|
+
return EXIT.clean;
|
|
382
|
+
}
|
|
383
|
+
function claudeRefusal(io, call) {
|
|
384
|
+
if (call === null || typeof call !== "object")
|
|
385
|
+
throw new Error("the hook was given no tool call");
|
|
386
|
+
const { tool_name: tool, tool_input: input, cwd: callCwd } = call;
|
|
387
|
+
const cwd = typeof callCwd === "string" ? callCwd : io.cwd;
|
|
388
|
+
let posts = [];
|
|
389
|
+
if (tool === "Bash") {
|
|
390
|
+
const command = input?.command;
|
|
391
|
+
if (typeof command !== "string")
|
|
392
|
+
throw new Error("the Bash call has no command");
|
|
393
|
+
posts = bashPosts(command, cwd, io.env.HOME || io.env.USERPROFILE || cwd);
|
|
394
|
+
}
|
|
395
|
+
else if (typeof tool === "string" && isGithubTool(tool)) {
|
|
396
|
+
posts = mcpPosts(tool, input, cwd);
|
|
397
|
+
}
|
|
398
|
+
if (posts.length === 0)
|
|
399
|
+
return undefined;
|
|
400
|
+
const unread = posts.filter((p) => p.text.includes(UNKNOWN));
|
|
401
|
+
if (unread.length > 0) {
|
|
402
|
+
const rows = unread.map((p) => ` ${p.where}: ${firstUnknown(p.text)}\n`);
|
|
403
|
+
return (`second-read: can't check what this command posts, since the shell fills part of it in only when it runs.\n` +
|
|
404
|
+
rows.join("") +
|
|
405
|
+
`Nothing was posted; write the text out in the command, or in a file passed by its path, and try again.\n`);
|
|
406
|
+
}
|
|
407
|
+
const rules = new Map();
|
|
408
|
+
const sections = posts
|
|
409
|
+
.map((post) => {
|
|
410
|
+
if (!rules.has(post.cwd))
|
|
411
|
+
rules.set(post.cwd, loadRules(io, post.cwd));
|
|
412
|
+
const { identity, rules: merged, exemptFiles } = rules.get(post.cwd);
|
|
413
|
+
const exempt = post.file !== undefined && exemptMatcher(exemptFiles)(post.file);
|
|
414
|
+
const options = { identity, rules: merged, kind: post.kind };
|
|
415
|
+
return { where: post.where, findings: exempt ? checkTerms(post.text, options) : check(post.text, options) };
|
|
416
|
+
})
|
|
417
|
+
.filter((s) => s.findings.length > 0);
|
|
418
|
+
return sections.length > 0 ? report(sections, FIX_TEXT) : undefined;
|
|
419
|
+
}
|
|
420
|
+
function runInit(io) {
|
|
421
|
+
const dir = configDir(io.env, io.platform, io.identity().home);
|
|
422
|
+
const path = join(dir, USER_FILE);
|
|
423
|
+
if (existsSync(path)) {
|
|
424
|
+
io.out(`second-read: ${path} already exists; it was left as it is.\n`);
|
|
425
|
+
return EXIT.clean;
|
|
426
|
+
}
|
|
427
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
428
|
+
writeFileSync(path, USER_FILE_TEMPLATE, { mode: 0o600, flag: "wx" });
|
|
429
|
+
io.out(`second-read: wrote ${path}.\n` +
|
|
430
|
+
`Add a private term by putting it in the terms list, one per line:\n\n` +
|
|
431
|
+
` terms = [\n "example-private-project",\n ]\n`);
|
|
432
|
+
return EXIT.clean;
|
|
433
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Text is folded before matching, so a term still matches when it's written in another case, with accents,
|
|
2
|
+
// in fullwidth or other compatibility forms, or with invisible characters inside it.
|
|
3
|
+
const MARKS = /[\p{M}\p{Cf}]/gu;
|
|
4
|
+
export function foldChar(ch) {
|
|
5
|
+
return ch.normalize("NFKD").toLowerCase().normalize("NFKD").replace(MARKS, "");
|
|
6
|
+
}
|
|
7
|
+
export function fold(input) {
|
|
8
|
+
let text = "";
|
|
9
|
+
const starts = [];
|
|
10
|
+
const ends = [];
|
|
11
|
+
let i = 0;
|
|
12
|
+
for (const ch of input) {
|
|
13
|
+
const folded = foldChar(ch);
|
|
14
|
+
for (let k = 0; k < folded.length; k++) {
|
|
15
|
+
starts.push(i);
|
|
16
|
+
ends.push(i + ch.length);
|
|
17
|
+
}
|
|
18
|
+
text += folded;
|
|
19
|
+
i += ch.length;
|
|
20
|
+
}
|
|
21
|
+
return { text, starts, ends };
|
|
22
|
+
}
|
|
23
|
+
// Maps a span of folded text back to the span of the original it was folded from.
|
|
24
|
+
export function originalSpan(folded, start, end) {
|
|
25
|
+
return [folded.starts[start], folded.ends[end - 1]];
|
|
26
|
+
}
|
|
27
|
+
export function escapeRegExp(s) {
|
|
28
|
+
return s.replace(/[.*+?^${}()|[\]\\/]/g, "\\$&");
|
|
29
|
+
}
|
|
30
|
+
const PART_SEPARATOR = "[^\\p{L}\\p{N}]{0,3}";
|
|
31
|
+
const BEFORE = "(?<![\\p{L}\\p{N}])";
|
|
32
|
+
const AFTER = "(?![\\p{L}\\p{N}])";
|
|
33
|
+
// A term matches at word boundaries in folded text. Its words may be joined by spaces, hyphens, dots,
|
|
34
|
+
// underscores or nothing, so "Example Project" also matches "example-project" and "ExampleProject".
|
|
35
|
+
export function termPattern(term) {
|
|
36
|
+
const parts = fold(term).text.split(/[^\p{L}\p{N}]+/u).filter(Boolean);
|
|
37
|
+
if (parts.length === 0)
|
|
38
|
+
return null;
|
|
39
|
+
return new RegExp(BEFORE + parts.map(escapeRegExp).join(PART_SEPARATOR) + AFTER, "gu");
|
|
40
|
+
}
|
|
41
|
+
// A pattern over folded text, matched at word boundaries. A space in `source` matches any run of whitespace.
|
|
42
|
+
export function wordPattern(source) {
|
|
43
|
+
return new RegExp(BEFORE + "(?:" + source.replaceAll(" ", "\\s+") + ")" + AFTER, "gu");
|
|
44
|
+
}
|