@jwilger/pi-development-system 0.90.0 → 0.91.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/README.md +13 -0
- package/extensions/development-system.ts +4 -0
- package/package.json +1 -1
- package/src/core/git-intent.ts +319 -35
- package/src/core/git-invocations.ts +32 -3
- package/src/core/message-problem.ts +10 -0
- package/src/core/push-command.ts +41 -6
- package/src/gates/commit-guard.ts +14 -11
- package/src/gates/excused-messages.ts +21 -0
- package/src/gates/push-guard.ts +130 -27
- package/src/gates/push-messages.ts +94 -0
package/README.md
CHANGED
|
@@ -124,6 +124,19 @@ What the tiers do not cover, so you can decide what to trust:
|
|
|
124
124
|
- **A departure is the agent's own call.** `devsys_record_departure` waives a soft gate (also with no
|
|
125
125
|
UI) and is written to the decision log; it is never available for a hard stop.
|
|
126
126
|
- **Gating starts at `devsys_intake`.** With no slice open, the review and red-first gates are off.
|
|
127
|
+
- **Command shape is read, not run.** The git guards classify the command text: aliases and
|
|
128
|
+
`git config` that redirect a later command, shells reading stdin, and `python -c` or `node -e`
|
|
129
|
+
that mention git are all treated as unclassifiable, so the user decides (headless: refused). A
|
|
130
|
+
command the classifier cannot see into at all (a script file, another tool) is not covered.
|
|
131
|
+
Commit messages are checked again at push time against what the remote lacks, so a commit made by
|
|
132
|
+
`-C`, `--fixup`, `commit-tree` or an alias cannot carry an AI trailer or skip the rationale; a push
|
|
133
|
+
in the same call as such a commit is refused, because the commit does not exist yet when it is checked.
|
|
134
|
+
- **A push source the shell builds is checked loosely.** `git push origin "$(cat sha)":refs/heads/x`
|
|
135
|
+
checks every local branch for AI trailers, not the unnamed commit the expression may name (one made
|
|
136
|
+
with `commit-tree` and on no branch). The unclassified-command prompt is the only stop there.
|
|
137
|
+
- **A push after a ref moves in the same call is checked against the refs as they were.**
|
|
138
|
+
`git merge --ff-only feature && git push origin main` publishes commits on `feature` that the
|
|
139
|
+
push check read before the call ran. Make the merge, switch or reset in one call and push in the next.
|
|
127
140
|
- **Editing gate configuration is not guarded** (`biome.json`, hooks, CI files); review catches it.
|
|
128
141
|
|
|
129
142
|
## Decision log
|
|
@@ -17,6 +17,7 @@ import { type Exec, timeoutAsFailure } from "../src/core/exec.ts";
|
|
|
17
17
|
import { defaultMatrix } from "../src/core/models.ts";
|
|
18
18
|
import { createApprovalStore } from "../src/gates/approvals.ts";
|
|
19
19
|
import { registerCommitGuard } from "../src/gates/commit-guard.ts";
|
|
20
|
+
import { createExcusedMessages } from "../src/gates/excused-messages.ts";
|
|
20
21
|
import { declareWithoutCodemode } from "../src/gates/exposure.ts";
|
|
21
22
|
import { registerGitGuard } from "../src/gates/git-guard.ts";
|
|
22
23
|
import { registerLintSuppressionGuard } from "../src/gates/lint-suppression-guard.ts";
|
|
@@ -124,11 +125,13 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
124
125
|
const exec: Exec = async (command, args, options) =>
|
|
125
126
|
timeoutAsFailure(await pi.exec(command, args, options));
|
|
126
127
|
registerGitGuard({ pi, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
|
|
128
|
+
const excused = createExcusedMessages();
|
|
127
129
|
registerCommitGuard({
|
|
128
130
|
pi,
|
|
129
131
|
state,
|
|
130
132
|
jev: (ctx) => jevHolder.forContext(ctx),
|
|
131
133
|
exec,
|
|
134
|
+
excused,
|
|
132
135
|
});
|
|
133
136
|
registerCiCommand({
|
|
134
137
|
pi,
|
|
@@ -141,6 +144,7 @@ export function createDevelopmentSystem(pi: ExtensionAPI) {
|
|
|
141
144
|
approvals,
|
|
142
145
|
jev: (ctx) => jevHolder.forContext(ctx),
|
|
143
146
|
exec,
|
|
147
|
+
excused,
|
|
144
148
|
});
|
|
145
149
|
registerTestGuard({ pi, state, approvals, jev: (ctx) => jevHolder.forContext(ctx) });
|
|
146
150
|
registerTestEvidence({ pi, state });
|
package/package.json
CHANGED
package/src/core/git-intent.ts
CHANGED
|
@@ -117,7 +117,7 @@ export const DATA_ONLY = new Set([
|
|
|
117
117
|
"head",
|
|
118
118
|
"tail",
|
|
119
119
|
]);
|
|
120
|
-
export const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh"]);
|
|
120
|
+
export const SHELLS = new Set(["bash", "sh", "zsh", "dash", "ksh", "fish", "csh", "tcsh"]);
|
|
121
121
|
export const OPAQUE = new Set(["xargs", "ssh", "find", "parallel", "watch"]);
|
|
122
122
|
export const GLOBAL_WITH_VALUE = new Set([
|
|
123
123
|
"-c",
|
|
@@ -128,7 +128,72 @@ export const GLOBAL_WITH_VALUE = new Set([
|
|
|
128
128
|
"--config-env",
|
|
129
129
|
"--super-prefix",
|
|
130
130
|
]);
|
|
131
|
-
|
|
131
|
+
|
|
132
|
+
/** Assignments that switch git hooks off (lefthook, husky), however the value is spelled. */
|
|
133
|
+
const skipsHooks = (word: string): boolean =>
|
|
134
|
+
/^(LEFTHOOK|HUSKY)=(0|false|no|off)$/i.test(word) ||
|
|
135
|
+
/^(LEFTHOOK_EXCLUDE|HUSKY_SKIP_HOOKS)=.+/i.test(word);
|
|
136
|
+
/** Assignments that feed git configuration (aliases, hooks path, push targets) from the environment. */
|
|
137
|
+
const setsGitConfig = (word: string): boolean =>
|
|
138
|
+
/^GIT_CONFIG_[A-Z_0-9]*=/.test(word) ||
|
|
139
|
+
/^(HOME|XDG_CONFIG_HOME)=/.test(word) || // git reads its global config from there
|
|
140
|
+
(/^(GIT_(SSH|SSH_COMMAND|EDITOR|SEQUENCE_EDITOR|PAGER|ASKPASS|EXTERNAL_DIFF|PROXY_COMMAND|EXEC_PATH)|EDITOR|VISUAL|PAGER)=/.test(
|
|
141
|
+
word,
|
|
142
|
+
) &&
|
|
143
|
+
!isBenignProgram(word.slice(word.indexOf("=") + 1)));
|
|
144
|
+
/** A bare program name (`true`, `cat`, `less`) that is neither git, a shell nor an interpreter: it cannot run a push. */
|
|
145
|
+
const isBenignProgram = (value: string): boolean =>
|
|
146
|
+
/^[\w.+-]+$/.test(value) && !isCommandWord(value);
|
|
147
|
+
/** Builtins that set variables for the commands after them. */
|
|
148
|
+
const ENV_SETTERS = new Set(["export", "declare", "typeset", "readonly", "local"]);
|
|
149
|
+
/** Commands that run another command (or a shell script) given as their arguments. */
|
|
150
|
+
const RUNNERS = new Set([
|
|
151
|
+
"timeout",
|
|
152
|
+
"script",
|
|
153
|
+
"busybox",
|
|
154
|
+
"setsid",
|
|
155
|
+
"stdbuf",
|
|
156
|
+
"ionice",
|
|
157
|
+
"flock",
|
|
158
|
+
"doas",
|
|
159
|
+
"su",
|
|
160
|
+
"runuser",
|
|
161
|
+
"xvfb-run",
|
|
162
|
+
"unbuffer",
|
|
163
|
+
"strace",
|
|
164
|
+
"ltrace",
|
|
165
|
+
"taskset",
|
|
166
|
+
"chrt",
|
|
167
|
+
"nsenter",
|
|
168
|
+
"chroot",
|
|
169
|
+
"bwrap",
|
|
170
|
+
"firejail",
|
|
171
|
+
]);
|
|
172
|
+
const INTERPRETERS = /^(python[\d.]*|node(js)?|ruby|perl|php|deno|bun|lua|tclsh|osascript)$/;
|
|
173
|
+
const INLINE_CODE_FLAG = /^(-[A-Za-z]*[cepEr]|--eval|--print|eval)$/;
|
|
174
|
+
/** Paths a program reads its standard input through. */
|
|
175
|
+
const STDIN_PATH = /^(-|\/dev\/stdin|\/dev\/fd\/\d+|\/proc\/(self|\d+)\/fd\/\d+)$/;
|
|
176
|
+
const INFO_FLAG = /^(-[Vh]|--version|--help)$/;
|
|
177
|
+
/** `node -v`, `ruby -v`, `perl -v` print a version; for python `-v` is verbose and still reads stdin. */
|
|
178
|
+
const VERSION_FLAG = /^-v$/;
|
|
179
|
+
|
|
180
|
+
/** Every subcommand git itself provides; any other word is an alias or an external `git-<word>`. */
|
|
181
|
+
export const GIT_SUBCOMMANDS = new Set(
|
|
182
|
+
`add am annotate apply archive bisect blame branch bundle cat-file check-attr check-ignore
|
|
183
|
+
check-ref-format checkout checkout-index cherry cherry-pick citool clean clone column commit
|
|
184
|
+
commit-graph commit-tree config count-objects credential describe diff diff-files diff-index
|
|
185
|
+
diff-tree difftool fast-export fast-import fetch fetch-pack filter-branch filter-repo
|
|
186
|
+
for-each-ref for-each-repo format-patch fsck gc gitk grep gui hash-object help hook index-pack
|
|
187
|
+
init instaweb interpret-trailers lfs log ls-files ls-remote ls-tree maintenance merge
|
|
188
|
+
merge-base merge-file merge-index merge-tree mergetool mktag mktree mv name-rev notes pack-objects
|
|
189
|
+
pack-refs patch-id prune prune-packed pull push range-diff read-tree rebase reflog remote repack
|
|
190
|
+
replace request-pull rerere reset restore rev-list rev-parse revert rm scalar send-email
|
|
191
|
+
shortlog show show-branch show-index show-ref sparse-checkout stash status stripspace submodule
|
|
192
|
+
switch symbolic-ref tag unpack-file unpack-objects update-index update-ref update-server-info
|
|
193
|
+
var verify-commit verify-pack verify-tag version whatchanged worktree write-tree bugreport diagnose
|
|
194
|
+
multi-pack-index refs repo mailinfo mailsplit upload-pack receive-pack upload-archive daemon
|
|
195
|
+
get-tar-commit-id difftool--helper maintenance backfill`.split(/\s+/),
|
|
196
|
+
);
|
|
132
197
|
|
|
133
198
|
export const basename = (path: string): string => path.slice(path.lastIndexOf("/") + 1);
|
|
134
199
|
export const isAssignment = (t: string): boolean => /^[A-Za-z_][A-Za-z0-9_]*=/.test(t);
|
|
@@ -139,9 +204,76 @@ const isLong = (arg: string, ...names: string[]): boolean => {
|
|
|
139
204
|
return key.length >= 4 && key.startsWith("--") && names.some((n) => n.startsWith(key));
|
|
140
205
|
};
|
|
141
206
|
|
|
207
|
+
/** Keys whose value git runs as a command: setting one makes a later, harmless-looking command run anything. */
|
|
208
|
+
const RUNS_COMMAND =
|
|
209
|
+
/^(core\.(sshcommand|editor|pager|fsmonitor|askpass)|diff\.external|sequence\.editor|credential(\..+)?\.helper|gpg(\..+)?\.program|filter\..+\.(clean|smudge|process)|diff\..+\.(textconv|command)|merge\..+\.driver|(merge|diff)tool\..+\.cmd|pager\..+|uploadpack\.packobjectshook|url\..+\.(insteadof|pushinsteadof))$/;
|
|
210
|
+
|
|
211
|
+
/** What setting this configuration key can do to a later git command. */
|
|
212
|
+
function configKeyIntent(key: string, value?: string): GitIntent {
|
|
213
|
+
const k = key.toLowerCase();
|
|
214
|
+
if (k === "core.hookspath") return "no-verify";
|
|
215
|
+
const runsCommand = RUNS_COMMAND.test(k) && !(value !== undefined && isBenignProgram(value));
|
|
216
|
+
const redirects =
|
|
217
|
+
k.startsWith("alias.") ||
|
|
218
|
+
k.startsWith("include.") ||
|
|
219
|
+
k.startsWith("includeif.") ||
|
|
220
|
+
runsCommand ||
|
|
221
|
+
/^remote\..+\.(push|mirror|pushurl)$/.test(k) ||
|
|
222
|
+
k === "push.default";
|
|
223
|
+
return redirects ? "unknown" : "ordinary";
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const READS_CONFIG = /^--(get|get-all|get-regexp|list|unset|unset-all)$|^-l$/;
|
|
227
|
+
|
|
228
|
+
/** `git config` options that take a value: `--comment --get` is a comment, not a lookup. */
|
|
229
|
+
const CONFIG_VALUE_OPTIONS = new Set([
|
|
230
|
+
"--comment",
|
|
231
|
+
"-f",
|
|
232
|
+
"--file",
|
|
233
|
+
"--blob",
|
|
234
|
+
"--type",
|
|
235
|
+
"--default",
|
|
236
|
+
"--value",
|
|
237
|
+
]);
|
|
238
|
+
|
|
239
|
+
/** The words with each value-taking option's value removed. */
|
|
240
|
+
const withoutOptionValues = (args: string[]): string[] =>
|
|
241
|
+
args.filter((_, k) => !CONFIG_VALUE_OPTIONS.has(args[k - 1] ?? ""));
|
|
242
|
+
|
|
243
|
+
function classifyConfig(args: string[]): GitIntent {
|
|
244
|
+
// `git config get|list|unset ...` (newer spelling) never sets a value.
|
|
245
|
+
if (["get", "list", "unset"].includes(args[0] ?? "")) return "ordinary";
|
|
246
|
+
// git stops reading options at the first plain word: `config k v --get` still writes k.
|
|
247
|
+
const options = args.slice(
|
|
248
|
+
0,
|
|
249
|
+
Math.max(
|
|
250
|
+
0,
|
|
251
|
+
args.findIndex((a) => !a.startsWith("-")),
|
|
252
|
+
),
|
|
253
|
+
);
|
|
254
|
+
const leading = args.findIndex((a) => !a.startsWith("-")) < 0 ? args : options;
|
|
255
|
+
if (withoutOptionValues(leading).some((a) => READS_CONFIG.test(a))) return "ordinary";
|
|
256
|
+
const plain = withoutOptionValues(args).filter((a) => !a.startsWith("-"));
|
|
257
|
+
// A key with no value is a lookup (`git config core.hooksPath`), not a write.
|
|
258
|
+
if (plain.length < 2) return "ordinary";
|
|
259
|
+
return plain.reduce<GitIntent>(
|
|
260
|
+
(acc, a, k) => worst(acc, configKeyIntent(a, plain[k + 1])),
|
|
261
|
+
"ordinary",
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
142
265
|
function classifyGitArgs(globals: string[], sub: string | undefined, args: string[]): GitIntent {
|
|
143
266
|
let intent: GitIntent = "ordinary";
|
|
144
|
-
|
|
267
|
+
for (const g of globals) {
|
|
268
|
+
const eq = g.indexOf("=");
|
|
269
|
+
intent = worst(
|
|
270
|
+
intent,
|
|
271
|
+
configKeyIntent(eq < 0 ? g : g.slice(0, eq), eq < 0 ? undefined : g.slice(eq + 1)),
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
if (sub !== undefined && !sub.startsWith("$") && !GIT_SUBCOMMANDS.has(sub)) {
|
|
275
|
+
intent = worst(intent, "unknown");
|
|
276
|
+
}
|
|
145
277
|
if (args.some((a) => isLong(a, "--no-verify"))) intent = worst(intent, "no-verify");
|
|
146
278
|
switch (sub) {
|
|
147
279
|
case "push": {
|
|
@@ -156,7 +288,7 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
|
|
|
156
288
|
return worst(intent, "force-push");
|
|
157
289
|
}
|
|
158
290
|
if (
|
|
159
|
-
args.some((a) => isLong(a, "--delete")) ||
|
|
291
|
+
args.some((a) => isLong(a, "--delete", "--prune")) ||
|
|
160
292
|
shortFlags.includes("-d") ||
|
|
161
293
|
args.some((a) => /^:\S/.test(a))
|
|
162
294
|
) {
|
|
@@ -172,6 +304,11 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
|
|
|
172
304
|
return args.some((a) => ["--abort", "--continue", "--skip", "--quit"].includes(a))
|
|
173
305
|
? intent
|
|
174
306
|
: worst(intent, "history-rewrite");
|
|
307
|
+
case "config":
|
|
308
|
+
return worst(intent, classifyConfig(args));
|
|
309
|
+
case "remote":
|
|
310
|
+
// `remote add --mirror=push` / `set-url --push` make a later plain push mirror or redirect.
|
|
311
|
+
return args.some((a) => /^--(mirror|push)/.test(a)) ? worst(intent, "unknown") : intent;
|
|
175
312
|
case "filter-branch":
|
|
176
313
|
case "filter-repo":
|
|
177
314
|
return worst(intent, "history-rewrite");
|
|
@@ -183,21 +320,27 @@ function classifyGitArgs(globals: string[], sub: string | undefined, args: strin
|
|
|
183
320
|
}
|
|
184
321
|
|
|
185
322
|
/** Skips leading `NAME=value` words, wrappers and shell keywords; `index` is the command word (-1: none). */
|
|
186
|
-
function skipPrefix(tokens: string[]): { index: number;
|
|
187
|
-
let
|
|
323
|
+
function skipPrefix(tokens: string[]): { index: number; env: GitIntent } {
|
|
324
|
+
let env: GitIntent = "ordinary";
|
|
188
325
|
let index = 0;
|
|
189
326
|
for (;;) {
|
|
190
327
|
const t = tokens[index];
|
|
191
|
-
if (t === undefined) return { index: -1,
|
|
192
|
-
if (isAssignment(t))
|
|
193
|
-
|
|
194
|
-
} else if (!(WRAPPERS.has(t) || KEYWORDS.has(t))) {
|
|
195
|
-
return { index, hookSkip };
|
|
196
|
-
}
|
|
328
|
+
if (t === undefined) return { index: -1, env };
|
|
329
|
+
if (isAssignment(t)) env = worst(env, assignmentIntent(t));
|
|
330
|
+
else if (!(WRAPPERS.has(t) || KEYWORDS.has(t))) return { index, env };
|
|
197
331
|
index++;
|
|
198
332
|
}
|
|
199
333
|
}
|
|
200
334
|
|
|
335
|
+
/** What one `NAME=value` word does to the git commands that run with it. */
|
|
336
|
+
function assignmentIntent(word: string): GitIntent {
|
|
337
|
+
if (skipsHooks(word)) return "no-verify";
|
|
338
|
+
return setsGitConfig(word) ? "unknown" : "ordinary";
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** `--config-env=key=VAR`: the value is a variable's name, whatever that variable holds; only the key is known. */
|
|
342
|
+
const envConfigKey = (pair: string): string => pair.split("=")[0] ?? "";
|
|
343
|
+
|
|
201
344
|
/** Git's global options (`-c k=v`, `-C dir`, flags) before the subcommand. */
|
|
202
345
|
function splitGlobals(rest: string[]): {
|
|
203
346
|
globals: string[];
|
|
@@ -208,8 +351,12 @@ function splitGlobals(rest: string[]): {
|
|
|
208
351
|
let j = 0;
|
|
209
352
|
while (j < rest.length && (rest[j] ?? "").startsWith("-")) {
|
|
210
353
|
const opt = rest[j] ?? "";
|
|
211
|
-
if (
|
|
212
|
-
globals.push(
|
|
354
|
+
if (opt.startsWith("--config-env=")) {
|
|
355
|
+
globals.push(envConfigKey(opt.slice("--config-env=".length)));
|
|
356
|
+
j++;
|
|
357
|
+
} else if (GLOBAL_WITH_VALUE.has(opt)) {
|
|
358
|
+
const value = rest[j + 1] ?? "";
|
|
359
|
+
globals.push(opt === "--config-env" ? envConfigKey(value) : value);
|
|
213
360
|
j += 2;
|
|
214
361
|
} else {
|
|
215
362
|
j++;
|
|
@@ -218,28 +365,132 @@ function splitGlobals(rest: string[]): {
|
|
|
218
365
|
return { globals, sub: rest[j], args: rest.slice(j + 1) };
|
|
219
366
|
}
|
|
220
367
|
|
|
368
|
+
/** A shell started without a script to run reads its commands from stdin, which cannot be seen. */
|
|
369
|
+
function classifyShell(rest: string[]): GitIntent {
|
|
370
|
+
const c = rest.findIndex((t) => /^-[A-Za-z]*c[A-Za-z]*$/.test(t));
|
|
371
|
+
const script = c >= 0 ? rest[c + 1] : undefined;
|
|
372
|
+
if (script !== undefined) return classifyGitCommand(script);
|
|
373
|
+
return readsStdin(rest) ? "unknown" : "ordinary";
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** No script file among the arguments (or `-s`, or a stdin path): the program reads its code from stdin. */
|
|
377
|
+
const readsStdin = (rest: string[]): boolean =>
|
|
378
|
+
rest.includes("-s") ||
|
|
379
|
+
rest.some((t) => STDIN_PATH.test(t)) ||
|
|
380
|
+
rest.every((t) => t.startsWith("-"));
|
|
381
|
+
|
|
382
|
+
/** A word that starts a command worth reading: git, a shell, or an interpreter that may run either. */
|
|
383
|
+
const isCommandWord = (word: string): boolean => {
|
|
384
|
+
const base = basename(word);
|
|
385
|
+
return base === "git" || SHELLS.has(base) || INTERPRETERS.test(base);
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
/** `timeout 5 bash -c '…'`, `script -qc '…'`: the wrapped command is a later word or a script string. */
|
|
389
|
+
function classifyRunner(rest: string[]): GitIntent {
|
|
390
|
+
let intent: GitIntent = "ordinary";
|
|
391
|
+
for (const [k, word] of rest.entries()) {
|
|
392
|
+
// Everything after the command word is that command's own arguments, not more commands.
|
|
393
|
+
if (isCommandWord(word)) {
|
|
394
|
+
return worst(intent, classifySegment(rest.slice(k)));
|
|
395
|
+
}
|
|
396
|
+
if (isAssignment(word)) intent = worst(intent, assignmentIntent(word));
|
|
397
|
+
else if (/\s/.test(word)) intent = worst(intent, classifyGitCommand(word));
|
|
398
|
+
}
|
|
399
|
+
return intent;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** `python3 -c '…git…'`: code that may run git by any means cannot be read. */
|
|
403
|
+
const interpreterRunsGit = (rest: string[], isPython: boolean): boolean => {
|
|
404
|
+
const inline = rest.some((t) => INLINE_CODE_FLAG.test(t));
|
|
405
|
+
// Inline code that mentions git, or a script read from stdin (pipe, heredoc): either can run any git.
|
|
406
|
+
if (inline) return rest.some((t) => /\bgit\b/.test(t) && !t.startsWith("-"));
|
|
407
|
+
if (rest.some((t) => INFO_FLAG.test(t) || (VERSION_FLAG.test(t) && !isPython))) return false;
|
|
408
|
+
// `node --test` is a runner, not stdin; flags alone (`python3 -u`) still read it, so does a stdin path.
|
|
409
|
+
const runs = rest.some((t) => /^--(test|run|check|watch)\b/.test(t));
|
|
410
|
+
return !runs && (rest.every((t) => t.startsWith("-")) || rest.some((t) => STDIN_PATH.test(t)));
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** `export LEFTHOOK=0`: the assignment outlives the command, so the commands after it skip hooks. */
|
|
414
|
+
const classifyExport = (rest: string[]): GitIntent =>
|
|
415
|
+
rest.reduce<GitIntent>(
|
|
416
|
+
(acc, t) => worst(acc, isAssignment(t) ? assignmentIntent(t) : "ordinary"),
|
|
417
|
+
"ordinary",
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
/** The command line `env -S` / `--split-string` carries, if there is one. */
|
|
421
|
+
function splitStringValue(rest: string[]): string | undefined {
|
|
422
|
+
for (const [k, t] of rest.entries()) {
|
|
423
|
+
if (t === "-S" || t === "--split-string") return rest[k + 1];
|
|
424
|
+
if (t.startsWith("--split-string=")) return t.slice("--split-string=".length);
|
|
425
|
+
if (/^-S./.test(t)) return t.slice(2);
|
|
426
|
+
}
|
|
427
|
+
return undefined;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** A command that is not git, a shell or an assignment: it may still wrap or script a git command. */
|
|
431
|
+
function classifyOtherCommand(base: string, rest: string[]): GitIntent {
|
|
432
|
+
if (DATA_ONLY.has(base)) return "ordinary";
|
|
433
|
+
if (RUNNERS.has(base)) return classifyRunner(rest);
|
|
434
|
+
if (INTERPRETERS.test(base) && interpreterRunsGit(rest, base.startsWith("python")))
|
|
435
|
+
return "unknown";
|
|
436
|
+
// `env -S "git push -f"` splits its value into a command line.
|
|
437
|
+
const split = splitStringValue([base, ...rest]);
|
|
438
|
+
if (split !== undefined) return classifyGitCommand(split);
|
|
439
|
+
// Wrapper with options (`sudo -E bash -c ...`, `env -i git ...`): classify from the git or shell token.
|
|
440
|
+
// Only an option-shaped head means a skipped wrapper's options (`command -v node` is a lookup);
|
|
441
|
+
// otherwise just a literal `git` among the words counts (`brew install node` runs nothing).
|
|
442
|
+
const wrapperOptions = base.startsWith("-") && !/^-[vV]$/.test(base);
|
|
443
|
+
const at = wrapperOptions
|
|
444
|
+
? rest.findIndex(isCommandWord)
|
|
445
|
+
: rest.findIndex((t) => basename(t) === "git");
|
|
446
|
+
if (at < 0) return "ordinary";
|
|
447
|
+
// `apt install git curl`, `brew install git gh`: a `git` argument followed by another package name is data.
|
|
448
|
+
const after = rest[at + 1];
|
|
449
|
+
if (!wrapperOptions && after !== undefined && !/^[-$]/.test(after) && !GIT_SUBCOMMANDS.has(after))
|
|
450
|
+
return "ordinary";
|
|
451
|
+
// `env -i LEFTHOOK=0 git commit`: assignments among the wrapper's own words reach the command too.
|
|
452
|
+
const env = rest
|
|
453
|
+
.slice(0, at)
|
|
454
|
+
.reduce<GitIntent>(
|
|
455
|
+
(acc, t) => worst(acc, isAssignment(t) ? assignmentIntent(t) : "ordinary"),
|
|
456
|
+
"ordinary",
|
|
457
|
+
);
|
|
458
|
+
return worst(env, classifySegment(rest.slice(at)));
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Whether a command can start git itself (a runner, an interpreter, a wrapper's options, or a git/shell word). */
|
|
462
|
+
const runsCommands = (base: string, rest: string[]): boolean =>
|
|
463
|
+
RUNNERS.has(base) ||
|
|
464
|
+
INTERPRETERS.test(base) ||
|
|
465
|
+
base.startsWith("-") ||
|
|
466
|
+
rest.some((t) => basename(t) === "git" || SHELLS.has(basename(t)));
|
|
467
|
+
|
|
221
468
|
// pi-lens-ignore: high-fan-out -- the classifier dispatches one git word to many small predicates; it is a table, not coordination
|
|
222
469
|
function classifySegment(tokens: string[]): GitIntent {
|
|
223
|
-
const { index: i,
|
|
224
|
-
if (i < 0) return
|
|
470
|
+
const { index: i, env } = skipPrefix(tokens);
|
|
471
|
+
if (i < 0) return env;
|
|
225
472
|
const head = tokens[i] ?? "";
|
|
226
473
|
const rest = tokens.slice(i + 1);
|
|
227
474
|
const base = basename(head);
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
-
if (
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
return
|
|
475
|
+
const withEnv = (found: GitIntent): GitIntent => worst(found, env);
|
|
476
|
+
if (head.startsWith("$") || head.includes("`") || base === "$GIT") return "unknown";
|
|
477
|
+
if (SHELLS.has(base)) return withEnv(classifyShell(rest));
|
|
478
|
+
if (base === "eval") return withEnv(classifyGitCommand(rest.join(" ")));
|
|
479
|
+
if (ENV_SETTERS.has(base)) return withEnv(classifyExport(rest));
|
|
480
|
+
if (OPAQUE.has(base)) {
|
|
481
|
+
return rest.some((t) => isCommandWord(t) || (/\s/.test(t) && /\bgit\b/.test(t)))
|
|
482
|
+
? "unknown"
|
|
483
|
+
: "ordinary";
|
|
484
|
+
}
|
|
485
|
+
// `source /dev/stdin <<< '…'`: sourcing standard input runs code that cannot be read.
|
|
486
|
+
if ((base === "source" || base === ".") && rest.some((t) => STDIN_PATH.test(t))) return "unknown";
|
|
487
|
+
if (base.startsWith("git-") && GIT_SUBCOMMANDS.has(base.slice(4))) {
|
|
488
|
+
return withEnv(classifyGitArgs([], base.slice(4), rest));
|
|
235
489
|
}
|
|
236
|
-
if (base === "eval") return classifyGitCommand(rest.join(" "));
|
|
237
|
-
if (OPAQUE.has(base)) return rest.some((t) => basename(t) === "git") ? "unknown" : "ordinary";
|
|
238
490
|
if (base !== "git") {
|
|
239
|
-
|
|
240
|
-
//
|
|
241
|
-
|
|
242
|
-
return at >= 0 ? withHookSkip(classifySegment(rest.slice(at))) : "ordinary";
|
|
491
|
+
const found = classifyOtherCommand(base, rest);
|
|
492
|
+
// `HUSKY=0 npm ci` skips a hook installer, not a commit hook: the assignment counts only for commands that can run git.
|
|
493
|
+
return runsCommands(base, rest) ? withEnv(found) : found;
|
|
243
494
|
}
|
|
244
495
|
const { globals, sub, args } = splitGlobals(rest);
|
|
245
496
|
const dynamic =
|
|
@@ -247,7 +498,7 @@ function classifySegment(tokens: string[]): GitIntent {
|
|
|
247
498
|
(["push", "reset", "rebase"].includes(sub ?? "") &&
|
|
248
499
|
args.some((t) => t.startsWith("$") || t.includes("`")));
|
|
249
500
|
const intent = classifyGitArgs(globals, sub, args);
|
|
250
|
-
return
|
|
501
|
+
return withEnv(dynamic ? worst(intent, "unknown") : intent);
|
|
251
502
|
}
|
|
252
503
|
|
|
253
504
|
/**
|
|
@@ -316,19 +567,52 @@ export function heredocDelimiter(line: string): string | undefined {
|
|
|
316
567
|
return undefined;
|
|
317
568
|
}
|
|
318
569
|
|
|
319
|
-
/**
|
|
570
|
+
/** The body of the heredoc that `delimiter` opened on line `from` of `lines` (empty when unterminated). */
|
|
571
|
+
function heredocBody(lines: readonly string[], from: number, delimiter: string): string {
|
|
572
|
+
const body: string[] = [];
|
|
573
|
+
for (const line of lines.slice(from + 1)) {
|
|
574
|
+
if (line.trim() === delimiter) break;
|
|
575
|
+
body.push(line);
|
|
576
|
+
}
|
|
577
|
+
return body.join("\n");
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Whether a script read from a heredoc cannot run git. An interpreter's body only has to avoid the word;
|
|
582
|
+
* a shell's must also be plain words, because `g\it`, `gi""t` and `${G}t` build it without spelling it.
|
|
583
|
+
*/
|
|
584
|
+
const scriptCannotRunGit = (body: string, shell: boolean): boolean =>
|
|
585
|
+
!/git/i.test(body) && (!shell || /^[\w\s./=:,@%+-]*$/.test(body));
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Classifies the most severe git intent in a shell command (deterministic fast path).
|
|
589
|
+
* A script fed by a heredoc (`python3 - <<'EOF'`) is unreadable only if it may run git: when neither
|
|
590
|
+
* the command line nor the body mentions git at all, it is not held for the user.
|
|
591
|
+
*/
|
|
320
592
|
export function classifyGitCommand(command: string): GitIntent {
|
|
321
593
|
let intent: GitIntent = "ordinary";
|
|
322
594
|
let heredocEnd: string | undefined;
|
|
323
|
-
|
|
595
|
+
const lines = splitLines(command);
|
|
596
|
+
for (const [index, line] of lines.entries()) {
|
|
324
597
|
if (heredocEnd !== undefined) {
|
|
325
598
|
if (line.trim() === heredocEnd) heredocEnd = undefined;
|
|
326
599
|
continue;
|
|
327
600
|
}
|
|
328
|
-
|
|
601
|
+
const delimiter = heredocDelimiter(line);
|
|
602
|
+
let lineIntent: GitIntent = "ordinary";
|
|
603
|
+
for (const segment of segments(line)) lineIntent = worst(lineIntent, classifySegment(segment));
|
|
329
604
|
for (const body of substitutions(live(line, false)))
|
|
330
|
-
|
|
331
|
-
|
|
605
|
+
lineIntent = worst(lineIntent, classifyGitCommand(body));
|
|
606
|
+
const gitFree =
|
|
607
|
+
delimiter !== undefined &&
|
|
608
|
+
!/git/i.test(line) &&
|
|
609
|
+
!/[$`]/.test(line) && // `$CMD <<EOF` runs whatever the variable holds
|
|
610
|
+
scriptCannotRunGit(
|
|
611
|
+
heredocBody(lines, index, delimiter),
|
|
612
|
+
segments(line).some((seg) => seg.some((t) => SHELLS.has(basename(t)))),
|
|
613
|
+
);
|
|
614
|
+
intent = worst(intent, lineIntent === "unknown" && gitFree ? "ordinary" : lineIntent);
|
|
615
|
+
heredocEnd = delimiter ?? heredocEnd;
|
|
332
616
|
}
|
|
333
617
|
return intent;
|
|
334
618
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
basename,
|
|
3
3
|
DATA_ONLY,
|
|
4
|
+
GIT_SUBCOMMANDS,
|
|
4
5
|
GLOBAL_WITH_VALUE,
|
|
5
6
|
heredocDelimiter,
|
|
6
7
|
isAssignment,
|
|
@@ -95,13 +96,33 @@ function visitSegment(tokens: readonly string[], into: Collector): void {
|
|
|
95
96
|
else if (OPAQUE.has(base)) {
|
|
96
97
|
if (gitIndex(rest) >= 0) into.opaque.push(...rest);
|
|
97
98
|
} else if (base === "git") visitGit(rest, into);
|
|
99
|
+
else if (/^git-[a-z]/.test(base))
|
|
100
|
+
visitGit([base.slice(4), ...rest], into); // /usr/lib/git-core/git-push
|
|
98
101
|
else visitWrapped(base, rest, into);
|
|
99
102
|
}
|
|
100
103
|
|
|
101
|
-
/**
|
|
104
|
+
/**
|
|
105
|
+
* Wrapper with options (`sudo -E git …`, `timeout 5 git …`, `timeout 5 bash -c '…'`): resolve from the
|
|
106
|
+
* git or shell token; `env -S 'git push'` carries its command line as one string and is read too.
|
|
107
|
+
*/
|
|
102
108
|
function visitWrapped(base: string, rest: readonly string[], into: Collector): void {
|
|
103
|
-
|
|
104
|
-
|
|
109
|
+
if (DATA_ONLY.has(base)) return;
|
|
110
|
+
const at = rest.findIndex((t) => basename(t) === "git" || SHELLS.has(basename(t)));
|
|
111
|
+
if (at >= 0) visitSegment(rest.slice(at), into);
|
|
112
|
+
else {
|
|
113
|
+
const split = splitString([base, ...rest]);
|
|
114
|
+
if (split !== undefined) merge(into, resolveGit(split));
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The command line `env -S 'git push'` / `--split-string` carries, if there is one. */
|
|
119
|
+
function splitString(rest: readonly string[]): string | undefined {
|
|
120
|
+
for (const [k, t] of rest.entries()) {
|
|
121
|
+
if (t === "-S" || t === "--split-string") return rest[k + 1];
|
|
122
|
+
if (t.startsWith("--split-string=")) return t.slice("--split-string=".length);
|
|
123
|
+
if (/^-S./.test(t)) return t.slice(2);
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
105
126
|
}
|
|
106
127
|
|
|
107
128
|
function merge(into: Collector, other: GitResolution): void {
|
|
@@ -131,6 +152,14 @@ export function resolveGit(command: string): GitResolution {
|
|
|
131
152
|
return into;
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
/**
|
|
156
|
+
* An alias defined in this very command (`git -c alias.p=push p`) and then run: it may be any command,
|
|
157
|
+
* a push or a commit included. An alias from the user's own configuration is not this command's doing.
|
|
158
|
+
*/
|
|
159
|
+
export const runsDefinedAlias = (command: string, resolution: GitResolution): boolean =>
|
|
160
|
+
/\balias\./i.test(command) &&
|
|
161
|
+
resolution.invocations.some((g) => !(g.sub.startsWith("$") || GIT_SUBCOMMANDS.has(g.sub)));
|
|
162
|
+
|
|
134
163
|
/** Whether something unreadable in the command may be running `git <subcommand>`. */
|
|
135
164
|
export const opaqueMentions = (resolution: GitResolution, subcommand: string): boolean =>
|
|
136
165
|
resolution.opaque.includes(subcommand);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { hasRationaleBody, parseConventionalCommit } from "./commit-message.ts";
|
|
2
|
+
|
|
3
|
+
/** Why a commit message fails the repository's rule (Conventional subject, a body saying why), or undefined. */
|
|
4
|
+
export function messageProblem(message: string): string | undefined {
|
|
5
|
+
const parsed = parseConventionalCommit(message);
|
|
6
|
+
if (!parsed.ok) return parsed.error.message;
|
|
7
|
+
return hasRationaleBody(message)
|
|
8
|
+
? undefined
|
|
9
|
+
: "the message has no body explaining why the change was made";
|
|
10
|
+
}
|
package/src/core/push-command.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
|
-
import { opaqueMentions, resolveGit } from "./git-invocations.ts";
|
|
1
|
+
import { opaqueMentions, resolveGit, runsDefinedAlias } 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
|
+
/** Local refs the refspecs push (`HEAD` in `HEAD:feature`); empty means the current branch. */
|
|
8
|
+
readonly sources: readonly string[];
|
|
7
9
|
readonly allBranches: boolean;
|
|
8
10
|
/** The refspecs name `HEAD`, so the current branch is pushed too. */
|
|
9
11
|
readonly usesHead: boolean;
|
|
10
12
|
/** `--tags` with no refspec: only tags are pushed, no branch. */
|
|
11
13
|
readonly tagsOnly: boolean;
|
|
14
|
+
/** `--tags` beside named refspecs: every tag goes too, and a tag may sit on a commit no named branch has. */
|
|
15
|
+
readonly withTags?: boolean;
|
|
16
|
+
/** Every refspec deletes a ref (`--delete`, `:branch`): nothing is sent, so no commit is published. */
|
|
17
|
+
readonly deleteOnly: boolean;
|
|
12
18
|
/** Where the push runs when `cd`/`git -C` moved it away from the session's directory. */
|
|
13
19
|
readonly dir?: string | undefined;
|
|
14
20
|
};
|
|
@@ -20,6 +26,11 @@ const isDynamic = (refspec: string): boolean => /[$`*?[]/.test(refspec);
|
|
|
20
26
|
const isDryRun = (args: readonly string[]): boolean =>
|
|
21
27
|
args.some((a) => a === "--dry-run" || a === "-n" || /^-[a-zA-Z]*n[a-zA-Z]*$/.test(a));
|
|
22
28
|
|
|
29
|
+
const source = (refspec: string): string =>
|
|
30
|
+
refspec.includes(":")
|
|
31
|
+
? refspec.slice(0, refspec.indexOf(":")).replace(/^\+/, "")
|
|
32
|
+
: refspec.replace(/^\+/, "");
|
|
33
|
+
|
|
23
34
|
const destination = (refspec: string): string => {
|
|
24
35
|
const dst = refspec.includes(":") ? refspec.slice(refspec.lastIndexOf(":") + 1) : refspec;
|
|
25
36
|
return dst.replace(/^\+/, "").replace(/^(?:refs\/)?heads\//, "");
|
|
@@ -29,6 +40,7 @@ type ParsedArgs = {
|
|
|
29
40
|
positional: string[];
|
|
30
41
|
all: boolean;
|
|
31
42
|
tags: boolean;
|
|
43
|
+
deleting: boolean;
|
|
32
44
|
repo: string | undefined;
|
|
33
45
|
};
|
|
34
46
|
|
|
@@ -38,11 +50,18 @@ function applyFlag(parsed: ParsedArgs, a: string): void {
|
|
|
38
50
|
if (a.startsWith(VALUE_PREFIX)) parsed.repo = a.slice(VALUE_PREFIX.length);
|
|
39
51
|
else if (a === "--all" || a === "--mirror") parsed.all = true;
|
|
40
52
|
else if (a === "--tags") parsed.tags = true;
|
|
53
|
+
else if (a === "--delete" || a === "-d") parsed.deleting = true;
|
|
41
54
|
else if (!a.startsWith("-")) parsed.positional.push(a);
|
|
42
55
|
}
|
|
43
56
|
|
|
44
57
|
function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
45
|
-
const parsed: ParsedArgs = {
|
|
58
|
+
const parsed: ParsedArgs = {
|
|
59
|
+
positional: [],
|
|
60
|
+
all: false,
|
|
61
|
+
tags: false,
|
|
62
|
+
deleting: false,
|
|
63
|
+
repo: undefined,
|
|
64
|
+
};
|
|
46
65
|
let valueFor: "repo" | "skip" | undefined;
|
|
47
66
|
for (const a of args) {
|
|
48
67
|
if (valueFor !== undefined) {
|
|
@@ -56,7 +75,7 @@ function positionalArgs(args: readonly string[]): ParsedArgs {
|
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
const toTarget = (args: readonly string[], dir: string | undefined): PushTarget => {
|
|
59
|
-
const { positional, all, tags, repo } = positionalArgs(args);
|
|
78
|
+
const { positional, all, tags, deleting, repo } = positionalArgs(args);
|
|
60
79
|
// With --repo, every positional argument is a refspec.
|
|
61
80
|
const [remote, ...refspecs] = repo === undefined ? positional : [repo, ...positional];
|
|
62
81
|
const destinations = refspecs.map(destination);
|
|
@@ -64,9 +83,23 @@ const toTarget = (args: readonly string[], dir: string | undefined): PushTarget
|
|
|
64
83
|
return {
|
|
65
84
|
remote,
|
|
66
85
|
branches: destinations.filter(named),
|
|
67
|
-
|
|
86
|
+
// A source that starts with "-" would be read as an option by `git log`; it is never a ref name.
|
|
87
|
+
sources: refspecs.map(source).filter((r) => r !== "" && !isDynamic(r) && !r.startsWith("-")),
|
|
88
|
+
// A destination or a source built by the shell (`$SHA:refs/heads/x`) could be any ref: check them all.
|
|
89
|
+
// `:` and `+:` push every branch that exists on both sides (the "matching" refspec).
|
|
90
|
+
allBranches:
|
|
91
|
+
all ||
|
|
92
|
+
destinations.some(isDynamic) ||
|
|
93
|
+
refspecs.map(source).some(isDynamic) ||
|
|
94
|
+
refspecs.some((r) => r.replace(/^\+/, "") === ":"),
|
|
68
95
|
usesHead: destinations.includes("HEAD"),
|
|
69
96
|
tagsOnly: tags && refspecs.length === 0 && !all,
|
|
97
|
+
// `--tags` sends every tag as well, so a push with it deletes nothing only.
|
|
98
|
+
deleteOnly:
|
|
99
|
+
refspecs.length > 0 &&
|
|
100
|
+
!tags &&
|
|
101
|
+
(deleting || refspecs.every((r) => r.startsWith(":") && r !== ":")),
|
|
102
|
+
...(tags && refspecs.length > 0 && !all ? { withTags: true } : {}),
|
|
70
103
|
...(dir === undefined ? {} : { dir }),
|
|
71
104
|
};
|
|
72
105
|
};
|
|
@@ -77,17 +110,19 @@ export function pushTargets(command: string): PushTarget[] {
|
|
|
77
110
|
const targets = resolution.invocations.flatMap((g) =>
|
|
78
111
|
g.sub === "push" && !isDryRun(g.args) ? [toTarget(g.args, g.dir)] : [],
|
|
79
112
|
);
|
|
80
|
-
// git run through something opaque (xargs, `$CMD
|
|
81
|
-
const opaquePush = opaqueMentions(resolution, "push");
|
|
113
|
+
// git run through something opaque (xargs, `$CMD`, an alias): a push cannot be ruled out, so assume the worst.
|
|
114
|
+
const opaquePush = opaqueMentions(resolution, "push") || runsDefinedAlias(command, resolution);
|
|
82
115
|
return opaquePush
|
|
83
116
|
? [
|
|
84
117
|
...targets,
|
|
85
118
|
{
|
|
86
119
|
remote: undefined,
|
|
87
120
|
branches: [],
|
|
121
|
+
sources: [],
|
|
88
122
|
allBranches: true,
|
|
89
123
|
usesHead: false,
|
|
90
124
|
tagsOnly: false,
|
|
125
|
+
deleteOnly: false,
|
|
91
126
|
dir: undefined,
|
|
92
127
|
},
|
|
93
128
|
]
|
|
@@ -6,14 +6,10 @@ import {
|
|
|
6
6
|
isToolCallEventType,
|
|
7
7
|
} from "@earendil-works/pi-coding-agent";
|
|
8
8
|
import { type CommitExtraction, extractCommits } from "../core/commit-command.ts";
|
|
9
|
-
import {
|
|
10
|
-
findForbiddenTrailerKeys,
|
|
11
|
-
findForbiddenTrailers,
|
|
12
|
-
hasRationaleBody,
|
|
13
|
-
parseConventionalCommit,
|
|
14
|
-
} from "../core/commit-message.ts";
|
|
9
|
+
import { findForbiddenTrailerKeys, findForbiddenTrailers } from "../core/commit-message.ts";
|
|
15
10
|
import type { Exec } from "../core/exec.ts";
|
|
16
11
|
import { resolveGit } from "../core/git-invocations.ts";
|
|
12
|
+
import { messageProblem } from "../core/message-problem.ts";
|
|
17
13
|
import { DECISION_LOG, reviewGap } from "../core/review-flow.ts";
|
|
18
14
|
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
19
15
|
import type { Jev } from "../jev/client.ts";
|
|
@@ -22,12 +18,15 @@ import { judgeCommit, MIX_THRESHOLD, RATIONALE_FLOOR } from "../jev/questions/co
|
|
|
22
18
|
import { snapshotDiff } from "../review/digest.ts";
|
|
23
19
|
import type { SessionState } from "../state/session-state.ts";
|
|
24
20
|
import { departureUse } from "./departure-use.ts";
|
|
21
|
+
import type { ExcusedMessages } from "./excused-messages.ts";
|
|
25
22
|
|
|
26
23
|
export type CommitGuardDeps = {
|
|
27
24
|
pi: ExtensionAPI;
|
|
28
25
|
state: SessionState;
|
|
29
26
|
jev: (ctx: ExtensionContext) => Jev;
|
|
30
27
|
exec: Exec;
|
|
28
|
+
/** Messages whose missing rationale a departure excused here, so the push does not ask again. */
|
|
29
|
+
excused?: ExcusedMessages | undefined;
|
|
31
30
|
};
|
|
32
31
|
|
|
33
32
|
type Need = { gate: GateId; why: string };
|
|
@@ -66,11 +65,8 @@ const messageOf = (extracted: CommitExtraction, cwd: string): string | undefined
|
|
|
66
65
|
|
|
67
66
|
/** Deterministic checks: Conventional subject and a prose rationale body. */
|
|
68
67
|
function messageNeeds(message: string): Need[] {
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
return hasRationaleBody(message)
|
|
72
|
-
? []
|
|
73
|
-
: [{ gate: RATIONALE, why: "the message has no body explaining why the change was made" }];
|
|
68
|
+
const problem = messageProblem(message);
|
|
69
|
+
return problem === undefined ? [] : [{ gate: RATIONALE, why: problem }];
|
|
74
70
|
}
|
|
75
71
|
|
|
76
72
|
/** `-a`/`--all`, or a `git add` in the same command, widen the commit beyond what is staged. */
|
|
@@ -361,6 +357,12 @@ async function needsOf(
|
|
|
361
357
|
return [...needs, ...viaJev.filter((n) => !needs.some((m) => m.gate === n.gate))];
|
|
362
358
|
}
|
|
363
359
|
|
|
360
|
+
/** A message let through under a rationale departure is remembered, so its push is not asked again. */
|
|
361
|
+
function rememberExcused(deps: CommitGuardDeps, needs: readonly Need[], messages: string[]): void {
|
|
362
|
+
if (!needs.some((n) => n.gate === RATIONALE)) return;
|
|
363
|
+
for (const message of messages) deps.excused?.add(message);
|
|
364
|
+
}
|
|
365
|
+
|
|
364
366
|
/** Soft gates on `git commit`: rationale, Conventional shape, structural/behavioural separation; AI trailers are refused. */
|
|
365
367
|
export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
366
368
|
deps.pi.on("tool_call", async (event, ctx) => {
|
|
@@ -397,6 +399,7 @@ export function registerCommitGuard(deps: CommitGuardDeps): void {
|
|
|
397
399
|
const uncovered = uses.find(({ use }) => !use.hasOpen());
|
|
398
400
|
if (uncovered !== undefined) return { block: true, reason: blockReason(uncovered.need) };
|
|
399
401
|
for (const { use } of uses) use.consume();
|
|
402
|
+
rememberExcused(deps, all, known);
|
|
400
403
|
return undefined;
|
|
401
404
|
});
|
|
402
405
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Commit messages whose missing rationale a recorded departure already excused at `git commit`, so the
|
|
3
|
+
* push that publishes them does not ask for a second departure. In memory: a restart asks again, which
|
|
4
|
+
* only costs a repeat departure.
|
|
5
|
+
*/
|
|
6
|
+
export type ExcusedMessages = {
|
|
7
|
+
add(message: string): void;
|
|
8
|
+
has(message: string): boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
const key = (message: string): string => message.trim();
|
|
12
|
+
|
|
13
|
+
export function createExcusedMessages(): ExcusedMessages {
|
|
14
|
+
const seen = new Set<string>();
|
|
15
|
+
return {
|
|
16
|
+
add: (message) => {
|
|
17
|
+
seen.add(key(message));
|
|
18
|
+
},
|
|
19
|
+
has: (message) => seen.has(key(message)),
|
|
20
|
+
};
|
|
21
|
+
}
|
package/src/gates/push-guard.ts
CHANGED
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
import { extractCommits } from "../core/commit-command.ts";
|
|
9
9
|
import { parseConventionalCommit } from "../core/commit-message.ts";
|
|
10
10
|
import type { Exec } from "../core/exec.ts";
|
|
11
|
+
import { resolveGit, runsDefinedAlias } from "../core/git-invocations.ts";
|
|
11
12
|
import { type PushTarget, pushTargets } from "../core/push-command.ts";
|
|
12
13
|
import { type GateId, isParseError, parseGateId } from "../core/types.ts";
|
|
13
14
|
import type { Jev } from "../jev/client.ts";
|
|
@@ -16,6 +17,9 @@ import { getFailureLog, getTrunkStatus } from "../state/ci.ts";
|
|
|
16
17
|
import { type DevsysConfig, loadConfig } from "../state/config.ts";
|
|
17
18
|
import type { SessionState } from "../state/session-state.ts";
|
|
18
19
|
import { type ApprovalStore, requestHardStop } from "./approvals.ts";
|
|
20
|
+
import { departureUse } from "./departure-use.ts";
|
|
21
|
+
import type { ExcusedMessages } from "./excused-messages.ts";
|
|
22
|
+
import { type PushMessageFindings, pushMessageFindings } from "./push-messages.ts";
|
|
19
23
|
|
|
20
24
|
export type PushGuardDeps = {
|
|
21
25
|
pi: ExtensionAPI;
|
|
@@ -24,6 +28,8 @@ export type PushGuardDeps = {
|
|
|
24
28
|
jev: (ctx: ExtensionContext) => Jev;
|
|
25
29
|
exec: Exec;
|
|
26
30
|
now?: () => Date;
|
|
31
|
+
/** Messages `git commit` already excused by a departure; shared with the commit guard. */
|
|
32
|
+
excused?: ExcusedMessages | undefined;
|
|
27
33
|
};
|
|
28
34
|
|
|
29
35
|
type Verdict = { readonly block: true; readonly reason: string } | undefined;
|
|
@@ -36,6 +42,75 @@ const gate = (id: string): GateId => {
|
|
|
36
42
|
const FIX_DIFF_LIMIT = 8000;
|
|
37
43
|
const RED_TRUNK = gate("push.red-trunk");
|
|
38
44
|
const DELIVERY_MODE = gate("push.delivery-mode");
|
|
45
|
+
const RATIONALE = gate("commit.rationale");
|
|
46
|
+
|
|
47
|
+
const COMMIT_MAKERS = new Set(["cherry-pick", "merge", "revert", "am", "commit-tree"]);
|
|
48
|
+
|
|
49
|
+
/** Flags that stop a commit-maker from writing a commit: a fast-forward, a staged-only result, or an abandoned run. */
|
|
50
|
+
const NO_COMMIT_FLAGS: Readonly<Record<string, readonly string[]>> = {
|
|
51
|
+
merge: ["--ff-only", "--abort", "--quit", "--squash", "--no-commit"],
|
|
52
|
+
"cherry-pick": ["--abort", "--quit", "-n", "--no-commit"],
|
|
53
|
+
revert: ["--abort", "--quit", "-n", "--no-commit"],
|
|
54
|
+
am: ["--abort", "--quit"],
|
|
55
|
+
};
|
|
56
|
+
const makesNoCommit = (g: { readonly sub: string; readonly args: readonly string[] }): boolean =>
|
|
57
|
+
g.args.some((a) => NO_COMMIT_FLAGS[g.sub]?.includes(a) === true);
|
|
58
|
+
|
|
59
|
+
/** A push in the same call as commits whose messages the commit guard never reads (`commit -C`, `cherry-pick`). */
|
|
60
|
+
const createsUnseenCommits = (command: string): boolean => {
|
|
61
|
+
const { invocations } = resolveGit(command);
|
|
62
|
+
return (
|
|
63
|
+
invocations.some((g) => COMMIT_MAKERS.has(g.sub) && !makesNoCommit(g)) ||
|
|
64
|
+
runsDefinedAlias(command, resolveGit(command)) ||
|
|
65
|
+
invocations.some(
|
|
66
|
+
(g) =>
|
|
67
|
+
g.sub === "commit" &&
|
|
68
|
+
g.args.some((a) => expandsAtRunTime(a) && !command.includes(`'${a}'`)),
|
|
69
|
+
) ||
|
|
70
|
+
extractCommits(command).some((e) => commitIsUnseen(command, e))
|
|
71
|
+
);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** `$(cat <<'EOF' …)` is the message itself (its body is read); any other `$(…)`, backtick or variable is only known once the call runs. */
|
|
75
|
+
const expandsAtRunTime = (arg: string): boolean =>
|
|
76
|
+
/\$\(|`|\$\{?\w/.test(
|
|
77
|
+
arg.replace(/\$\(\s*cat\s+#HD\d+\s*\)/g, "").replace(/\\[\s\S]/g, ""), // an escaped `\`` or `\$` is literal text
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
/** A commit whose message the guard read before the call ran may still change: a `-F` file the same call writes. */
|
|
81
|
+
const commitIsUnseen = (command: string, e: ReturnType<typeof extractCommits>[number]): boolean => {
|
|
82
|
+
if (e.kind === "unknown") return true;
|
|
83
|
+
if (e.kind !== "file") return false;
|
|
84
|
+
// Stdin, or a path the shell expands (`~/msg`), is a message the commit guard never read.
|
|
85
|
+
if (e.path === "-" || /^~|[$`]/.test(e.path)) return true;
|
|
86
|
+
return command.split(e.path).length > 2;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const forbiddenVerdict = (found: PushMessageFindings): Verdict =>
|
|
90
|
+
found.forbidden.length === 0
|
|
91
|
+
? undefined
|
|
92
|
+
: {
|
|
93
|
+
block: true,
|
|
94
|
+
reason:
|
|
95
|
+
`commit.forbidden-trailer: this push would publish a commit that carries an AI attribution (${found.forbidden.join("; ")}). ` +
|
|
96
|
+
"Commits in this repository have no Co-Authored-By or generated-by trailers and this is not something to depart from. " +
|
|
97
|
+
"Remove the trailer from the unpushed commit, then push again.",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A commit with no rationale needs the departure `git commit` would have asked for. Checked before any
|
|
102
|
+
* approval is asked (a refusal after the user approved would ask them again); spent only once the push
|
|
103
|
+
* is otherwise allowed.
|
|
104
|
+
*/
|
|
105
|
+
const rationaleOwed = (deps: PushGuardDeps, found: PushMessageFindings): boolean =>
|
|
106
|
+
found.unexplained !== undefined && !departureUse(deps.state, RATIONALE).hasOpen();
|
|
107
|
+
|
|
108
|
+
const rationaleRefusal = (found: PushMessageFindings): Verdict => ({
|
|
109
|
+
block: true,
|
|
110
|
+
reason:
|
|
111
|
+
`commit.rationale: this push would publish a commit that fails the message rule (${found.unexplained}). ` +
|
|
112
|
+
"Improve the unpushed message, or record a departure with devsys_record_departure (gate commit.rationale) and push again.",
|
|
113
|
+
});
|
|
39
114
|
|
|
40
115
|
async function currentBranch(exec: Exec, cwd: string): Promise<string | undefined> {
|
|
41
116
|
try {
|
|
@@ -147,6 +222,48 @@ async function hardStop(deps: PushGuardDeps, stop: HardStop): Promise<Verdict> {
|
|
|
147
222
|
};
|
|
148
223
|
}
|
|
149
224
|
|
|
225
|
+
type PushCall = { readonly toolCallId: string; readonly input: { readonly command: string } };
|
|
226
|
+
|
|
227
|
+
/** Delivery-mode and red-trunk hard stops for a push onto the trunk. */
|
|
228
|
+
async function trunkVerdict(
|
|
229
|
+
deps: PushGuardDeps,
|
|
230
|
+
ctx: ExtensionContext,
|
|
231
|
+
event: PushCall,
|
|
232
|
+
config: DevsysConfig,
|
|
233
|
+
targets: readonly PushTarget[],
|
|
234
|
+
): Promise<Verdict> {
|
|
235
|
+
const { mode, trunk } = config.delivery;
|
|
236
|
+
if (!(await pushesTrunk(deps.exec, ctx.cwd, targets, trunk))) return undefined;
|
|
237
|
+
const command = event.input.command;
|
|
238
|
+
if (mode === "pull-request") {
|
|
239
|
+
return hardStop(deps, {
|
|
240
|
+
ctx,
|
|
241
|
+
toolCallId: event.toolCallId,
|
|
242
|
+
command,
|
|
243
|
+
gate: DELIVERY_MODE,
|
|
244
|
+
why: `delivery.mode is "pull-request" but this pushes ${trunk}`,
|
|
245
|
+
costIfWrong: "unreviewed work lands directly on the trunk",
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const status = await getTrunkStatus(deps.exec, { branch: trunk, cwd: ctx.cwd });
|
|
249
|
+
if (status.status !== "red") return undefined;
|
|
250
|
+
if (extractCommits(command).length > 0) {
|
|
251
|
+
return {
|
|
252
|
+
block: true,
|
|
253
|
+
reason: `CI on ${trunk} is red and this call commits and pushes together, so the push cannot be judged as a fix for it. Commit first in one call, then push in the next.`,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
if (await repairsRedTrunk(deps, ctx, config, status.runId)) return undefined;
|
|
257
|
+
return hardStop(deps, {
|
|
258
|
+
ctx,
|
|
259
|
+
toolCallId: event.toolCallId,
|
|
260
|
+
command,
|
|
261
|
+
gate: RED_TRUNK,
|
|
262
|
+
why: `CI on ${trunk} is red (${status.headSha?.slice(0, 7) ?? "unknown sha"}) and this push is not a recognised fix for it`,
|
|
263
|
+
costIfWrong: "unrelated work piles onto a broken build and hides the failure",
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
|
|
150
267
|
/** Delivery-mode and red-trunk hard stops on `git push`; records when a push last succeeded. */
|
|
151
268
|
export function registerPushGuard(deps: PushGuardDeps): void {
|
|
152
269
|
deps.pi.on("tool_call", async (event, ctx) => {
|
|
@@ -157,42 +274,28 @@ export function registerPushGuard(deps: PushGuardDeps): void {
|
|
|
157
274
|
if (!loaded.ok) {
|
|
158
275
|
return { block: true, reason: `cannot read the delivery policy: ${loaded.error.message}` };
|
|
159
276
|
}
|
|
160
|
-
|
|
161
|
-
if (mode === "local-only") {
|
|
277
|
+
if (loaded.value.delivery.mode === "local-only") {
|
|
162
278
|
return {
|
|
163
279
|
block: true,
|
|
164
280
|
reason: `delivery.mode is "local-only" in .development-system.toml: nothing is pushed. Change the mode with the user if this should be published.`,
|
|
165
281
|
};
|
|
166
282
|
}
|
|
167
|
-
if (
|
|
168
|
-
const command = event.input.command;
|
|
169
|
-
if (mode === "pull-request") {
|
|
170
|
-
return hardStop(deps, {
|
|
171
|
-
ctx,
|
|
172
|
-
toolCallId: event.toolCallId,
|
|
173
|
-
command,
|
|
174
|
-
gate: DELIVERY_MODE,
|
|
175
|
-
why: `delivery.mode is "pull-request" but this pushes ${trunk}`,
|
|
176
|
-
costIfWrong: "unreviewed work lands directly on the trunk",
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
const status = await getTrunkStatus(deps.exec, { branch: trunk, cwd: ctx.cwd });
|
|
180
|
-
if (status.status !== "red") return undefined;
|
|
181
|
-
if (extractCommits(command).length > 0) {
|
|
283
|
+
if (createsUnseenCommits(event.input.command)) {
|
|
182
284
|
return {
|
|
183
285
|
block: true,
|
|
184
|
-
reason:
|
|
286
|
+
reason:
|
|
287
|
+
"this call creates commits (cherry-pick, merge, revert, am, commit-tree or a commit with a message the guard cannot read) and pushes them together, so their messages cannot be checked first. Make the commits in one call, then push in the next.",
|
|
185
288
|
};
|
|
186
289
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
290
|
+
// Read-only first: an AI trailer blocks before any approval or departure is spent.
|
|
291
|
+
const found = await pushMessageFindings(deps.exec, ctx.cwd, targets, deps.excused);
|
|
292
|
+
const refused = found === undefined ? undefined : forbiddenVerdict(found);
|
|
293
|
+
if (refused !== undefined) return refused;
|
|
294
|
+
if (found !== undefined && rationaleOwed(deps, found)) return rationaleRefusal(found);
|
|
295
|
+
const stopped = await trunkVerdict(deps, ctx, event, loaded.value, targets);
|
|
296
|
+
if (stopped !== undefined) return stopped;
|
|
297
|
+
if (found?.unexplained !== undefined) departureUse(deps.state, RATIONALE).consume();
|
|
298
|
+
return undefined;
|
|
196
299
|
});
|
|
197
300
|
|
|
198
301
|
deps.pi.on("tool_result", (event) => {
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { findForbiddenTrailerKeys, findForbiddenTrailers } from "../core/commit-message.ts";
|
|
3
|
+
import type { Exec } from "../core/exec.ts";
|
|
4
|
+
import { messageProblem } from "../core/message-problem.ts";
|
|
5
|
+
import type { PushTarget } from "../core/push-command.ts";
|
|
6
|
+
import type { ExcusedMessages } from "./excused-messages.ts";
|
|
7
|
+
|
|
8
|
+
type UnpushedCommit = { readonly merge: boolean; readonly message: string };
|
|
9
|
+
|
|
10
|
+
/** What is wrong with the messages of the commits a push would publish. */
|
|
11
|
+
export type PushMessageFindings = {
|
|
12
|
+
/** AI-attribution trailers (non-negotiable 8): never a departure. */
|
|
13
|
+
readonly forbidden: readonly string[];
|
|
14
|
+
/** Why the first non-merge commit needs a `commit.rationale` departure, if one does. */
|
|
15
|
+
readonly unexplained: string | undefined;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/** git prints these bytes for `%x1f` / `%x00`; the bytes themselves cannot be passed as an argument. */
|
|
19
|
+
const FIELD = "\u001f";
|
|
20
|
+
const RECORD = "\u0000";
|
|
21
|
+
|
|
22
|
+
/** `git log <ref> --not --remotes`: what that ref has that no remote-tracking branch has (upstream commits pulled into a fork are not this push's to judge). */
|
|
23
|
+
async function unpushedCommits(
|
|
24
|
+
exec: Exec,
|
|
25
|
+
cwd: string,
|
|
26
|
+
ref: string,
|
|
27
|
+
): Promise<UnpushedCommit[] | undefined> {
|
|
28
|
+
const log = await exec("git", ["log", ref, "--not", "--remotes", "--format=%P%x1f%B%x00", "--"], {
|
|
29
|
+
cwd,
|
|
30
|
+
timeout: 15_000,
|
|
31
|
+
});
|
|
32
|
+
if (log.code !== 0) return undefined; // a ref git cannot resolve: the push itself will fail
|
|
33
|
+
return log.stdout
|
|
34
|
+
.split(RECORD)
|
|
35
|
+
.filter((record) => record.trim() !== "")
|
|
36
|
+
.map((record) => {
|
|
37
|
+
const [parents = "", ...message] = record.replace(/^\n/, "").split(FIELD);
|
|
38
|
+
return { merge: parents.trim().split(/\s+/).length > 1, message: message.join(FIELD) };
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** No remote-tracking branch at all (a first push): every commit looks unpushed, so only AI trailers are judged, never the rationale. */
|
|
43
|
+
async function knowsRemotes(exec: Exec, cwd: string): Promise<boolean> {
|
|
44
|
+
const refs = await exec("git", ["for-each-ref", "--count=1", "refs/remotes"], {
|
|
45
|
+
cwd,
|
|
46
|
+
timeout: 5000,
|
|
47
|
+
});
|
|
48
|
+
return refs.code === 0 && refs.stdout.trim() !== "";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The refs a push sends: the named branches, HEAD for a bare push, every branch for `--all`. */
|
|
52
|
+
const refsOf = (t: PushTarget): string[] => {
|
|
53
|
+
if (t.allBranches) return ["--branches"];
|
|
54
|
+
if (t.tagsOnly) return ["--tags"];
|
|
55
|
+
const named = t.sources.length === 0 ? ["HEAD"] : [...t.sources];
|
|
56
|
+
return t.withTags === true ? [...named, "--tags"] : named;
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The messages of the commits the push would publish (what each target's refs have that its remote
|
|
61
|
+
* lacks), however each was made: `-C`, `--fixup`, `commit-tree`, `merge -m`, an alias. Read-only.
|
|
62
|
+
* Undefined when git cannot say (unknown ref). With no remote-tracking branch yet, only trailers are judged.
|
|
63
|
+
*/
|
|
64
|
+
export async function pushMessageFindings(
|
|
65
|
+
exec: Exec,
|
|
66
|
+
cwd: string,
|
|
67
|
+
targets: readonly PushTarget[],
|
|
68
|
+
excused: ExcusedMessages | undefined,
|
|
69
|
+
): Promise<PushMessageFindings | undefined> {
|
|
70
|
+
const commits: UnpushedCommit[] = [];
|
|
71
|
+
const unjudgedForRationale = new Set<UnpushedCommit>();
|
|
72
|
+
let judged = false;
|
|
73
|
+
for (const t of targets.filter((x) => !x.deleteOnly)) {
|
|
74
|
+
const where = t.dir === undefined ? cwd : resolve(cwd, t.dir);
|
|
75
|
+
const firstPush = !(await knowsRemotes(exec, where));
|
|
76
|
+
for (const ref of refsOf(t)) {
|
|
77
|
+
const found = await unpushedCommits(exec, where, ref);
|
|
78
|
+
if (found === undefined) continue;
|
|
79
|
+
judged = true;
|
|
80
|
+
commits.push(...found);
|
|
81
|
+
if (firstPush) for (const c of found) unjudgedForRationale.add(c);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
if (!judged) return undefined;
|
|
85
|
+
const forbidden = commits.flatMap((c) => [
|
|
86
|
+
...findForbiddenTrailers(c.message),
|
|
87
|
+
...findForbiddenTrailerKeys(c.message),
|
|
88
|
+
]);
|
|
89
|
+
const unexplained = commits
|
|
90
|
+
.filter((c) => !(c.merge || unjudgedForRationale.has(c)) && excused?.has(c.message) !== true)
|
|
91
|
+
.map((c) => messageProblem(c.message))
|
|
92
|
+
.find((problem) => problem !== undefined);
|
|
93
|
+
return { forbidden: [...new Set(forbidden)], unexplained };
|
|
94
|
+
}
|