@jwilger/pi-development-system 0.8.1 → 0.8.3
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/package.json +1 -1
- package/skills/delivery-discipline/SKILL.md +2 -1
- package/src/core/commit-command.ts +80 -34
- package/src/core/commit-message.ts +26 -2
- package/src/core/git-intent.ts +9 -1
- package/src/core/git-invocations.ts +44 -10
- package/src/core/push-command.ts +6 -3
- package/src/core/redact.ts +5 -1
- package/src/gates/commit-guard.ts +53 -25
- package/src/gates/push-guard.ts +15 -3
package/package.json
CHANGED
|
@@ -41,7 +41,8 @@ check now uses expiry; clock-skew tolerance is deliberately not added.
|
|
|
41
41
|
|
|
42
42
|
Do not start unrelated work while trunk CI is red. Fix it first; a red build
|
|
43
43
|
hides every later failure. A push to a red trunk is a hard stop unless the
|
|
44
|
-
commit is `fix
|
|
44
|
+
commit is a `fix` and Jev reads its diff as only repairing the failing run (it needs
|
|
45
|
+
the failing log and Jev online; otherwise the push is a hard stop). If `gh`
|
|
45
46
|
is missing or unauthenticated, CI reads as unknown and the push is allowed:
|
|
46
47
|
check CI by other means before relying on it.
|
|
47
48
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { splitLines } from "./git-intent.ts";
|
|
1
2
|
import { resolveGit } from "./git-invocations.ts";
|
|
2
3
|
|
|
3
4
|
export type CommitExtraction =
|
|
@@ -7,14 +8,17 @@ export type CommitExtraction =
|
|
|
7
8
|
| { readonly kind: "unknown"; readonly trailers: readonly string[] };
|
|
8
9
|
|
|
9
10
|
const HEREDOC =
|
|
10
|
-
/<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]
|
|
11
|
+
/<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1([^\n]*)\n([\s\S]*?)\n[ \t]*\2[ \t]*(?:\n|$)/g;
|
|
11
12
|
|
|
12
|
-
/**
|
|
13
|
+
/**
|
|
14
|
+
* Heredoc bodies are data: take them out so their quotes cannot confuse line splitting. The rest of
|
|
15
|
+
* the opening line stays (`| git commit -F -`) with a `#HD<n>` marker naming the removed body.
|
|
16
|
+
*/
|
|
13
17
|
function stripHeredocs(command: string): { rest: string; bodies: string[] } {
|
|
14
18
|
const bodies: string[] = [];
|
|
15
|
-
const rest = command.replace(HEREDOC, (
|
|
16
|
-
bodies.push(
|
|
17
|
-
return
|
|
19
|
+
const rest = command.replace(HEREDOC, (...m: unknown[]) => {
|
|
20
|
+
bodies.push(String(m[4]).trim());
|
|
21
|
+
return `${String(m[3])} #HD${bodies.length - 1}\n`;
|
|
18
22
|
});
|
|
19
23
|
return { rest, bodies };
|
|
20
24
|
}
|
|
@@ -33,18 +37,31 @@ type Parsed = {
|
|
|
33
37
|
readonly next: number;
|
|
34
38
|
};
|
|
35
39
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
["--
|
|
39
|
-
["--
|
|
40
|
+
/** Option, kind and the shortest prefix git still resolves uniquely (`--fi` would be `--fixup`). */
|
|
41
|
+
const LONG: ReadonlyArray<readonly [string, Parsed["kind"], number]> = [
|
|
42
|
+
["--message", "message", 4],
|
|
43
|
+
["--file", "file", 5],
|
|
44
|
+
["--trailer", "trailer", 4],
|
|
40
45
|
];
|
|
41
46
|
|
|
47
|
+
/** The option a `--name`/`--na=value` spelling means; git accepts any unambiguous prefix (3+ chars). */
|
|
48
|
+
function longOption(arg: string): { kind: Parsed["kind"]; inline: string | undefined } | undefined {
|
|
49
|
+
if (!arg.startsWith("--")) return undefined;
|
|
50
|
+
const eq = arg.indexOf("=");
|
|
51
|
+
const name = eq < 0 ? arg : arg.slice(0, eq);
|
|
52
|
+
const hit = LONG.find(([long, , min]) => name.length >= min && long.startsWith(name));
|
|
53
|
+
if (hit === undefined) return undefined;
|
|
54
|
+
return { kind: hit[1], inline: eq < 0 ? undefined : arg.slice(eq + 1) };
|
|
55
|
+
}
|
|
56
|
+
|
|
42
57
|
/** Reads the message/file/trailer option starting at args[i], in any of its spellings. */
|
|
43
58
|
function parseArg(args: readonly string[], i: number): Parsed | undefined {
|
|
44
59
|
const a = args[i] ?? "";
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
60
|
+
const long = longOption(a);
|
|
61
|
+
if (long !== undefined) {
|
|
62
|
+
return long.inline === undefined
|
|
63
|
+
? { kind: long.kind, value: args[i + 1], next: i + 2 }
|
|
64
|
+
: { kind: long.kind, value: long.inline, next: i + 1 };
|
|
48
65
|
}
|
|
49
66
|
if (a === "-F") return { kind: "file", value: args[i + 1], next: i + 2 };
|
|
50
67
|
if (/^-F./.test(a)) return { kind: "file", value: a.slice(2), next: i + 1 };
|
|
@@ -55,17 +72,21 @@ function parseArg(args: readonly string[], i: number): Parsed | undefined {
|
|
|
55
72
|
return undefined;
|
|
56
73
|
}
|
|
57
74
|
|
|
75
|
+
const OPAQUE_VALUE = /\$\(|`|^\$\{?\w+\}?$/;
|
|
76
|
+
|
|
77
|
+
function record(options: Options, parsed: Parsed): void {
|
|
78
|
+
if (parsed.kind === "file") options.file = parsed.value;
|
|
79
|
+
else if (parsed.kind === "trailer") options.trailers.push(parsed.value ?? "");
|
|
80
|
+
else if (parsed.value === undefined || OPAQUE_VALUE.test(parsed.value)) options.opaque = true;
|
|
81
|
+
else options.messages.push(parsed.value);
|
|
82
|
+
}
|
|
83
|
+
|
|
58
84
|
function readOptions(args: readonly string[]): Options {
|
|
59
85
|
const options: Options = { messages: [], trailers: [], file: undefined, opaque: false };
|
|
60
|
-
let i = 0;
|
|
61
|
-
while (i < args.length) {
|
|
86
|
+
for (let i = 0; i < args.length; ) {
|
|
62
87
|
const parsed = parseArg(args, i);
|
|
88
|
+
if (parsed !== undefined) record(options, parsed);
|
|
63
89
|
i = parsed?.next ?? i + 1;
|
|
64
|
-
if (parsed === undefined) continue;
|
|
65
|
-
if (parsed.kind === "file") options.file = parsed.value;
|
|
66
|
-
else if (parsed.kind === "trailer") options.trailers.push(parsed.value ?? "");
|
|
67
|
-
else if (parsed.value === undefined || /\$\(|`/.test(parsed.value)) options.opaque = true;
|
|
68
|
-
else options.messages.push(parsed.value);
|
|
69
90
|
}
|
|
70
91
|
return options;
|
|
71
92
|
}
|
|
@@ -75,24 +96,49 @@ const unknown = (trailers: readonly string[] = []): CommitExtraction => ({
|
|
|
75
96
|
trailers,
|
|
76
97
|
});
|
|
77
98
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
99
|
+
const STDIN_PATHS = new Set(["-", "/dev/stdin", "/proc/self/fd/0"]);
|
|
100
|
+
const MARKER = /#HD(\d+)/g;
|
|
101
|
+
|
|
102
|
+
/** Body of the first heredoc opened on this line or later (its stdin, or the `$(cat <<EOF)` message). */
|
|
103
|
+
const heredocFrom = (lines: readonly string[], line: number, bodies: readonly string[]) => {
|
|
104
|
+
for (const text of lines.slice(line)) {
|
|
105
|
+
const hit = [...text.matchAll(MARKER)][0];
|
|
106
|
+
if (hit !== undefined) return bodies[Number(hit[1])];
|
|
86
107
|
}
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
108
|
+
return undefined;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function extractOne(args: readonly string[], heredoc: string | undefined): CommitExtraction {
|
|
112
|
+
const options = readOptions(args);
|
|
113
|
+
if (options.file !== undefined && STDIN_PATHS.has(options.file)) {
|
|
91
114
|
return heredoc === undefined
|
|
92
|
-
?
|
|
115
|
+
? { kind: "file", path: "-" }
|
|
93
116
|
: { kind: "message", message: heredoc };
|
|
94
117
|
}
|
|
95
|
-
if (options.
|
|
118
|
+
if (options.file !== undefined) return { kind: "file", path: options.file };
|
|
119
|
+
const parts = [...options.messages];
|
|
120
|
+
if (options.opaque) {
|
|
121
|
+
if (heredoc === undefined) return unknown(options.trailers);
|
|
122
|
+
parts.push(heredoc);
|
|
123
|
+
}
|
|
124
|
+
if (parts.length === 0) return unknown(options.trailers);
|
|
96
125
|
const trailers = options.trailers.length === 0 ? "" : `\n\n${options.trailers.join("\n")}`;
|
|
97
|
-
return { kind: "message", message: `${
|
|
126
|
+
return { kind: "message", message: `${parts.join("\n\n")}${trailers}` };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** One extraction per `git commit` in the command (empty when it commits nothing). */
|
|
130
|
+
export function extractCommits(command: string): CommitExtraction[] {
|
|
131
|
+
const { rest, bodies } = stripHeredocs(command);
|
|
132
|
+
const resolution = resolveGit(rest);
|
|
133
|
+
const lines = splitLines(rest);
|
|
134
|
+
const found = resolution.invocations.flatMap((g) =>
|
|
135
|
+
g.sub === "commit" ? [extractOne(g.args, heredocFrom(lines, g.line, bodies))] : [],
|
|
136
|
+
);
|
|
137
|
+
const hidden = resolution.opaque && /\bcommit\b/.test(command);
|
|
138
|
+
return hidden ? [...found, unknown()] : found;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The first commit a shell command makes: its message, or why it cannot be known. */
|
|
142
|
+
export function extractCommit(command: string): CommitExtraction {
|
|
143
|
+
return extractCommits(command)[0] ?? { kind: "not-commit" };
|
|
98
144
|
}
|
|
@@ -93,13 +93,37 @@ export function findForbiddenTrailers(
|
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
const AI_TRAILER_ANYWHERE =
|
|
97
|
+
/(?:co-authored-by|generated-by)\s*[:=]|signed-off-by\s*[:=][^\n]*\b(?:ai|claude|copilot|codex|gpt|chatgpt|openai|anthropic|gemini|llm)\b/gi;
|
|
98
|
+
|
|
99
|
+
/** AI-attribution trailer keys anywhere in free text (command lines, quoted strings, printf bodies). */
|
|
100
|
+
export function findForbiddenTrailerKeys(text: string): string[] {
|
|
101
|
+
return [...text.matchAll(AI_TRAILER_ANYWHERE)].map((m) => m[0].trim());
|
|
102
|
+
}
|
|
103
|
+
|
|
96
104
|
const isBullets = (paragraph: string): boolean =>
|
|
97
105
|
paragraph.split("\n").every((line) => /^\s*(?:[-*+]|\d+[.)])\s/.test(line));
|
|
98
106
|
|
|
99
107
|
const wordCount = (text: string): number => text.split(/\s+/).filter((w) => w !== "").length;
|
|
100
108
|
|
|
109
|
+
const PROSE_TRAILER_WORDS = 5;
|
|
110
|
+
|
|
111
|
+
/** A trailer-shaped line whose value is a sentence ("Reason: the cache was stale…") is prose. */
|
|
112
|
+
const isProseTrailer = (line: string): boolean => {
|
|
113
|
+
const m = TRAILER.exec(line.trim());
|
|
114
|
+
return m !== null && m[1] !== "BREAKING CHANGE" && wordCount(m[2] ?? "") >= PROSE_TRAILER_WORDS;
|
|
115
|
+
};
|
|
116
|
+
|
|
101
117
|
/** A body needs at least one prose paragraph (not a file list) that says more than a few words. */
|
|
102
118
|
export function hasRationaleBody(message: string): boolean {
|
|
103
|
-
const { body } = split(message);
|
|
104
|
-
|
|
119
|
+
const { body, trailers } = split(message);
|
|
120
|
+
const trailerBlockLines =
|
|
121
|
+
withoutComments(message)
|
|
122
|
+
.split(/\n\s*\n/)
|
|
123
|
+
.at(-1)
|
|
124
|
+
?.split("\n") ?? [];
|
|
125
|
+
const proseTrailer = trailers.length > 0 && trailerBlockLines.some(isProseTrailer);
|
|
126
|
+
return (
|
|
127
|
+
proseTrailer || body.some((paragraph) => !isBullets(paragraph) && wordCount(paragraph) >= 5)
|
|
128
|
+
);
|
|
105
129
|
}
|
package/src/core/git-intent.ts
CHANGED
|
@@ -106,7 +106,15 @@ export const DATA_ONLY = new Set([
|
|
|
106
106
|
]);
|
|
107
107
|
export const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
108
108
|
export const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
|
|
109
|
-
export const GLOBAL_WITH_VALUE = new Set([
|
|
109
|
+
export const GLOBAL_WITH_VALUE = new Set([
|
|
110
|
+
"-c",
|
|
111
|
+
"-C",
|
|
112
|
+
"--git-dir",
|
|
113
|
+
"--work-tree",
|
|
114
|
+
"--namespace",
|
|
115
|
+
"--config-env",
|
|
116
|
+
"--super-prefix",
|
|
117
|
+
]);
|
|
110
118
|
const HOOK_SKIP_ENV = new Set(["LEFTHOOK=0", "HUSKY=0"]);
|
|
111
119
|
|
|
112
120
|
export const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
|
@@ -14,7 +14,14 @@ import {
|
|
|
14
14
|
WRAPPERS,
|
|
15
15
|
} from "./git-intent.ts";
|
|
16
16
|
|
|
17
|
-
export type GitInvocation = {
|
|
17
|
+
export type GitInvocation = {
|
|
18
|
+
readonly sub: string;
|
|
19
|
+
readonly args: readonly string[];
|
|
20
|
+
/** Directory the command runs in, relative to the starting one (from `cd` and `git -C`), when it moved. */
|
|
21
|
+
readonly dir?: string;
|
|
22
|
+
/** Index of the (newline-split) line of the top-level command this invocation sits on. */
|
|
23
|
+
readonly line: number;
|
|
24
|
+
};
|
|
18
25
|
|
|
19
26
|
export type GitResolution = {
|
|
20
27
|
readonly invocations: readonly GitInvocation[];
|
|
@@ -22,7 +29,12 @@ export type GitResolution = {
|
|
|
22
29
|
readonly opaque: boolean;
|
|
23
30
|
};
|
|
24
31
|
|
|
25
|
-
type Collector = {
|
|
32
|
+
type Collector = {
|
|
33
|
+
invocations: GitInvocation[];
|
|
34
|
+
opaque: boolean;
|
|
35
|
+
dir: string | undefined;
|
|
36
|
+
line: number;
|
|
37
|
+
};
|
|
26
38
|
|
|
27
39
|
const firstCommand = (tokens: readonly string[]): number => {
|
|
28
40
|
const at = tokens.findIndex((t) => !(isAssignment(t) || WRAPPERS.has(t) || KEYWORDS.has(t)));
|
|
@@ -32,16 +44,25 @@ const firstCommand = (tokens: readonly string[]): number => {
|
|
|
32
44
|
const gitIndex = (tokens: readonly string[]): number =>
|
|
33
45
|
tokens.findIndex((t) => basename(t) === "git");
|
|
34
46
|
|
|
47
|
+
const joinDir = (base: string | undefined, next: string): string =>
|
|
48
|
+
next.startsWith("/") || base === undefined ? next : `${base}/${next}`;
|
|
49
|
+
|
|
35
50
|
/** `git [global options] <sub> <args…>` (tokens start at the word after `git`). */
|
|
36
51
|
function visitGit(rest: readonly string[], into: Collector): void {
|
|
37
52
|
let j = 0;
|
|
53
|
+
let dir = into.dir;
|
|
38
54
|
while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
|
|
55
|
+
if (rest[j] === "-C") dir = joinDir(dir, rest[j + 1] ?? ".");
|
|
39
56
|
j += GLOBAL_WITH_VALUE.has(rest[j] ?? "") ? 2 : 1;
|
|
40
57
|
}
|
|
41
58
|
const sub = rest[j];
|
|
42
59
|
if (sub === undefined) return;
|
|
43
60
|
if (sub.startsWith("$")) into.opaque = true;
|
|
44
|
-
else
|
|
61
|
+
else {
|
|
62
|
+
const args = rest.slice(j + 1);
|
|
63
|
+
const line = into.line;
|
|
64
|
+
into.invocations.push(dir === undefined ? { sub, args, line } : { sub, args, dir, line });
|
|
65
|
+
}
|
|
45
66
|
}
|
|
46
67
|
|
|
47
68
|
/** A shell or `eval`: its script is more command text to resolve. */
|
|
@@ -55,23 +76,34 @@ function visitScript(base: string, rest: readonly string[], into: Collector): vo
|
|
|
55
76
|
if (script !== undefined) merge(into, resolveGit(script));
|
|
56
77
|
}
|
|
57
78
|
|
|
79
|
+
/** `cd <dir>`: later git commands run there (a `$`-built target is unknowable, so it is ignored). */
|
|
80
|
+
function visitCd(rest: readonly string[], into: Collector): void {
|
|
81
|
+
const target = rest.find((t) => !t.startsWith("-"));
|
|
82
|
+
if (target === undefined || /[$`]/.test(target) || target === "-") return;
|
|
83
|
+
into.dir = joinDir(into.dir, target);
|
|
84
|
+
}
|
|
85
|
+
|
|
58
86
|
function visitSegment(tokens: readonly string[], into: Collector): void {
|
|
59
87
|
const head = tokens[firstCommand(tokens)];
|
|
60
88
|
if (head === undefined) return;
|
|
61
89
|
const rest = tokens.slice(firstCommand(tokens) + 1);
|
|
62
90
|
const base = basename(head);
|
|
63
91
|
if (head.startsWith("$")) into.opaque = true;
|
|
92
|
+
else if (base === "cd") visitCd(rest, into);
|
|
64
93
|
else if (SHELLS.has(base) || base === "eval") visitScript(base, rest, into);
|
|
65
94
|
else if (OPAQUE.has(base)) into.opaque = into.opaque || gitIndex(rest) >= 0;
|
|
66
95
|
else if (base === "git") visitGit(rest, into);
|
|
67
|
-
else
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
96
|
+
else visitWrapped(base, rest, into);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Wrapper with options (`sudo -E git …`, `timeout 5 git …`): resolve from the git token. */
|
|
100
|
+
function visitWrapped(base: string, rest: readonly string[], into: Collector): void {
|
|
101
|
+
const at = gitIndex(rest);
|
|
102
|
+
if (!DATA_ONLY.has(base) && at >= 0) visitSegment(rest.slice(at), into);
|
|
71
103
|
}
|
|
72
104
|
|
|
73
105
|
function merge(into: Collector, other: GitResolution): void {
|
|
74
|
-
into.invocations.push(...other.invocations);
|
|
106
|
+
into.invocations.push(...other.invocations.map((g) => ({ ...g, line: into.line })));
|
|
75
107
|
if (other.opaque) into.opaque = true;
|
|
76
108
|
}
|
|
77
109
|
|
|
@@ -80,9 +112,11 @@ function merge(into: Collector, other: GitResolution): void {
|
|
|
80
112
|
* `timeout`, `if … then`), `bash -c`/`eval` scripts and `$(…)` substitutions. Heredoc bodies are data.
|
|
81
113
|
*/
|
|
82
114
|
export function resolveGit(command: string): GitResolution {
|
|
83
|
-
const into: Collector = { invocations: [], opaque: false };
|
|
115
|
+
const into: Collector = { invocations: [], opaque: false, dir: undefined, line: 0 };
|
|
84
116
|
let heredocEnd: string | undefined;
|
|
85
|
-
|
|
117
|
+
const lines = splitLines(command);
|
|
118
|
+
for (const [index, line] of lines.entries()) {
|
|
119
|
+
into.line = index;
|
|
86
120
|
if (heredocEnd !== undefined) {
|
|
87
121
|
if (line.trim() === heredocEnd) heredocEnd = undefined;
|
|
88
122
|
continue;
|
package/src/core/push-command.ts
CHANGED
|
@@ -5,6 +5,8 @@ export type PushTarget = {
|
|
|
5
5
|
/** Destination branch names named by refspecs; empty means "the current branch's default". */
|
|
6
6
|
readonly branches: readonly string[];
|
|
7
7
|
readonly allBranches: boolean;
|
|
8
|
+
/** Where the push runs when `cd`/`git -C` moved it away from the session's directory. */
|
|
9
|
+
readonly dir?: string | undefined;
|
|
8
10
|
};
|
|
9
11
|
|
|
10
12
|
const VALUE_OPTIONS = new Set(["--repo", "-o", "--push-option", "--receive-pack", "--exec"]);
|
|
@@ -12,7 +14,7 @@ const isDynamic = (refspec: string): boolean => /[$`*?[]/.test(refspec);
|
|
|
12
14
|
|
|
13
15
|
const destination = (refspec: string): string => {
|
|
14
16
|
const dst = refspec.includes(":") ? refspec.slice(refspec.lastIndexOf(":") + 1) : refspec;
|
|
15
|
-
return dst.replace(/^\+/, "").replace(/^refs\/heads\//, "");
|
|
17
|
+
return dst.replace(/^\+/, "").replace(/^(?:refs\/)?heads\//, "");
|
|
16
18
|
};
|
|
17
19
|
|
|
18
20
|
type ParsedArgs = { positional: string[]; all: boolean; repo: string | undefined };
|
|
@@ -34,7 +36,7 @@ function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
|
34
36
|
return parsed;
|
|
35
37
|
}
|
|
36
38
|
|
|
37
|
-
const toTarget = (args: readonly string[]): PushTarget => {
|
|
39
|
+
const toTarget = (args: readonly string[], dir: string | undefined): PushTarget => {
|
|
38
40
|
const { positional, all, repo } = positionalArgs(args);
|
|
39
41
|
// With --repo, every positional argument is a refspec.
|
|
40
42
|
const [remote, ...refspecs] = repo === undefined ? positional : [repo, ...positional];
|
|
@@ -44,6 +46,7 @@ const toTarget = (args: readonly string[]): PushTarget => {
|
|
|
44
46
|
remote,
|
|
45
47
|
branches: destinations.filter(named),
|
|
46
48
|
allBranches: all || destinations.some(isDynamic),
|
|
49
|
+
...(dir === undefined ? {} : { dir }),
|
|
47
50
|
};
|
|
48
51
|
};
|
|
49
52
|
|
|
@@ -51,7 +54,7 @@ const toTarget = (args: readonly string[]): PushTarget => {
|
|
|
51
54
|
export function pushTargets(command: string): PushTarget[] {
|
|
52
55
|
const resolution = resolveGit(command);
|
|
53
56
|
const targets = resolution.invocations.flatMap((g) =>
|
|
54
|
-
g.sub === "push" ? [toTarget(g.args)] : [],
|
|
57
|
+
g.sub === "push" ? [toTarget(g.args, g.dir)] : [],
|
|
55
58
|
);
|
|
56
59
|
// git run through something opaque (xargs, `$CMD`): a push cannot be ruled out, so assume the worst.
|
|
57
60
|
const opaquePush = resolution.opaque && /\bpush\b/.test(command);
|
package/src/core/redact.ts
CHANGED
|
@@ -15,11 +15,15 @@ export function redactSecrets(text: string): string {
|
|
|
15
15
|
"$1$2[redacted]$2",
|
|
16
16
|
)
|
|
17
17
|
.replace(
|
|
18
|
-
/\b(\w*(?:token|key|secret|password|passwd|pwd)\w*\s*(?::=|[:=])[ \t]*)[^\s"'`,;]{4,}/gi,
|
|
18
|
+
/\b(\w*(?:token|key|secret|password|passwd|passphrase|pwd)\w*\s*(?::=|[:=])[ \t]*)[^\s"'`,;]{4,}/gi,
|
|
19
19
|
"$1[redacted]",
|
|
20
20
|
)
|
|
21
21
|
.replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted]")
|
|
22
22
|
.replace(/\bAKIA[0-9A-Z]{16}\b/g, "[redacted]")
|
|
23
|
+
.replace(/\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, "[redacted]")
|
|
24
|
+
.replace(/\bAIza[0-9A-Za-z_-]{35}/g, "[redacted]")
|
|
25
|
+
.replace(/(\s-u\s+[^\s:]+:)\S+/g, "$1[redacted]")
|
|
26
|
+
.replace(/\b(Cookie:\s*)[^\n]+/gi, "$1[redacted]")
|
|
23
27
|
.replace(/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "[redacted]")
|
|
24
28
|
.replace(/\bglpat-[A-Za-z0-9_-]{16,}/g, "[redacted]")
|
|
25
29
|
.replace(/\bnpm_[A-Za-z0-9]{30,}/g, "[redacted]")
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
import {
|
|
4
4
|
type ExtensionAPI,
|
|
5
5
|
type ExtensionContext,
|
|
6
6
|
isToolCallEventType,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
|
-
import { type CommitExtraction,
|
|
8
|
+
import { type CommitExtraction, extractCommits } from "../core/commit-command.ts";
|
|
9
9
|
import {
|
|
10
|
+
findForbiddenTrailerKeys,
|
|
10
11
|
findForbiddenTrailers,
|
|
11
12
|
hasRationaleBody,
|
|
12
13
|
parseConventionalCommit,
|
|
@@ -38,7 +39,9 @@ const MIXED = gateId("commit.mixed-change");
|
|
|
38
39
|
|
|
39
40
|
const readMessageFile = (cwd: string, path: string): string | undefined => {
|
|
40
41
|
try {
|
|
41
|
-
|
|
42
|
+
const file = resolve(cwd, path);
|
|
43
|
+
if (!statSync(file).isFile()) return undefined; // never read a FIFO or device (it can block)
|
|
44
|
+
return readFileSync(file, "utf8");
|
|
42
45
|
} catch {
|
|
43
46
|
return undefined;
|
|
44
47
|
}
|
|
@@ -108,34 +111,59 @@ const blockReason = (need: Need): string =>
|
|
|
108
111
|
`departing is deliberate call devsys_record_departure with gate "${need.gate}", what you are doing ` +
|
|
109
112
|
"instead, why, and the cost if wrong; then retry.";
|
|
110
113
|
|
|
114
|
+
const forbiddenReason = (found: readonly string[]): string =>
|
|
115
|
+
`commit.forbidden-trailer: this commit carries an AI attribution (${found.join("; ")}). ` +
|
|
116
|
+
"Commits in this repository have no Co-Authored-By or generated-by trailers and this is not " +
|
|
117
|
+
"something to depart from. Remove the trailer and commit again.";
|
|
118
|
+
|
|
119
|
+
/** AI trailers in any message, --trailer value, or the command text itself (heredocs, printf, echo). */
|
|
120
|
+
function forbiddenIn(
|
|
121
|
+
command: string,
|
|
122
|
+
extractions: readonly CommitExtraction[],
|
|
123
|
+
messages: readonly (string | undefined)[],
|
|
124
|
+
): string[] {
|
|
125
|
+
const trailers = extractions.flatMap((e) => (e.kind === "unknown" ? e.trailers : []));
|
|
126
|
+
const text = [...messages, ...trailers].join("\n");
|
|
127
|
+
return [...findForbiddenTrailers(text), ...findForbiddenTrailerKeys(command)];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function needsOf(
|
|
131
|
+
deps: CommitGuardDeps,
|
|
132
|
+
ctx: ExtensionContext,
|
|
133
|
+
message: string,
|
|
134
|
+
): Promise<Need[]> {
|
|
135
|
+
const needs = messageNeeds(message);
|
|
136
|
+
const bodyPresent = !needs.some((n) => n.why.startsWith("the message has no body"));
|
|
137
|
+
const viaJev = await jevNeeds(deps, ctx, message, bodyPresent);
|
|
138
|
+
return [...needs, ...viaJev.filter((n) => !needs.some((m) => m.gate === n.gate))];
|
|
139
|
+
}
|
|
140
|
+
|
|
111
141
|
/** Soft gates on `git commit`: rationale, Conventional shape, structural/behavioural separation; AI trailers are refused. */
|
|
112
142
|
export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
113
143
|
deps.pi.on("tool_call", async (event, ctx) => {
|
|
114
144
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
115
145
|
const command = event.input.command;
|
|
116
|
-
const
|
|
117
|
-
if (
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
// Safety net: whatever the message source, the command text itself must not carry an AI trailer
|
|
121
|
-
// (heredocs written to a file first, --trailer values on amends, messages built elsewhere).
|
|
122
|
-
const trailers = extracted.kind === "unknown" ? extracted.trailers.join("\n") : "";
|
|
123
|
-
const forbidden = findForbiddenTrailers(`${message ?? ""}\n${trailers}\n${command}`);
|
|
124
|
-
if (forbidden.length > 0) {
|
|
125
|
-
return {
|
|
126
|
-
block: true,
|
|
127
|
-
reason:
|
|
128
|
-
`commit.forbidden-trailer: this message carries an AI attribution (${forbidden.join("; ")}). ` +
|
|
129
|
-
"Commits in this repository have no Co-Authored-By or generated-by trailers and this is not " +
|
|
130
|
-
"something to depart from. Remove the trailer and commit again.",
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
if (message === undefined) return undefined;
|
|
146
|
+
const extractions = extractCommits(command);
|
|
147
|
+
if (extractions.length === 0) return undefined;
|
|
148
|
+
const messages = extractions.map((e) => messageOf(e, ctx.cwd));
|
|
134
149
|
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
150
|
+
const forbidden = forbiddenIn(command, extractions, messages);
|
|
151
|
+
if (forbidden.length > 0) return { block: true, reason: forbiddenReason(forbidden) };
|
|
152
|
+
|
|
153
|
+
const known = messages.flatMap((m) => (m === undefined ? [] : [m]));
|
|
154
|
+
const all: Need[] = [];
|
|
155
|
+
// A -F file that cannot be read now (written by this same command, or stdin) cannot be checked.
|
|
156
|
+
if (extractions.some((e, i) => e.kind === "file" && messages[i] === undefined)) {
|
|
157
|
+
all.push({
|
|
158
|
+
gate: RATIONALE,
|
|
159
|
+
why: "the message file cannot be read before the commit runs, so its rationale cannot be checked; use -m or a heredoc",
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
for (const message of known) {
|
|
163
|
+
for (const need of await needsOf(deps, ctx, message)) {
|
|
164
|
+
if (!all.some((n) => n.gate === need.gate)) all.push(need);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
139
167
|
const uses = all.map((need) => ({ need, use: departureUse(deps.state, need.gate) }));
|
|
140
168
|
const uncovered = uses.find(({ use }) => !use.hasOpen());
|
|
141
169
|
if (uncovered !== undefined) return { block: true, reason: blockReason(uncovered.need) };
|
package/src/gates/push-guard.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
1
2
|
import {
|
|
2
3
|
type ExtensionAPI,
|
|
3
4
|
type ExtensionContext,
|
|
@@ -31,6 +32,7 @@ const gate = (id: string): GateId => {
|
|
|
31
32
|
if (isParseError(parsed)) throw new Error(parsed.message);
|
|
32
33
|
return parsed;
|
|
33
34
|
};
|
|
35
|
+
const FIX_DIFF_LIMIT = 8000;
|
|
34
36
|
const RED_TRUNK = gate("push.red-trunk");
|
|
35
37
|
const DELIVERY_MODE = gate("push.delivery-mode");
|
|
36
38
|
|
|
@@ -52,7 +54,12 @@ async function pushesTrunk(
|
|
|
52
54
|
): Promise<boolean> {
|
|
53
55
|
for (const t of targets) {
|
|
54
56
|
if (t.allBranches || t.branches.includes(trunk)) return true;
|
|
55
|
-
|
|
57
|
+
const where = t.dir === undefined ? cwd : resolve(cwd, t.dir);
|
|
58
|
+
if (t.branches.length === 0) {
|
|
59
|
+
// No refspec: the current branch decides. When it cannot be read, assume it is the trunk.
|
|
60
|
+
const branch = await currentBranch(exec, where);
|
|
61
|
+
if (branch === undefined || branch === trunk) return true;
|
|
62
|
+
}
|
|
56
63
|
}
|
|
57
64
|
return false;
|
|
58
65
|
}
|
|
@@ -72,10 +79,15 @@ async function repairsRedTrunk(
|
|
|
72
79
|
if (message.code !== 0 || !parsed.ok || parsed.value.type !== "fix") return false;
|
|
73
80
|
const range = `${config.delivery.remote}/${config.delivery.trunk}..HEAD`;
|
|
74
81
|
const diff = await deps.exec("git", ["diff", range], { cwd: ctx.cwd, timeout: 10_000 });
|
|
75
|
-
|
|
82
|
+
// Jev only reads a bounded diff; a longer range could hide unrelated change past what it sees.
|
|
83
|
+
if (diff.code !== 0 || diff.stdout.trim() === "" || diff.stdout.length > FIX_DIFF_LIMIT) {
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
const failingLog = await getFailureLog(deps.exec, ctx.cwd, runId);
|
|
87
|
+
if (failingLog.trim() === "") return false; // nothing to compare the diff against: fail closed
|
|
76
88
|
const judged = await judgeFixRelated(deps.jev(ctx), {
|
|
77
89
|
diff: diff.stdout,
|
|
78
|
-
failingLog
|
|
90
|
+
failingLog,
|
|
79
91
|
message: message.stdout,
|
|
80
92
|
});
|
|
81
93
|
return judged.ok && judged.value >= FIX_RELATED_THRESHOLD;
|