@jwilger/pi-development-system 0.8.0 → 0.8.2
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 +4 -1
- package/src/core/commit-command.ts +101 -81
- package/src/core/commit-message.ts +26 -3
- package/src/core/gates.ts +1 -1
- package/src/core/git-intent.ts +40 -12
- package/src/core/git-invocations.ts +116 -0
- package/src/core/models.ts +5 -2
- package/src/core/push-command.ts +47 -19
- package/src/core/redact.ts +15 -2
- package/src/gates/commit-guard.ts +63 -33
- package/src/gates/push-guard.ts +15 -6
- package/src/gates/request-approval-tool.ts +6 -0
- package/src/state/ci-command.ts +11 -0
- package/src/state/ci-watch.ts +6 -5
- package/src/state/ci.ts +16 -9
- package/src/state/config.ts +3 -1
package/package.json
CHANGED
|
@@ -41,7 +41,10 @@ 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`
|
|
46
|
+
is missing or unauthenticated, CI reads as unknown and the push is allowed:
|
|
47
|
+
check CI by other means before relying on it.
|
|
45
48
|
|
|
46
49
|
## Pull-request mode
|
|
47
50
|
|
|
@@ -1,53 +1,25 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { parseShell } from "./shell-parse.ts";
|
|
1
|
+
import { resolveGit } from "./git-invocations.ts";
|
|
3
2
|
|
|
4
3
|
export type CommitExtraction =
|
|
5
4
|
| { readonly kind: "not-commit" }
|
|
6
5
|
| { readonly kind: "message"; readonly message: string }
|
|
7
6
|
| { readonly kind: "file"; readonly path: string }
|
|
8
|
-
| { readonly kind: "unknown" };
|
|
7
|
+
| { readonly kind: "unknown"; readonly trailers: readonly string[] };
|
|
9
8
|
|
|
10
9
|
const HEREDOC =
|
|
11
10
|
/<<-?[ \t]*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1[^\n]*\n([\s\S]*?)\n[ \t]*\2[ \t]*(?:\n|$)/g;
|
|
12
|
-
const GLOBAL_WITH_VALUE = new Set(["-c", "-C", "--git-dir", "--work-tree", "--namespace"]);
|
|
13
11
|
|
|
14
12
|
/** Heredoc bodies are data: take them out so their quotes cannot confuse line splitting. */
|
|
15
|
-
function stripHeredocs(command: string): { rest: string; bodies:
|
|
16
|
-
const bodies:
|
|
17
|
-
const rest = command.replace(HEREDOC, (
|
|
18
|
-
bodies.push(body);
|
|
13
|
+
function stripHeredocs(command: string): { rest: string; bodies: Heredoc[] } {
|
|
14
|
+
const bodies: Heredoc[] = [];
|
|
15
|
+
const rest = command.replace(HEREDOC, (...m: unknown[]) => {
|
|
16
|
+
bodies.push({ body: String(m[3]), at: Number(m[m.length - 2]) });
|
|
19
17
|
return "\n";
|
|
20
18
|
});
|
|
21
19
|
return { rest, bodies };
|
|
22
20
|
}
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
const out: string[][] = [[]];
|
|
26
|
-
for (const t of parseShell(line, (name) => `$${name}`)) {
|
|
27
|
-
if (typeof t === "string") out.at(-1)?.push(t);
|
|
28
|
-
else if ("op" in t && /^[<>]/.test(t.op)) continue;
|
|
29
|
-
else if (!("comment" in t)) out.push([]);
|
|
30
|
-
}
|
|
31
|
-
return out.filter((s) => s.length > 0);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const WRAPPERS = new Set(["command", "sudo", "env", "time", "nohup", "exec", "builtin", "nice"]);
|
|
35
|
-
const isAssignment = (w: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(w);
|
|
36
|
-
const isGit = (w: string): boolean => w === "git" || w.endsWith("/git");
|
|
37
|
-
|
|
38
|
-
/** Index of the word after `git [global options]`, when this segment's command is git. */
|
|
39
|
-
function subcommandIndex(segment: readonly string[]): number | undefined {
|
|
40
|
-
const start = segment.findIndex((w) => !isAssignment(w) && !WRAPPERS.has(w));
|
|
41
|
-
if (start === -1 || !isGit(segment[start] ?? "")) return undefined;
|
|
42
|
-
let skipNext = false;
|
|
43
|
-
for (let i = start + 1; i < segment.length; i++) {
|
|
44
|
-
const w = segment[i] ?? "";
|
|
45
|
-
if (skipNext) skipNext = false;
|
|
46
|
-
else if (GLOBAL_WITH_VALUE.has(w)) skipNext = true;
|
|
47
|
-
else if (!w.startsWith("-")) return i;
|
|
48
|
-
}
|
|
49
|
-
return undefined;
|
|
50
|
-
}
|
|
22
|
+
type Heredoc = { readonly body: string; readonly at: number };
|
|
51
23
|
|
|
52
24
|
type Options = {
|
|
53
25
|
messages: string[];
|
|
@@ -56,59 +28,107 @@ type Options = {
|
|
|
56
28
|
opaque: boolean;
|
|
57
29
|
};
|
|
58
30
|
|
|
31
|
+
type Parsed = {
|
|
32
|
+
readonly kind: "message" | "file" | "trailer";
|
|
33
|
+
readonly value: string | undefined;
|
|
34
|
+
/** Index of the next argument to read. */
|
|
35
|
+
readonly next: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const LONG: ReadonlyArray<readonly [string, Parsed["kind"]]> = [
|
|
39
|
+
["--message", "message"],
|
|
40
|
+
["--file", "file"],
|
|
41
|
+
["--trailer", "trailer"],
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** The option a `--name`/`--na=value` spelling means; git accepts any unambiguous prefix (3+ chars). */
|
|
45
|
+
function longOption(arg: string): { kind: Parsed["kind"]; inline: string | undefined } | undefined {
|
|
46
|
+
if (!arg.startsWith("--")) return undefined;
|
|
47
|
+
const eq = arg.indexOf("=");
|
|
48
|
+
const name = eq < 0 ? arg : arg.slice(0, eq);
|
|
49
|
+
if (name.length < 5) return undefined;
|
|
50
|
+
const hit = LONG.find(([long]) => long.startsWith(name));
|
|
51
|
+
return hit === undefined
|
|
52
|
+
? undefined
|
|
53
|
+
: { kind: hit[1], inline: eq < 0 ? undefined : arg.slice(eq + 1) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Reads the message/file/trailer option starting at args[i], in any of its spellings. */
|
|
57
|
+
function parseArg(args: readonly string[], i: number): Parsed | undefined {
|
|
58
|
+
const a = args[i] ?? "";
|
|
59
|
+
const long = longOption(a);
|
|
60
|
+
if (long !== undefined) {
|
|
61
|
+
return long.inline === undefined
|
|
62
|
+
? { kind: long.kind, value: args[i + 1], next: i + 2 }
|
|
63
|
+
: { kind: long.kind, value: long.inline, next: i + 1 };
|
|
64
|
+
}
|
|
65
|
+
if (a === "-F") return { kind: "file", value: args[i + 1], next: i + 2 };
|
|
66
|
+
if (/^-F./.test(a)) return { kind: "file", value: a.slice(2), next: i + 1 };
|
|
67
|
+
if (/^-[A-Za-z]*m$/.test(a)) return { kind: "message", value: args[i + 1], next: i + 2 };
|
|
68
|
+
if (/^-[A-Za-z]*m./.test(a)) {
|
|
69
|
+
return { kind: "message", value: a.slice(a.indexOf("m") + 1), next: i + 1 };
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
59
74
|
function readOptions(args: readonly string[]): Options {
|
|
60
75
|
const options: Options = { messages: [], trailers: [], file: undefined, opaque: false };
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
else if (
|
|
69
|
-
else
|
|
70
|
-
else if (a.startsWith("--file=")) options.file = a.slice("--file=".length);
|
|
71
|
-
else if (a === "--trailer") options.trailers.push(args[++i] ?? "");
|
|
72
|
-
else if (a.startsWith("--trailer=")) options.trailers.push(a.slice("--trailer=".length));
|
|
73
|
-
else if (/^-[A-Za-z]*m$/.test(a)) take(args[++i]);
|
|
74
|
-
else if (/^-[A-Za-z]*m./.test(a)) take(a.slice(a.indexOf("m") + 1));
|
|
76
|
+
let i = 0;
|
|
77
|
+
while (i < args.length) {
|
|
78
|
+
const parsed = parseArg(args, i);
|
|
79
|
+
i = parsed?.next ?? i + 1;
|
|
80
|
+
if (parsed === undefined) continue;
|
|
81
|
+
if (parsed.kind === "file") options.file = parsed.value;
|
|
82
|
+
else if (parsed.kind === "trailer") options.trailers.push(parsed.value ?? "");
|
|
83
|
+
else if (parsed.value === undefined || /\$\(|`/.test(parsed.value)) options.opaque = true;
|
|
84
|
+
else options.messages.push(parsed.value);
|
|
75
85
|
}
|
|
76
86
|
return options;
|
|
77
87
|
}
|
|
78
88
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
89
|
+
const unknown = (trailers: readonly string[] = []): CommitExtraction => ({
|
|
90
|
+
kind: "unknown",
|
|
91
|
+
trailers,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const STDIN_PATHS = new Set(["-", "/dev/stdin", "/proc/self/fd/0"]);
|
|
95
|
+
|
|
96
|
+
/** The heredoc that is this commit's input: the first one written after the word `commit`. */
|
|
97
|
+
const heredocFor = (command: string, bodies: readonly Heredoc[]): string | undefined => {
|
|
98
|
+
const from = command.search(/\bcommit\b/);
|
|
99
|
+
return bodies.find((h) => h.at >= from)?.body.trim();
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
function extractOne(args: readonly string[], heredoc: string | undefined): CommitExtraction {
|
|
103
|
+
const options = readOptions(args);
|
|
104
|
+
if (options.file !== undefined && STDIN_PATHS.has(options.file)) {
|
|
105
|
+
return heredoc === undefined
|
|
106
|
+
? unknown(options.trailers)
|
|
107
|
+
: { kind: "message", message: heredoc };
|
|
108
|
+
}
|
|
109
|
+
if (options.file !== undefined) return { kind: "file", path: options.file };
|
|
110
|
+
const parts = [...options.messages];
|
|
111
|
+
if (options.opaque) {
|
|
112
|
+
if (heredoc === undefined) return unknown(options.trailers);
|
|
113
|
+
parts.push(heredoc);
|
|
98
114
|
}
|
|
99
|
-
|
|
115
|
+
if (parts.length === 0) return unknown(options.trailers);
|
|
116
|
+
const trailers = options.trailers.length === 0 ? "" : `\n\n${options.trailers.join("\n")}`;
|
|
117
|
+
return { kind: "message", message: `${parts.join("\n\n")}${trailers}` };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** One extraction per `git commit` in the command (empty when it commits nothing). */
|
|
121
|
+
export function extractCommits(command: string): CommitExtraction[] {
|
|
122
|
+
const { rest, bodies } = stripHeredocs(command);
|
|
123
|
+
const resolution = resolveGit(rest);
|
|
124
|
+
const heredoc = heredocFor(command, bodies);
|
|
125
|
+
const commits = resolution.invocations.filter((g) => g.sub === "commit");
|
|
126
|
+
const found = commits.map((g) => extractOne(g.args, heredoc));
|
|
127
|
+
const hidden = resolution.opaque && /\bcommit\b/.test(command);
|
|
128
|
+
return hidden ? [...found, unknown()] : found;
|
|
100
129
|
}
|
|
101
130
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
export function gitInvocations(command: string): GitInvocation[] {
|
|
106
|
-
const { rest } = stripHeredocs(command);
|
|
107
|
-
return splitLines(rest).flatMap((line) =>
|
|
108
|
-
segmentsOf(line).flatMap((segment): GitInvocation[] => {
|
|
109
|
-
const at = subcommandIndex(segment);
|
|
110
|
-
const sub = at === undefined ? undefined : segment[at];
|
|
111
|
-
return at === undefined || sub === undefined ? [] : [{ sub, args: segment.slice(at + 1) }];
|
|
112
|
-
}),
|
|
113
|
-
);
|
|
131
|
+
/** The first commit a shell command makes: its message, or why it cannot be known. */
|
|
132
|
+
export function extractCommit(command: string): CommitExtraction {
|
|
133
|
+
return extractCommits(command)[0] ?? { kind: "not-commit" };
|
|
114
134
|
}
|
|
@@ -86,20 +86,43 @@ export function findForbiddenTrailers(
|
|
|
86
86
|
.split("\n")
|
|
87
87
|
.filter((line) => {
|
|
88
88
|
const trimmed = line.trim();
|
|
89
|
-
const key = /^([A-Za-z][A-Za-z-]*)
|
|
89
|
+
const key = /^([A-Za-z][A-Za-z-]*)\s*[:=]/.exec(trimmed)?.[1]?.toLowerCase();
|
|
90
90
|
if (key !== undefined && keys.includes(key)) return true;
|
|
91
91
|
if (key === "signed-off-by") return AI_NAMES.test(trimmed);
|
|
92
92
|
return GENERATED_BANNER.test(trimmed) && AI_NAMES.test(trimmed);
|
|
93
93
|
});
|
|
94
94
|
}
|
|
95
95
|
|
|
96
|
+
const AI_TRAILER_ANYWHERE = /(?:co-authored-by|generated-by)\s*[:=]/gi;
|
|
97
|
+
|
|
98
|
+
/** AI-attribution trailer keys anywhere in free text (command lines, quoted strings, printf bodies). */
|
|
99
|
+
export function findForbiddenTrailerKeys(text: string): string[] {
|
|
100
|
+
return [...text.matchAll(AI_TRAILER_ANYWHERE)].map((m) => m[0].trim());
|
|
101
|
+
}
|
|
102
|
+
|
|
96
103
|
const isBullets = (paragraph: string): boolean =>
|
|
97
104
|
paragraph.split("\n").every((line) => /^\s*(?:[-*+]|\d+[.)])\s/.test(line));
|
|
98
105
|
|
|
99
106
|
const wordCount = (text: string): number => text.split(/\s+/).filter((w) => w !== "").length;
|
|
100
107
|
|
|
108
|
+
const PROSE_TRAILER_WORDS = 5;
|
|
109
|
+
|
|
110
|
+
/** A trailer-shaped line whose value is a sentence ("Reason: the cache was stale…") is prose. */
|
|
111
|
+
const isProseTrailer = (line: string): boolean => {
|
|
112
|
+
const m = TRAILER.exec(line.trim());
|
|
113
|
+
return m !== null && m[1] !== "BREAKING CHANGE" && wordCount(m[2] ?? "") >= PROSE_TRAILER_WORDS;
|
|
114
|
+
};
|
|
115
|
+
|
|
101
116
|
/** A body needs at least one prose paragraph (not a file list) that says more than a few words. */
|
|
102
117
|
export function hasRationaleBody(message: string): boolean {
|
|
103
|
-
const { body } = split(message);
|
|
104
|
-
|
|
118
|
+
const { body, trailers } = split(message);
|
|
119
|
+
const trailerBlockLines =
|
|
120
|
+
withoutComments(message)
|
|
121
|
+
.split(/\n\s*\n/)
|
|
122
|
+
.at(-1)
|
|
123
|
+
?.split("\n") ?? [];
|
|
124
|
+
const proseTrailer = trailers.length > 0 && trailerBlockLines.some(isProseTrailer);
|
|
125
|
+
return (
|
|
126
|
+
proseTrailer || body.some((paragraph) => !isBullets(paragraph) && wordCount(paragraph) >= 5)
|
|
127
|
+
);
|
|
105
128
|
}
|
package/src/core/gates.ts
CHANGED
|
@@ -18,7 +18,7 @@ export const GATES: Readonly<Record<string, GateInfo>> = {
|
|
|
18
18
|
tier: "soft",
|
|
19
19
|
default: "structural and behavioural changes go in separate commits",
|
|
20
20
|
},
|
|
21
|
-
"commit.forbidden-trailer": { tier: "
|
|
21
|
+
"commit.forbidden-trailer": { tier: "hard", default: "no Co-Authored-By or AI trailers" },
|
|
22
22
|
"push.red-trunk": { tier: "hard", default: "do not push unrelated work onto a red trunk" },
|
|
23
23
|
"push.delivery-mode": { tier: "hard", default: "follow the repository's delivery mode" },
|
|
24
24
|
"tdd.red-first": { tier: "soft", default: "write a failing test before changing behaviour" },
|
package/src/core/git-intent.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function splitLines(command: string): string[] {
|
|
|
59
59
|
const isRedirect = (op: string): boolean => /^[<>]/.test(op) && !op.endsWith("(");
|
|
60
60
|
|
|
61
61
|
/** Tokens of one line grouped into simple commands (operators separate segments). */
|
|
62
|
-
function segments(line: string): string[][] {
|
|
62
|
+
export function segments(line: string): string[][] {
|
|
63
63
|
const result: string[][] = [[]];
|
|
64
64
|
for (const token of parseShell(line, (name) => `$${name}`)) {
|
|
65
65
|
if (typeof token === "string") result[result.length - 1]?.push(token);
|
|
@@ -70,9 +70,29 @@ function segments(line: string): string[][] {
|
|
|
70
70
|
return result.filter((s) => s.length > 0);
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
const WRAPPERS = new Set([
|
|
74
|
-
|
|
75
|
-
|
|
73
|
+
export const WRAPPERS = new Set([
|
|
74
|
+
"command",
|
|
75
|
+
"sudo",
|
|
76
|
+
"env",
|
|
77
|
+
"time",
|
|
78
|
+
"nohup",
|
|
79
|
+
"exec",
|
|
80
|
+
"builtin",
|
|
81
|
+
"nice",
|
|
82
|
+
]);
|
|
83
|
+
export const KEYWORDS = new Set([
|
|
84
|
+
"then",
|
|
85
|
+
"do",
|
|
86
|
+
"else",
|
|
87
|
+
"elif",
|
|
88
|
+
"if",
|
|
89
|
+
"while",
|
|
90
|
+
"until",
|
|
91
|
+
"{",
|
|
92
|
+
"!",
|
|
93
|
+
"time",
|
|
94
|
+
]);
|
|
95
|
+
export const DATA_ONLY = new Set([
|
|
76
96
|
"echo",
|
|
77
97
|
"printf",
|
|
78
98
|
"cat",
|
|
@@ -84,13 +104,21 @@ const DATA_ONLY = new Set([
|
|
|
84
104
|
"head",
|
|
85
105
|
"tail",
|
|
86
106
|
]);
|
|
87
|
-
const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
88
|
-
const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
|
|
89
|
-
const GLOBAL_WITH_VALUE = new Set([
|
|
107
|
+
export const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
108
|
+
export const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
|
|
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
|
+
]);
|
|
90
118
|
const HOOK_SKIP_ENV = new Set(["LEFTHOOK=0", "HUSKY=0"]);
|
|
91
119
|
|
|
92
|
-
const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
|
93
|
-
const isAssignment = (t: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
|
|
120
|
+
export const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
|
121
|
+
export const isAssignment = (t: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
|
|
94
122
|
|
|
95
123
|
/** True when `arg` is `name` or an unambiguous-looking git abbreviation of it (`--amen`, `--del=x`). */
|
|
96
124
|
const isLong = (arg: string, ...names: string[]): boolean => {
|
|
@@ -200,7 +228,7 @@ function classifySegment(tokens: string[]): GitIntent {
|
|
|
200
228
|
* The parts of a line bash may still execute or treat as syntax: single-quoted text and
|
|
201
229
|
* backslash-escaped characters are dropped; double-quoted text is kept unless `dropDouble`.
|
|
202
230
|
*/
|
|
203
|
-
function live(line: string, dropDouble: boolean): string {
|
|
231
|
+
export function live(line: string, dropDouble: boolean): string {
|
|
204
232
|
let out = "";
|
|
205
233
|
let quote: "'" | '"' | undefined;
|
|
206
234
|
for (let i = 0; i < line.length; i++) {
|
|
@@ -222,7 +250,7 @@ function live(line: string, dropDouble: boolean): string {
|
|
|
222
250
|
}
|
|
223
251
|
|
|
224
252
|
/** Bodies of `$(...)` and backtick substitutions anywhere in the text (quoted or not). */
|
|
225
|
-
function substitutions(text: string): string[] {
|
|
253
|
+
export function substitutions(text: string): string[] {
|
|
226
254
|
const bodies: string[] = [];
|
|
227
255
|
for (let at = text.indexOf("$("); at >= 0; at = text.indexOf("$(", at + 2)) {
|
|
228
256
|
let depth = 1;
|
|
@@ -241,7 +269,7 @@ function substitutions(text: string): string[] {
|
|
|
241
269
|
const HEREDOC = /^<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/;
|
|
242
270
|
|
|
243
271
|
/** The delimiter of a heredoc started by an unquoted `<<` on this line, if any. */
|
|
244
|
-
function heredocDelimiter(line: string): string | undefined {
|
|
272
|
+
export function heredocDelimiter(line: string): string | undefined {
|
|
245
273
|
let quote: "'" | '"' | undefined;
|
|
246
274
|
for (let i = 0; i < line.length; i++) {
|
|
247
275
|
const ch = line.charAt(i);
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
basename,
|
|
3
|
+
DATA_ONLY,
|
|
4
|
+
GLOBAL_WITH_VALUE,
|
|
5
|
+
heredocDelimiter,
|
|
6
|
+
isAssignment,
|
|
7
|
+
KEYWORDS,
|
|
8
|
+
live,
|
|
9
|
+
OPAQUE,
|
|
10
|
+
SHELLS,
|
|
11
|
+
segments,
|
|
12
|
+
splitLines,
|
|
13
|
+
substitutions,
|
|
14
|
+
WRAPPERS,
|
|
15
|
+
} from "./git-intent.ts";
|
|
16
|
+
|
|
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
|
+
};
|
|
23
|
+
|
|
24
|
+
export type GitResolution = {
|
|
25
|
+
readonly invocations: readonly GitInvocation[];
|
|
26
|
+
/** True when git may run through something the parser cannot see into (xargs, `$CMD`, …). */
|
|
27
|
+
readonly opaque: boolean;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type Collector = { invocations: GitInvocation[]; opaque: boolean; dir: string | undefined };
|
|
31
|
+
|
|
32
|
+
const firstCommand = (tokens: readonly string[]): number => {
|
|
33
|
+
const at = tokens.findIndex((t) => !(isAssignment(t) || WRAPPERS.has(t) || KEYWORDS.has(t)));
|
|
34
|
+
return at < 0 ? tokens.length : at;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const gitIndex = (tokens: readonly string[]): number =>
|
|
38
|
+
tokens.findIndex((t) => basename(t) === "git");
|
|
39
|
+
|
|
40
|
+
const joinDir = (base: string | undefined, next: string): string =>
|
|
41
|
+
next.startsWith("/") || base === undefined ? next : `${base}/${next}`;
|
|
42
|
+
|
|
43
|
+
/** `git [global options] <sub> <args…>` (tokens start at the word after `git`). */
|
|
44
|
+
function visitGit(rest: readonly string[], into: Collector): void {
|
|
45
|
+
let j = 0;
|
|
46
|
+
let dir = into.dir;
|
|
47
|
+
while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
|
|
48
|
+
if (rest[j] === "-C") dir = joinDir(dir, rest[j + 1] ?? ".");
|
|
49
|
+
j += GLOBAL_WITH_VALUE.has(rest[j] ?? "") ? 2 : 1;
|
|
50
|
+
}
|
|
51
|
+
const sub = rest[j];
|
|
52
|
+
if (sub === undefined) return;
|
|
53
|
+
if (sub.startsWith("$")) into.opaque = true;
|
|
54
|
+
else {
|
|
55
|
+
const args = rest.slice(j + 1);
|
|
56
|
+
into.invocations.push(dir === undefined ? { sub, args } : { sub, args, dir });
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** A shell or `eval`: its script is more command text to resolve. */
|
|
61
|
+
function visitScript(base: string, rest: readonly string[], into: Collector): void {
|
|
62
|
+
if (base === "eval") {
|
|
63
|
+
merge(into, resolveGit(rest.join(" ")));
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
const c = rest.findIndex((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
|
|
67
|
+
const script = c >= 0 ? rest[c + 1] : undefined;
|
|
68
|
+
if (script !== undefined) merge(into, resolveGit(script));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `cd <dir>`: later git commands run there (a `$`-built target is unknowable, so it is ignored). */
|
|
72
|
+
function visitCd(rest: readonly string[], into: Collector): void {
|
|
73
|
+
const target = rest.find((t) => !t.startsWith("-"));
|
|
74
|
+
if (target === undefined || /[$`]/.test(target) || target === "-") return;
|
|
75
|
+
into.dir = joinDir(into.dir, target);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function visitSegment(tokens: readonly string[], into: Collector): void {
|
|
79
|
+
const head = tokens[firstCommand(tokens)];
|
|
80
|
+
if (head === undefined) return;
|
|
81
|
+
const rest = tokens.slice(firstCommand(tokens) + 1);
|
|
82
|
+
const base = basename(head);
|
|
83
|
+
if (head.startsWith("$")) into.opaque = true;
|
|
84
|
+
else if (base === "cd") visitCd(rest, into);
|
|
85
|
+
else if (SHELLS.has(base) || base === "eval") visitScript(base, rest, into);
|
|
86
|
+
else if (OPAQUE.has(base)) into.opaque = into.opaque || gitIndex(rest) >= 0;
|
|
87
|
+
else if (base === "git") visitGit(rest, into);
|
|
88
|
+
else if (!DATA_ONLY.has(base) && gitIndex(rest) >= 0) {
|
|
89
|
+
// Wrapper with options (`sudo -E git …`, `timeout 5 git …`): resolve from the git token.
|
|
90
|
+
visitSegment(rest.slice(gitIndex(rest)), into);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function merge(into: Collector, other: GitResolution): void {
|
|
95
|
+
into.invocations.push(...other.invocations);
|
|
96
|
+
if (other.opaque) into.opaque = true;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Every `git <sub> <args…>` the shell command may run: simple commands, wrapped ones (`env`,
|
|
101
|
+
* `timeout`, `if … then`), `bash -c`/`eval` scripts and `$(…)` substitutions. Heredoc bodies are data.
|
|
102
|
+
*/
|
|
103
|
+
export function resolveGit(command: string): GitResolution {
|
|
104
|
+
const into: Collector = { invocations: [], opaque: false, dir: undefined };
|
|
105
|
+
let heredocEnd: string | undefined;
|
|
106
|
+
for (const line of splitLines(command)) {
|
|
107
|
+
if (heredocEnd !== undefined) {
|
|
108
|
+
if (line.trim() === heredocEnd) heredocEnd = undefined;
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
for (const segment of segments(line)) visitSegment(segment, into);
|
|
112
|
+
for (const body of substitutions(live(line, false))) merge(into, resolveGit(body));
|
|
113
|
+
heredocEnd = heredocDelimiter(line) ?? heredocEnd;
|
|
114
|
+
}
|
|
115
|
+
return into;
|
|
116
|
+
}
|
package/src/core/models.ts
CHANGED
|
@@ -40,7 +40,10 @@ const MIN_DATE_DIGITS = 6;
|
|
|
40
40
|
function versionKey(id: string): VersionKey {
|
|
41
41
|
const numbers: number[] = [];
|
|
42
42
|
let date: number | undefined;
|
|
43
|
-
|
|
43
|
+
// Hyphenated ISO snapshot dates (`-2024-08-06`) collapse to one date, like compact YYYYMMDD.
|
|
44
|
+
const iso = /-(\d{4})-(\d{2})-(\d{2})(?!\d)/.exec(id);
|
|
45
|
+
if (iso !== null) date = Number(`${iso[1]}${iso[2]}${iso[3]}`);
|
|
46
|
+
for (const run of id.replace(/-\d{4}-\d{2}-\d{2}(?!\d)/, "").match(/\d+/g) ?? []) {
|
|
44
47
|
if (run.length >= MIN_DATE_DIGITS) date = Number(run);
|
|
45
48
|
else numbers.push(Number(run));
|
|
46
49
|
}
|
|
@@ -208,7 +211,7 @@ export function renderMatrixToml(matrix: ModelMatrix): string {
|
|
|
208
211
|
export function upsertModelsTable(text: string, matrix: ModelMatrix): string {
|
|
209
212
|
const table = renderMatrixToml(matrix);
|
|
210
213
|
const lines = text.split("\n");
|
|
211
|
-
const start = lines.findIndex((l) => l.trim()
|
|
214
|
+
const start = lines.findIndex((l) => /^\[models\]\s*(#.*)?$/.test(l.trim()));
|
|
212
215
|
if (start === -1) {
|
|
213
216
|
const body = text.trimEnd();
|
|
214
217
|
return body === "" ? table : `${body}\n\n${table}`;
|
package/src/core/push-command.ts
CHANGED
|
@@ -1,36 +1,64 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { resolveGit } from "./git-invocations.ts";
|
|
2
2
|
|
|
3
3
|
export type PushTarget = {
|
|
4
4
|
readonly remote: string | undefined;
|
|
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"]);
|
|
13
|
+
const isDynamic = (refspec: string): boolean => /[$`*?[]/.test(refspec);
|
|
11
14
|
|
|
12
15
|
const destination = (refspec: string): string => {
|
|
13
16
|
const dst = refspec.includes(":") ? refspec.slice(refspec.lastIndexOf(":") + 1) : refspec;
|
|
14
|
-
return dst.replace(/^\+/, "").replace(/^refs\/heads\//, "");
|
|
17
|
+
return dst.replace(/^\+/, "").replace(/^(?:refs\/)?heads\//, "");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type ParsedArgs = { positional: string[]; all: boolean; repo: string | undefined };
|
|
21
|
+
|
|
22
|
+
const VALUE_PREFIX = "--repo=";
|
|
23
|
+
|
|
24
|
+
function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
25
|
+
const parsed: ParsedArgs = { positional: [], all: false, repo: undefined };
|
|
26
|
+
let valueFor: "repo" | "skip" | undefined;
|
|
27
|
+
for (const a of args) {
|
|
28
|
+
if (valueFor === "repo") parsed.repo = a;
|
|
29
|
+
if (valueFor !== undefined) valueFor = undefined;
|
|
30
|
+
else if (a === "--repo") valueFor = "repo";
|
|
31
|
+
else if (a.startsWith(VALUE_PREFIX)) parsed.repo = a.slice(VALUE_PREFIX.length);
|
|
32
|
+
else if (VALUE_OPTIONS.has(a)) valueFor = "skip";
|
|
33
|
+
else if (a === "--all" || a === "--mirror") parsed.all = true;
|
|
34
|
+
else if (!a.startsWith("-")) parsed.positional.push(a);
|
|
35
|
+
}
|
|
36
|
+
return parsed;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const toTarget = (args: readonly string[], dir: string | undefined): PushTarget => {
|
|
40
|
+
const { positional, all, repo } = positionalArgs(args);
|
|
41
|
+
// With --repo, every positional argument is a refspec.
|
|
42
|
+
const [remote, ...refspecs] = repo === undefined ? positional : [repo, ...positional];
|
|
43
|
+
const destinations = refspecs.map(destination);
|
|
44
|
+
const named = (b: string): boolean => b !== "HEAD" && b !== "" && !isDynamic(b);
|
|
45
|
+
return {
|
|
46
|
+
remote,
|
|
47
|
+
branches: destinations.filter(named),
|
|
48
|
+
allBranches: all || destinations.some(isDynamic),
|
|
49
|
+
...(dir === undefined ? {} : { dir }),
|
|
50
|
+
};
|
|
15
51
|
};
|
|
16
52
|
|
|
17
53
|
/** The remote and branches of every `git push` in a command (empty list when there is no push). */
|
|
18
54
|
export function pushTargets(command: string): PushTarget[] {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
29
|
-
const [remote, ...refspecs] = positional;
|
|
30
|
-
const branches = refspecs.flatMap((r) => {
|
|
31
|
-
const b = destination(r);
|
|
32
|
-
return b === "HEAD" || b === "" ? [] : [b];
|
|
33
|
-
});
|
|
34
|
-
return [{ remote, branches, allBranches }];
|
|
35
|
-
});
|
|
55
|
+
const resolution = resolveGit(command);
|
|
56
|
+
const targets = resolution.invocations.flatMap((g) =>
|
|
57
|
+
g.sub === "push" ? [toTarget(g.args, g.dir)] : [],
|
|
58
|
+
);
|
|
59
|
+
// git run through something opaque (xargs, `$CMD`): a push cannot be ruled out, so assume the worst.
|
|
60
|
+
const opaquePush = resolution.opaque && /\bpush\b/.test(command);
|
|
61
|
+
return opaquePush
|
|
62
|
+
? [...targets, { remote: undefined, branches: [], allBranches: true }]
|
|
63
|
+
: targets;
|
|
36
64
|
}
|
package/src/core/redact.ts
CHANGED
|
@@ -11,8 +11,21 @@ export function redactSecrets(text: string): string {
|
|
|
11
11
|
"[redacted private key]",
|
|
12
12
|
)
|
|
13
13
|
.replace(
|
|
14
|
-
/\b(\w*(?:token|key|secret|password|passwd)\w*["']?\s*[:=]\s*)(["'])[^"'\n]+\2/gi,
|
|
14
|
+
/\b(\w*(?:token|key|secret|password|passwd)\w*["']?\s*(?::=|[:=])\s*)(["'])[^"'\n]+\2/gi,
|
|
15
15
|
"$1$2[redacted]$2",
|
|
16
16
|
)
|
|
17
|
-
.replace(
|
|
17
|
+
.replace(
|
|
18
|
+
/\b(\w*(?:token|key|secret|password|passwd|passphrase|pwd)\w*\s*(?::=|[:=])[ \t]*)[^\s"'`,;]{4,}/gi,
|
|
19
|
+
"$1[redacted]",
|
|
20
|
+
)
|
|
21
|
+
.replace(/\bsk-[A-Za-z0-9_-]{16,}/g, "[redacted]")
|
|
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]")
|
|
27
|
+
.replace(/\bxox[abprs]-[A-Za-z0-9-]{10,}/g, "[redacted]")
|
|
28
|
+
.replace(/\bglpat-[A-Za-z0-9_-]{16,}/g, "[redacted]")
|
|
29
|
+
.replace(/\bnpm_[A-Za-z0-9]{30,}/g, "[redacted]")
|
|
30
|
+
.replace(/\beyJ[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}\.[A-Za-z0-9_-]{5,}/g, "[redacted]");
|
|
18
31
|
}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
1
|
+
import { readFileSync, statSync } from "node:fs";
|
|
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 {
|
|
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,12 +39,19 @@ 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
|
}
|
|
45
48
|
};
|
|
46
49
|
|
|
50
|
+
const messageOf = (extracted: CommitExtraction, cwd: string): string | undefined => {
|
|
51
|
+
if (extracted.kind === "message") return extracted.message;
|
|
52
|
+
return extracted.kind === "file" ? readMessageFile(cwd, extracted.path) : undefined;
|
|
53
|
+
};
|
|
54
|
+
|
|
47
55
|
/** Deterministic checks: Conventional subject and a prose rationale body. */
|
|
48
56
|
function messageNeeds(message: string): Need[] {
|
|
49
57
|
const parsed = parseConventionalCommit(message);
|
|
@@ -58,12 +66,16 @@ async function pendingDiff(
|
|
|
58
66
|
exec: Exec,
|
|
59
67
|
cwd: string,
|
|
60
68
|
): Promise<{ stat: string; diff: string } | undefined> {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
try {
|
|
70
|
+
const [stat, diff] = await Promise.all([
|
|
71
|
+
exec("git", ["diff", "HEAD", "--stat"], { cwd, timeout: 10_000 }),
|
|
72
|
+
exec("git", ["diff", "HEAD"], { cwd, timeout: 10_000 }),
|
|
73
|
+
]);
|
|
74
|
+
if (stat.code !== 0 || diff.code !== 0 || diff.stdout.trim() === "") return undefined;
|
|
75
|
+
return { stat: stat.stdout, diff: diff.stdout };
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
67
79
|
}
|
|
68
80
|
|
|
69
81
|
async function jevNeeds(
|
|
@@ -99,34 +111,52 @@ const blockReason = (need: Need): string =>
|
|
|
99
111
|
`departing is deliberate call devsys_record_departure with gate "${need.gate}", what you are doing ` +
|
|
100
112
|
"instead, why, and the cost if wrong; then retry.";
|
|
101
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
|
+
|
|
102
141
|
/** Soft gates on `git commit`: rationale, Conventional shape, structural/behavioural separation; AI trailers are refused. */
|
|
103
142
|
export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
104
143
|
deps.pi.on("tool_call", async (event, ctx) => {
|
|
105
144
|
if (!isToolCallEventType("bash", event)) return undefined;
|
|
106
|
-
const
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
: extracted.kind === "file"
|
|
111
|
-
? readMessageFile(ctx.cwd, extracted.path)
|
|
112
|
-
: undefined;
|
|
113
|
-
if (message === undefined) return undefined;
|
|
114
|
-
|
|
115
|
-
const forbidden = findForbiddenTrailers(message);
|
|
116
|
-
if (forbidden.length > 0) {
|
|
117
|
-
return {
|
|
118
|
-
block: true,
|
|
119
|
-
reason:
|
|
120
|
-
`commit.forbidden-trailer: this message carries an AI attribution (${forbidden.join("; ")}). ` +
|
|
121
|
-
"Commits in this repository have no Co-Authored-By or generated-by trailers and this is not " +
|
|
122
|
-
"something to depart from. Remove the trailer and commit again.",
|
|
123
|
-
};
|
|
124
|
-
}
|
|
145
|
+
const command = event.input.command;
|
|
146
|
+
const extractions = extractCommits(command);
|
|
147
|
+
if (extractions.length === 0) return undefined;
|
|
148
|
+
const messages = extractions.map((e) => messageOf(e, ctx.cwd));
|
|
125
149
|
|
|
126
|
-
const
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
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
|
+
for (const message of known) {
|
|
156
|
+
for (const need of await needsOf(deps, ctx, message)) {
|
|
157
|
+
if (!all.some((n) => n.gate === need.gate)) all.push(need);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
130
160
|
const uses = all.map((need) => ({ need, use: departureUse(deps.state, need.gate) }));
|
|
131
161
|
const uncovered = uses.find(({ use }) => !use.hasOpen());
|
|
132
162
|
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,
|
|
@@ -35,9 +36,13 @@ const RED_TRUNK = gate("push.red-trunk");
|
|
|
35
36
|
const DELIVERY_MODE = gate("push.delivery-mode");
|
|
36
37
|
|
|
37
38
|
async function currentBranch(exec: Exec, cwd: string): Promise<string | undefined> {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
try {
|
|
40
|
+
const r = await exec("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd, timeout: 5000 });
|
|
41
|
+
const name = r.code === 0 ? r.stdout.trim() : "";
|
|
42
|
+
return name === "" || name === "HEAD" ? undefined : name;
|
|
43
|
+
} catch {
|
|
44
|
+
return undefined;
|
|
45
|
+
}
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
async function pushesTrunk(
|
|
@@ -48,7 +53,8 @@ async function pushesTrunk(
|
|
|
48
53
|
): Promise<boolean> {
|
|
49
54
|
for (const t of targets) {
|
|
50
55
|
if (t.allBranches || t.branches.includes(trunk)) return true;
|
|
51
|
-
|
|
56
|
+
const where = t.dir === undefined ? cwd : resolve(cwd, t.dir);
|
|
57
|
+
if (t.branches.length === 0 && (await currentBranch(exec, where)) === trunk) return true;
|
|
52
58
|
}
|
|
53
59
|
return false;
|
|
54
60
|
}
|
|
@@ -58,6 +64,7 @@ async function repairsRedTrunk(
|
|
|
58
64
|
deps: PushGuardDeps,
|
|
59
65
|
ctx: ExtensionContext,
|
|
60
66
|
config: DevsysConfig,
|
|
67
|
+
runId: number | undefined,
|
|
61
68
|
): Promise<boolean> {
|
|
62
69
|
const message = await deps.exec("git", ["log", "-1", "--format=%B"], {
|
|
63
70
|
cwd: ctx.cwd,
|
|
@@ -68,9 +75,11 @@ async function repairsRedTrunk(
|
|
|
68
75
|
const range = `${config.delivery.remote}/${config.delivery.trunk}..HEAD`;
|
|
69
76
|
const diff = await deps.exec("git", ["diff", range], { cwd: ctx.cwd, timeout: 10_000 });
|
|
70
77
|
if (diff.code !== 0 || diff.stdout.trim() === "") return false;
|
|
78
|
+
const failingLog = await getFailureLog(deps.exec, ctx.cwd, runId);
|
|
79
|
+
if (failingLog.trim() === "") return false; // nothing to compare the diff against: fail closed
|
|
71
80
|
const judged = await judgeFixRelated(deps.jev(ctx), {
|
|
72
81
|
diff: diff.stdout,
|
|
73
|
-
failingLog
|
|
82
|
+
failingLog,
|
|
74
83
|
message: message.stdout,
|
|
75
84
|
});
|
|
76
85
|
return judged.ok && judged.value >= FIX_RELATED_THRESHOLD;
|
|
@@ -145,7 +154,7 @@ export function registerPushGuard(deps: PushGuardDeps): void {
|
|
|
145
154
|
}
|
|
146
155
|
const status = await getTrunkStatus(deps.exec, { branch: trunk, cwd: ctx.cwd });
|
|
147
156
|
if (status.status !== "red") return undefined;
|
|
148
|
-
if (await repairsRedTrunk(deps, ctx, loaded.value)) return undefined;
|
|
157
|
+
if (await repairsRedTrunk(deps, ctx, loaded.value, status.runId)) return undefined;
|
|
149
158
|
return hardStop(deps, {
|
|
150
159
|
ctx,
|
|
151
160
|
toolCallId: event.toolCallId,
|
|
@@ -33,6 +33,12 @@ export function createRequestApprovalTool(
|
|
|
33
33
|
exposure: "direct",
|
|
34
34
|
async execute(toolCallId, params: Static<typeof Parameters>, _signal, _onUpdate, ctx) {
|
|
35
35
|
const gate = parseGateId(params.gate);
|
|
36
|
+
if (params.gate === "commit.forbidden-trailer") {
|
|
37
|
+
return text(
|
|
38
|
+
"commit.forbidden-trailer is never approvable: remove the AI attribution from the commit instead.",
|
|
39
|
+
true,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
36
42
|
if (isParseError(gate) || gate.includes(":") || lookupGate(gate)?.tier !== "hard") {
|
|
37
43
|
const hard = gateIds().filter((id) => lookupGate(id)?.tier === "hard");
|
|
38
44
|
return text(`"${params.gate}" is not a hard gate. Hard gates: ${hard.join(", ")}`, true);
|
package/src/state/ci-command.ts
CHANGED
|
@@ -16,6 +16,15 @@ export type CiCommandDeps = {
|
|
|
16
16
|
maxPolls?: number;
|
|
17
17
|
};
|
|
18
18
|
|
|
19
|
+
async function headSha(exec: Exec, cwd: string): Promise<string | undefined> {
|
|
20
|
+
try {
|
|
21
|
+
const r = await exec("git", ["rev-parse", "HEAD"], { cwd, timeout: 5000 });
|
|
22
|
+
return r.code === 0 && r.stdout.trim() !== "" ? r.stdout.trim() : undefined;
|
|
23
|
+
} catch {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
const sameCi = (a: CiState | undefined, b: CiState): boolean =>
|
|
20
29
|
a?.status === b.status && a?.sha === b.sha;
|
|
21
30
|
|
|
@@ -29,7 +38,9 @@ export async function runCiWatch(
|
|
|
29
38
|
ctx.ui.notify(`cannot read the delivery policy: ${config.error.message}`, "error");
|
|
30
39
|
return undefined;
|
|
31
40
|
}
|
|
41
|
+
const head = await headSha(deps.exec, ctx.cwd);
|
|
32
42
|
const final = await watchCi({
|
|
43
|
+
expectSha: head,
|
|
33
44
|
exec: deps.exec,
|
|
34
45
|
branch: config.value.delivery.trunk,
|
|
35
46
|
cwd: ctx.cwd,
|
package/src/state/ci-watch.ts
CHANGED
|
@@ -11,9 +11,11 @@ export type WatchOptions = {
|
|
|
11
11
|
sleep: (ms: number) => Promise<void>;
|
|
12
12
|
intervalMs: number;
|
|
13
13
|
maxPolls: number;
|
|
14
|
+
/** The commit whose run is being waited for; runs of other commits count as still pending. */
|
|
15
|
+
expectSha?: string | undefined;
|
|
14
16
|
};
|
|
15
17
|
|
|
16
|
-
/** Observes the trunk's newest run until it settles (not pending) or the poll budget is spent. */
|
|
18
|
+
/** Observes the trunk's newest run (for `expectSha`, when given) until it settles (not pending) or the poll budget is spent. */
|
|
17
19
|
export async function watchCi(options: WatchOptions): Promise<CiState> {
|
|
18
20
|
let last: CiState = { status: "unknown" };
|
|
19
21
|
for (let poll = 0; poll < options.maxPolls; poll++) {
|
|
@@ -21,10 +23,9 @@ export async function watchCi(options: WatchOptions): Promise<CiState> {
|
|
|
21
23
|
branch: options.branch,
|
|
22
24
|
cwd: options.cwd,
|
|
23
25
|
});
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
: { status: trunk.status, sha: trunk.headSha };
|
|
26
|
+
const stale = options.expectSha !== undefined && trunk.headSha !== options.expectSha;
|
|
27
|
+
const status = stale ? "pending" : trunk.status;
|
|
28
|
+
last = trunk.headSha === undefined ? { status } : { status, sha: trunk.headSha };
|
|
28
29
|
options.onObserved(last);
|
|
29
30
|
if (last.status !== "pending") return last;
|
|
30
31
|
await options.sleep(options.intervalMs);
|
package/src/state/ci.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Exec } from "../core/exec.ts";
|
|
|
3
3
|
export type TrunkStatus = {
|
|
4
4
|
readonly status: "green" | "red" | "pending" | "unknown";
|
|
5
5
|
readonly headSha: string | undefined;
|
|
6
|
+
readonly runId?: number | undefined;
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
const UNKNOWN: TrunkStatus = { status: "unknown", headSha: undefined };
|
|
@@ -12,7 +13,7 @@ const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
|
12
13
|
|
|
13
14
|
const FAILED = new Set(["failure", "timed_out", "startup_failure", "action_required"]);
|
|
14
15
|
|
|
15
|
-
/** Parses `gh run list --json status,conclusion,headSha` output for the newest run. */
|
|
16
|
+
/** Parses `gh run list --json status,conclusion,headSha,databaseId` output for the newest run. */
|
|
16
17
|
export function parseRunList(stdout: string): TrunkStatus {
|
|
17
18
|
let parsed: unknown;
|
|
18
19
|
try {
|
|
@@ -23,12 +24,13 @@ export function parseRunList(stdout: string): TrunkStatus {
|
|
|
23
24
|
const run = Array.isArray(parsed) ? parsed[0] : undefined;
|
|
24
25
|
if (!isRecord(run)) return UNKNOWN;
|
|
25
26
|
const headSha = typeof run.headSha === "string" ? run.headSha : undefined;
|
|
26
|
-
|
|
27
|
-
if (run.
|
|
27
|
+
const id = typeof run.databaseId === "number" ? { runId: run.databaseId } : {};
|
|
28
|
+
if (run.status !== "completed") return { status: "pending", headSha, ...id };
|
|
29
|
+
if (run.conclusion === "success") return { status: "green", headSha, ...id };
|
|
28
30
|
if (typeof run.conclusion === "string" && FAILED.has(run.conclusion)) {
|
|
29
|
-
return { status: "red", headSha };
|
|
31
|
+
return { status: "red", headSha, ...id };
|
|
30
32
|
}
|
|
31
|
-
return { status: "unknown", headSha };
|
|
33
|
+
return { status: "unknown", headSha, ...id };
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
/** CI state of the newest run on the trunk branch; `unknown` whenever `gh` cannot say. */
|
|
@@ -47,7 +49,7 @@ export async function getTrunkStatus(
|
|
|
47
49
|
"--limit",
|
|
48
50
|
"1",
|
|
49
51
|
"--json",
|
|
50
|
-
"status,conclusion,headSha",
|
|
52
|
+
"status,conclusion,headSha,databaseId",
|
|
51
53
|
],
|
|
52
54
|
{ ...(where.cwd !== undefined ? { cwd: where.cwd } : {}), timeout: 15_000 },
|
|
53
55
|
);
|
|
@@ -57,10 +59,15 @@ export async function getTrunkStatus(
|
|
|
57
59
|
}
|
|
58
60
|
}
|
|
59
61
|
|
|
60
|
-
/** The failing log of
|
|
61
|
-
export async function getFailureLog(
|
|
62
|
+
/** The failing log of a failed run, clipped; empty when unavailable (`gh run view` needs an id when non-interactive). */
|
|
63
|
+
export async function getFailureLog(
|
|
64
|
+
exec: Exec,
|
|
65
|
+
cwd: string | undefined,
|
|
66
|
+
runId: number | undefined,
|
|
67
|
+
): Promise<string> {
|
|
68
|
+
if (runId === undefined) return "";
|
|
62
69
|
try {
|
|
63
|
-
const r = await exec("gh", ["run", "view", "--log-failed"], {
|
|
70
|
+
const r = await exec("gh", ["run", "view", String(runId), "--log-failed"], {
|
|
64
71
|
...(cwd !== undefined ? { cwd } : {}),
|
|
65
72
|
timeout: 30_000,
|
|
66
73
|
});
|
package/src/state/config.ts
CHANGED
|
@@ -124,11 +124,13 @@ const POSITIVE = { min: 1, max: 1_000_000, integer: true };
|
|
|
124
124
|
|
|
125
125
|
const MODEL_CANDIDATE = /^(@[a-z]+|[^/\s]+\/\S+)$/;
|
|
126
126
|
|
|
127
|
+
const knownSlotRef = (c: string): boolean => !c.startsWith("@") || isSlot(c.slice(1));
|
|
128
|
+
|
|
127
129
|
function candidates(raw: unknown, key: string): Result<readonly string[], ConfigError> {
|
|
128
130
|
const valid =
|
|
129
131
|
Array.isArray(raw) &&
|
|
130
132
|
raw.length > 0 &&
|
|
131
|
-
raw.every((c) => typeof c === "string" && MODEL_CANDIDATE.test(c));
|
|
133
|
+
raw.every((c) => typeof c === "string" && MODEL_CANDIDATE.test(c) && knownSlotRef(c));
|
|
132
134
|
return valid
|
|
133
135
|
? ok(raw.map(String))
|
|
134
136
|
: fail(
|