@kaynooo/guard-tools 0.2.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +3 -2
- package/dist/cli.js +506 -39
- package/package.json +7 -6
package/LICENSE
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
MIT License
|
|
2
2
|
|
|
3
|
-
Copyright (c) 2026 Kaynooo <kaynooo
|
|
3
|
+
Copyright (c) 2026 Kaynooo <contact@kaynooo.fr>
|
|
4
4
|
|
|
5
5
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
6
|
of this software and associated documentation files (the "Software"), to deal
|
package/README.md
CHANGED
|
@@ -13,11 +13,12 @@ guard check --json --cwd "$PWD" - < command.txt
|
|
|
13
13
|
| Rule | Refuses | Lives in |
|
|
14
14
|
| --- | --- | --- |
|
|
15
15
|
| `remote` | `ssh`, `scp`, `sftp`, `mosh`, `rsync` to a host, `DOCKER_HOST=ssh://`, `docker -H ssh://` | `src/remote.ts` |
|
|
16
|
-
| `git` | `git stash` (pointing at `base`), `git add -A`/`.`/`-u`, `git commit -a` in any flag cluster, `git push` the user did not ask for, `git push --tags` without `--atomic` | `src/git.ts` |
|
|
17
|
-
| `commit` | a commit message breaking the format: no Conventional Commit title, a trailer, a body over two lines, a bullet list, a hard wrap; `-m`, `-F <file>` and `-F -` with a heredoc are read | `src/commit.ts`, the validator in `@kaynooo/git-tools` |
|
|
16
|
+
| `git` | `git stash` (pointing at `proof`, `base`, a commit first or `git worktree add`), `git add -A`/`.`/`-u`, `git commit -a` in any flag cluster, `git push` the user did not ask for, `git push --tags` without `--atomic` | `src/git.ts` |
|
|
17
|
+
| `commit` | a commit message breaking the format: no Conventional Commit title, a trailer, a body over two lines, a bullet list, a hard wrap; `-m`, `-F <file>` and `-F -` with a heredoc are read; the refusal spells the rule out and points at `gc` | `src/commit.ts`, the validator in `@kaynooo/git-tools` |
|
|
18
18
|
| `sql` | a SQL client, console or dump tool fed a write (INSERT, UPDATE, DELETE, DDL, migrations, fixtures) outside a test database | `@kaynooo/db-tools` (`src/clients.ts`, `src/guard.ts`) |
|
|
19
19
|
| `env` | a command reading, writing or printing an env file or the whole environment; `guard path` refuses a file tool opening one | `@kaynooo/env-tools` (`src/guard.ts`) |
|
|
20
20
|
| `package-manager` | an install with another manager than the nearest lockfile pins, when it cannot be translated | `@kaynooo/pkg-tools` (`src/pin.ts`) |
|
|
21
|
+
| `edit` | when `sub` is installed, a `sed -i` and a python, bun or node script replacing text in a file (`-c`, `-e` or a heredoc), handing over the `sub` command doing the same where its `s` commands or `.replace(...)` calls take literals | `src/replace.ts` |
|
|
21
22
|
|
|
22
23
|
Every rule reads the command with one shell parser (`packages/kit/src/shell.ts`): quotes, heredoc bodies (text, never commands), `env`/`sudo`/`command` wrappers and subshells.
|
|
23
24
|
|
package/dist/cli.js
CHANGED
|
@@ -340,13 +340,39 @@ class Completing {
|
|
|
340
340
|
var completion = { active: false };
|
|
341
341
|
var OPTIONAL = /\[[^\]]+\]/g;
|
|
342
342
|
var USAGE_PREFIX = /^usage:\s*/i;
|
|
343
|
+
var FULL_STOP = /\.$/;
|
|
343
344
|
function usageLine(usage, colors = paint(process3.stdout)) {
|
|
344
345
|
return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
|
|
345
346
|
}
|
|
346
|
-
function helpText(usage, options, colors = paint(process3.stdout)) {
|
|
347
|
-
return [usageLine(usage, colors), "", colors.bold("Options:"), ...optionRows(options, colors)].join(`
|
|
347
|
+
function helpText(usage, options, colors = paint(process3.stdout), { examples = [], replaces } = {}) {
|
|
348
|
+
return [usageLine(usage, colors), ...replacesRows(replaces, colors), "", colors.bold("Options:"), ...optionRows(options, colors), ...exampleRows(examples, colors)].join(`
|
|
348
349
|
`);
|
|
349
350
|
}
|
|
351
|
+
function replacesRows(replaces, colors = paint(process3.stdout)) {
|
|
352
|
+
return replaces === undefined ? [] : ["", `${colors.bold("Instead of:")} ${replaces}`];
|
|
353
|
+
}
|
|
354
|
+
function wantsHelpJson(env = process3.env) {
|
|
355
|
+
return env.KIT_HELP === "json";
|
|
356
|
+
}
|
|
357
|
+
function description(usage, { examples = [], replaces } = {}) {
|
|
358
|
+
const lines = usage.replace(USAGE_PREFIX, "").split(`
|
|
359
|
+
`);
|
|
360
|
+
const first = lines[0];
|
|
361
|
+
const colon = first.indexOf(": ");
|
|
362
|
+
const prose = lines[lines.indexOf("") + 1] ?? "";
|
|
363
|
+
const fallback = lines.includes("") ? `${prose.charAt(0).toLowerCase()}${prose.slice(1)}`.replace(FULL_STOP, "") : "";
|
|
364
|
+
return {
|
|
365
|
+
synopsis: colon === -1 ? first : first.slice(0, colon),
|
|
366
|
+
summary: colon === -1 ? fallback : first.slice(colon + 2),
|
|
367
|
+
...replaces === undefined ? {} : { replaces },
|
|
368
|
+
examples: examples.map((example) => typeof example === "string" ? { line: example } : { line: example[0], note: example[1] })
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function exampleRows(examples, colors = paint(process3.stdout)) {
|
|
372
|
+
if (examples.length === 0)
|
|
373
|
+
return [];
|
|
374
|
+
return ["", colors.bold("Examples:"), ...examples.map((example) => typeof example === "string" ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)];
|
|
375
|
+
}
|
|
350
376
|
function optionRows(options, colors = paint(process3.stdout)) {
|
|
351
377
|
const rows = Object.entries(options).map(([name, option]) => {
|
|
352
378
|
const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
|
|
@@ -356,8 +382,27 @@ function optionRows(options, colors = paint(process3.stdout)) {
|
|
|
356
382
|
const width = Math.max(0, ...rows.map((row) => row.width));
|
|
357
383
|
return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
|
|
358
384
|
}
|
|
385
|
+
var OUTPUT_OPTIONS = {
|
|
386
|
+
color: { type: "string", description: "auto (on a terminal alone), always or never" },
|
|
387
|
+
plain: { type: "boolean", description: "plain text, no colour or spinner: --color never" }
|
|
388
|
+
};
|
|
389
|
+
function applyOutput(color, plain, env = process3.env) {
|
|
390
|
+
const when = plain === true ? "never" : color ?? "auto";
|
|
391
|
+
if (when === "auto")
|
|
392
|
+
return;
|
|
393
|
+
if (when !== "always" && when !== "never")
|
|
394
|
+
badUsage(`--color takes auto, always or never, not ${when}`);
|
|
395
|
+
if (when === "always") {
|
|
396
|
+
env.FORCE_COLOR = "1";
|
|
397
|
+
delete env.NO_COLOR;
|
|
398
|
+
} else {
|
|
399
|
+
env.NO_COLOR = "1";
|
|
400
|
+
delete env.FORCE_COLOR;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
359
403
|
function parse(args, usage, options, settings = {}) {
|
|
360
|
-
const
|
|
404
|
+
const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options)));
|
|
405
|
+
const all = { ...options, ...output, help: { type: "boolean", short: "h", description: "show this help" } };
|
|
361
406
|
if (completion.active)
|
|
362
407
|
throw new Completing(all);
|
|
363
408
|
const separator = settings.rest === true ? args.indexOf("--") : -1;
|
|
@@ -365,12 +410,14 @@ function parse(args, usage, options, settings = {}) {
|
|
|
365
410
|
const rest = separator === -1 ? undefined : args.slice(separator + 1);
|
|
366
411
|
let parsed;
|
|
367
412
|
try {
|
|
368
|
-
parsed = parseArgs({ args: own, options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
|
|
413
|
+
parsed = parseArgs({ args: joinDashValues(own, all), options: all, allowPositionals: true, allowNegative: settings.allowNegative === true, strict: true });
|
|
369
414
|
} catch (error) {
|
|
370
415
|
badUsage(parseError(error));
|
|
371
416
|
}
|
|
372
|
-
|
|
373
|
-
|
|
417
|
+
const values = parsed.values;
|
|
418
|
+
applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
|
|
419
|
+
if (values.help === true) {
|
|
420
|
+
console.log(wantsHelpJson() ? JSON.stringify(description(usage, settings)) : helpText(usage, all, undefined, settings));
|
|
374
421
|
throw new HelpShown;
|
|
375
422
|
}
|
|
376
423
|
if (settings.positionals !== undefined)
|
|
@@ -385,6 +432,25 @@ function checkPositionals(given, names) {
|
|
|
385
432
|
if (!variadic && given.length > names.length)
|
|
386
433
|
badUsage(`unexpected argument ${given[names.length]}`);
|
|
387
434
|
}
|
|
435
|
+
function joinDashValues(args, options) {
|
|
436
|
+
const longOf = new Map(Object.entries(options).flatMap(([name, option]) => option.short === undefined ? [[`--${name}`, name]] : [[`--${name}`, name], [`-${option.short}`, name]]));
|
|
437
|
+
const isOption = (arg) => arg === "--" || longOf.has(arg.includes("=") ? arg.slice(0, arg.indexOf("=")) : arg);
|
|
438
|
+
const joined = [];
|
|
439
|
+
for (let i = 0;i < args.length; i++) {
|
|
440
|
+
const arg = args[i] ?? "";
|
|
441
|
+
if (arg === "--")
|
|
442
|
+
return [...joined, ...args.slice(i)];
|
|
443
|
+
const name = longOf.get(arg);
|
|
444
|
+
const next = args[i + 1];
|
|
445
|
+
if (name !== undefined && options[name]?.type === "string" && next !== undefined && next.startsWith("-") && next !== "-" && !isOption(next)) {
|
|
446
|
+
joined.push(`--${name}=${next}`);
|
|
447
|
+
i++;
|
|
448
|
+
} else {
|
|
449
|
+
joined.push(arg);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return joined;
|
|
453
|
+
}
|
|
388
454
|
function parseError(error) {
|
|
389
455
|
const message = upTo(error instanceof Error ? error.message : String(error), ". ");
|
|
390
456
|
return `${message.charAt(0).toLowerCase()}${message.slice(1)}`;
|
|
@@ -417,6 +483,37 @@ function distance(a, b) {
|
|
|
417
483
|
}
|
|
418
484
|
|
|
419
485
|
// ../kit/src/cli.ts
|
|
486
|
+
var LEVEL_OPTIONS = {
|
|
487
|
+
...OUTPUT_OPTIONS,
|
|
488
|
+
help: { type: "boolean", short: "h", description: "show this help" }
|
|
489
|
+
};
|
|
490
|
+
var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
|
|
491
|
+
function commandRows(commands, colors) {
|
|
492
|
+
const width = Math.max(...Object.keys(commands).map((name) => name.length));
|
|
493
|
+
const groups = new Map([["Commands:", []]]);
|
|
494
|
+
for (const [name, { summary, group }] of Object.entries(commands)) {
|
|
495
|
+
const heading = group === undefined ? "Commands:" : `${group}:`;
|
|
496
|
+
const rows = groups.get(heading) ?? [];
|
|
497
|
+
rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
|
|
498
|
+
groups.set(heading, rows);
|
|
499
|
+
}
|
|
500
|
+
return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
|
|
501
|
+
}
|
|
502
|
+
function takeOutputFlags(argv) {
|
|
503
|
+
let index = 0;
|
|
504
|
+
for (;index < argv.length; index++) {
|
|
505
|
+
const word = argv[index];
|
|
506
|
+
if (word === "--plain")
|
|
507
|
+
applyOutput(undefined, true);
|
|
508
|
+
else if (word.startsWith("--color="))
|
|
509
|
+
applyOutput(word.slice("--color=".length), undefined);
|
|
510
|
+
else if (word === "--color" && argv[index + 1] !== undefined)
|
|
511
|
+
applyOutput(argv[++index], undefined);
|
|
512
|
+
else
|
|
513
|
+
break;
|
|
514
|
+
}
|
|
515
|
+
return argv.slice(index);
|
|
516
|
+
}
|
|
420
517
|
function closest(name, names) {
|
|
421
518
|
const started = names.find((known) => known.startsWith(name));
|
|
422
519
|
if (started !== undefined)
|
|
@@ -424,20 +521,26 @@ function closest(name, names) {
|
|
|
424
521
|
const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
|
|
425
522
|
return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
|
|
426
523
|
}
|
|
427
|
-
async function help({ path, header, commands, defaultCommand }, stream) {
|
|
524
|
+
async function help({ path, header, summary, commands, defaultCommand, examples, replaces }, stream) {
|
|
428
525
|
const colors = paint(stream);
|
|
429
|
-
const width = Math.max(...Object.keys(commands).map((name) => name.length));
|
|
430
526
|
const prefix = path.join(" ");
|
|
527
|
+
if (wantsHelpJson())
|
|
528
|
+
return JSON.stringify(description(`${prefix} <command> [options]: ${summary}`, { examples, replaces }));
|
|
431
529
|
const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
|
|
432
530
|
const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
|
|
531
|
+
const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
|
|
433
532
|
return [
|
|
434
533
|
header(colors),
|
|
435
534
|
"",
|
|
436
535
|
usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
|
|
536
|
+
...replacesRows(replaces, colors),
|
|
437
537
|
"",
|
|
438
|
-
colors
|
|
439
|
-
...
|
|
440
|
-
|
|
538
|
+
...commandRows(commands, colors),
|
|
539
|
+
...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
|
|
540
|
+
"",
|
|
541
|
+
colors.bold("Options:"),
|
|
542
|
+
...optionRows(path.length === 1 ? TOP_OPTIONS : LEVEL_OPTIONS, colors),
|
|
543
|
+
...exampleRows(examples ?? [], colors),
|
|
441
544
|
"",
|
|
442
545
|
`${colors.dim("Run")} ${prefix} ${colors.cyan("<command>")} --help ${colors.dim("for its options.")}`
|
|
443
546
|
].join(`
|
|
@@ -451,18 +554,32 @@ function printError(prefix, message, hint, cause) {
|
|
|
451
554
|
${styled ? tint(label) : `${label}:`} ${text}`;
|
|
452
555
|
console.error(`${head} ${message}${under("cause", cause, colors.dim)}${under("hint", hint, colors.yellow)}`);
|
|
453
556
|
}
|
|
454
|
-
function
|
|
557
|
+
function printJsonError(message, hint, cause) {
|
|
558
|
+
console.error(JSON.stringify({ error: message, ...hint === undefined ? {} : { hint }, ...cause === undefined ? {} : { cause } }));
|
|
559
|
+
}
|
|
560
|
+
function wantsJson(argv) {
|
|
561
|
+
const end = argv.indexOf("--");
|
|
562
|
+
return (end === -1 ? argv : argv.slice(0, end)).includes("--json");
|
|
563
|
+
}
|
|
564
|
+
function reportThrown(prefix, error, redact = (text) => text, json = false) {
|
|
455
565
|
if (error instanceof HelpShown)
|
|
456
566
|
return 0;
|
|
457
567
|
const help = error instanceof UsageError ? `run ${prefix} --help for its options` : undefined;
|
|
458
568
|
const named = error instanceof Error && !(error instanceof Exit) && error.name !== "Error" ? `${error.name}: ${error.message}` : undefined;
|
|
459
569
|
const cause = error instanceof Error && error.cause instanceof Error ? redact(error.cause.message) : undefined;
|
|
460
|
-
|
|
570
|
+
const report = json ? printJsonError : (message, hint, causeText) => printError(prefix, message, hint, causeText);
|
|
571
|
+
report(redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
|
|
461
572
|
return error instanceof Exit ? error.code : 1;
|
|
462
573
|
}
|
|
463
|
-
async function dispatch(level,
|
|
464
|
-
const [name, ...args] = argv;
|
|
574
|
+
async function dispatch(level, typed, { version, redact }) {
|
|
465
575
|
const prefix = level.path.join(" ");
|
|
576
|
+
let argv;
|
|
577
|
+
try {
|
|
578
|
+
argv = takeOutputFlags(typed);
|
|
579
|
+
} catch (error) {
|
|
580
|
+
return reportThrown(prefix, error);
|
|
581
|
+
}
|
|
582
|
+
const [name, ...args] = argv;
|
|
466
583
|
if (name === undefined && level.path.length > 1) {
|
|
467
584
|
console.error(await help(level, process4.stderr));
|
|
468
585
|
return 2;
|
|
@@ -488,12 +605,12 @@ async function dispatch(level, argv, { version, redact }) {
|
|
|
488
605
|
}
|
|
489
606
|
if ("commands" in command) {
|
|
490
607
|
const { summary } = command;
|
|
491
|
-
return dispatch({ path: [...level.path, name], header: (colors) => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined }, rest, { version, redact });
|
|
608
|
+
return dispatch({ path: [...level.path, name], header: (colors) => `${colors.bold(`${prefix} ${name}`)} ${summary}`, summary, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples, replaces: undefined }, rest, { version, redact });
|
|
492
609
|
}
|
|
493
610
|
try {
|
|
494
611
|
return await command.run(rest);
|
|
495
612
|
} catch (error) {
|
|
496
|
-
return reportThrown(`${prefix} ${name}`, error, redact);
|
|
613
|
+
return reportThrown(`${prefix} ${name}`, error, redact, wantsJson(rest));
|
|
497
614
|
}
|
|
498
615
|
}
|
|
499
616
|
var COMPLETE = "__complete";
|
|
@@ -534,7 +651,7 @@ async function printCandidates(found) {
|
|
|
534
651
|
async function runCli(cli, argv = process4.argv.slice(2)) {
|
|
535
652
|
if (argv[0] === COMPLETE)
|
|
536
653
|
return printCandidates(candidates(cli.commands, cli.aliases, cli.defaultCommand, argv.slice(1)));
|
|
537
|
-
return dispatch({ path: [cli.name], header: (colors) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand }, argv, { version: cli.version, redact: cli.redact });
|
|
654
|
+
return dispatch({ path: [cli.name], header: (colors) => `${colors.bold(cli.name)} ${colors.dim(cli.version)} ${cli.about}`, summary: cli.about, commands: cli.commands, aliases: cli.aliases, defaultCommand: cli.defaultCommand, examples: cli.examples, replaces: cli.replaces }, argv, { version: cli.version, redact: cli.redact });
|
|
538
655
|
}
|
|
539
656
|
// ../kit/src/process.ts
|
|
540
657
|
import { constants } from "os";
|
|
@@ -558,7 +675,7 @@ async function runCommand(argv, { cwd, env, stdin = "ignore" } = {}) {
|
|
|
558
675
|
return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() };
|
|
559
676
|
}
|
|
560
677
|
// package.json
|
|
561
|
-
var version = "0.
|
|
678
|
+
var version = "0.4.0";
|
|
562
679
|
|
|
563
680
|
// ../kit/src/shell.ts
|
|
564
681
|
var SEPARATORS = new Set([`
|
|
@@ -571,6 +688,7 @@ var LEADING_TILDE = /^~(?=\/|$)/;
|
|
|
571
688
|
var LEADING_TABS = /^\t+/;
|
|
572
689
|
var OPENERS = /^[({]+/;
|
|
573
690
|
var CLOSERS = /[)}]+$/;
|
|
691
|
+
var DOUBLE_QUOTED_ESCAPES = new Set(["$", "`", '"', "\\"]);
|
|
574
692
|
var HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()-][^\s;&|<>()]*))/;
|
|
575
693
|
var WRAPPERS = new Set(["command", "env", "sudo", "exec", "nohup", "time"]);
|
|
576
694
|
function separatorWidth(command, i) {
|
|
@@ -758,7 +876,9 @@ function words(text) {
|
|
|
758
876
|
if (isContinuation)
|
|
759
877
|
continue;
|
|
760
878
|
isStarted = true;
|
|
761
|
-
if (char === "\\" && quote
|
|
879
|
+
if (char === "\\" && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? ""))
|
|
880
|
+
current += char;
|
|
881
|
+
else if (char === "\\" && quote !== "'")
|
|
762
882
|
current += text[++i] ?? "";
|
|
763
883
|
else if (quote !== null && char === quote)
|
|
764
884
|
quote = null;
|
|
@@ -799,7 +919,10 @@ function parseCommand(text) {
|
|
|
799
919
|
break;
|
|
800
920
|
}
|
|
801
921
|
}
|
|
802
|
-
const
|
|
922
|
+
const closers = CLOSERS.exec(text.trimEnd())?.[0].length ?? 0;
|
|
923
|
+
const word = tokens.at(-1) ?? "";
|
|
924
|
+
const trimmed = word.length - (CLOSERS.exec(word)?.[0].length ?? 0);
|
|
925
|
+
const last = word.slice(0, Math.max(trimmed, word.length - closers));
|
|
803
926
|
if (tokens.length > 0)
|
|
804
927
|
tokens.splice(-1, 1, ...last === "" ? [] : [last]);
|
|
805
928
|
const [program = "", ...args] = tokens;
|
|
@@ -1129,13 +1252,14 @@ var TRAILER = /^(?:co-authored-by|signed-off-by|reviewed-by|acked-by|tested-by|r
|
|
|
1129
1252
|
var BULLET = /^\s*(?:[-*+]|\d+[.)])\s/;
|
|
1130
1253
|
var SENTENCE_END = /[.!?:;)\]`'"]$/;
|
|
1131
1254
|
var LOWER_START = /^\p{Ll}/u;
|
|
1132
|
-
var
|
|
1133
|
-
var TITLE = `The title is no Conventional Commit
|
|
1134
|
-
var TITLE_LINE = `The title takes one line and a blank line follows it. ${
|
|
1135
|
-
var TRAILERS = `No trailers, ever: no Co-Authored-By, Signed-off-by or "Generated with" line, no --signoff or --trailer. ${
|
|
1136
|
-
var BULLETS = `The body names what changed in a line or two, never a bullet list narrating the diff. ${
|
|
1137
|
-
var TOO_LONG = `The body holds at most two lines besides the ticket lines; what goes beyond belongs in the reply. ${
|
|
1138
|
-
var WRAPPED = `A body sentence stays on one line however long it runs: never hard-wrap it. ${
|
|
1255
|
+
var RULE = 'The rule: a `type(scope): title` line, `!` after the scope for a breaking change, type and scope taken from the repository\'s history; then optionally a blank line and one or two lines on what changed, never a bullet list or a hard-wrapped sentence; then a blank line and one line per ticket, its title copied verbatim, `#<n> - <title>` (`owner/repo#<n> - <title>` for another repository). The message ends there: no Co-Authored-By, Signed-off-by or "Generated with" trailer. Only a breaking change gives full details.';
|
|
1256
|
+
var TITLE = `The title is no Conventional Commit. ${RULE}`;
|
|
1257
|
+
var TITLE_LINE = `The title takes one line and a blank line follows it. ${RULE}`;
|
|
1258
|
+
var TRAILERS = `No trailers, ever: no Co-Authored-By, Signed-off-by or "Generated with" line, no --signoff or --trailer. ${RULE}`;
|
|
1259
|
+
var BULLETS = `The body names what changed in a line or two, never a bullet list narrating the diff. ${RULE}`;
|
|
1260
|
+
var TOO_LONG = `The body holds at most two lines besides the ticket lines; what goes beyond belongs in the reply. ${RULE}`;
|
|
1261
|
+
var WRAPPED = `A body sentence stays on one line however long it runs: never hard-wrap it. ${RULE}`;
|
|
1262
|
+
var USE_GC = "Commit with `gc`, which stages exactly the paths given once the message keeps the rule: `gc 'fix(api): retry a timed-out fetch' -m 'A 502 is retried once before failing.' --ticket '#12 - [UC3] Retry failed fetches' -- src/api.ts`.";
|
|
1139
1263
|
function isDescription(line) {
|
|
1140
1264
|
return line !== undefined && line !== "" && !TICKET.test(line);
|
|
1141
1265
|
}
|
|
@@ -1449,8 +1573,8 @@ var COMMIT_VALUE_LONGS = new Set(["--message", "--file", "--reuse-message", "--r
|
|
|
1449
1573
|
var SHORT_CLUSTER = /^-[A-Z]+$/i;
|
|
1450
1574
|
var STAGES_ALL = /[Au]/;
|
|
1451
1575
|
var ALL_TAGS = new Set(["--tags", "--follow-tags"]);
|
|
1452
|
-
var STAGE_BY_PATH =
|
|
1453
|
-
var STASH = "Never `git stash`: a pop with nothing saved applies someone else's old stash. To
|
|
1576
|
+
var STAGE_BY_PATH = `Stage by path: the tree carries unrelated work and peer sessions edit it live. ${USE_GC}`;
|
|
1577
|
+
var STASH = "Never `git stash`: a pop with nothing saved applies someone else's old stash. To prove a test fails without your change, `proof --keep <test> -- <cmd>` runs it at HEAD in a temporary worktree and again here; `base -- <cmd>` runs any command at HEAD (`--ref <rev>` another commit), the working tree untouched. To set edits aside before a cherry-pick, a rebase or a checkout, commit them with `gc` first, or do the other work in `git worktree add`.";
|
|
1454
1578
|
var ADD_ALL = `\`git add\` over the whole tree is refused. ${STAGE_BY_PATH}`;
|
|
1455
1579
|
var COMMIT_ALL = `\`git commit -a\` stages the whole tree and is refused. ${STAGE_BY_PATH}`;
|
|
1456
1580
|
var PUSH = "`git push` runs only when the user asked for a push in their latest prompt: say the commit is ready and let them ask.";
|
|
@@ -1617,6 +1741,330 @@ function remoteViolation(command) {
|
|
|
1617
1741
|
return splitCommand(command).some((piece) => isRemote(piece.command)) ? REMOTE : null;
|
|
1618
1742
|
}
|
|
1619
1743
|
|
|
1744
|
+
// src/replace.ts
|
|
1745
|
+
var PYTHON = /^python[\d.]*$/;
|
|
1746
|
+
var SCRIPT_RUNNERS = new Set(["bun", "node"]);
|
|
1747
|
+
var EVAL_FLAGS = new Set(["-e", "--eval", "-p", "--print"]);
|
|
1748
|
+
var WRITES = /\bwrite_text\(|\bBun\.write\(|\bwriteFileSync\(|\bwriteFile\(|\bopen\([^)\n]*,\s*['"](?:w|a|r\+)/;
|
|
1749
|
+
var REPLACE_CALL = /\.(replace|replaceAll)\(/g;
|
|
1750
|
+
var FILE_CALL = /\b(?:Path|open|Bun\.file|Bun\.write|readFileSync|writeFileSync|readFile|writeFile)\(\s*/g;
|
|
1751
|
+
var IDENTIFIER = /^[A-Z_$][\w$]*/i;
|
|
1752
|
+
var COUNT_CHECK = /\.count\(/g;
|
|
1753
|
+
var COUNT_EQUALS = /^\s*\)\s*==\s*(\d+)/;
|
|
1754
|
+
var PYTHON_COUNT = /^\s*,\s*(\d+)\s*\)/;
|
|
1755
|
+
var SPACE2 = /^\s*/;
|
|
1756
|
+
var COMMA = /^\s*,\s*/;
|
|
1757
|
+
var REGEX_FLAGS = /^[gm]*$/;
|
|
1758
|
+
var PLAIN_WORD2 = /^[\w@%+=:,./*?-]+$/;
|
|
1759
|
+
var SED_SEPARATORS = /^[\s;]*/;
|
|
1760
|
+
var SED_SEPARATOR = /[\s;]/;
|
|
1761
|
+
var BRE_ESCAPED = new Map([["(", "("], [")", ")"], ["{", "{"], ["}", "}"], ["+", "+"], ["?", "?"], ["|", "|"]]);
|
|
1762
|
+
var POSIX_CLASSES = new Map([["[:space:]", String.raw`\s`], ["[:digit:]", String.raw`\d`], ["[:alpha:]", "a-zA-Z"], ["[:alnum:]", "a-zA-Z0-9"], ["[:upper:]", "A-Z"], ["[:lower:]", "a-z"], ["[:blank:]", String.raw` \t`]]);
|
|
1763
|
+
var ESCAPES = new Map([["n", `
|
|
1764
|
+
`], ["t", "\t"]]);
|
|
1765
|
+
var BRE_METAS = new Set([".", "[", "]", "*", "^", "$"]);
|
|
1766
|
+
var ERE_METAS = new Set([...BRE_METAS, "+", "?", "(", ")", "{", "}", "|"]);
|
|
1767
|
+
var JS_SPECIALS = /[.*+?^${}()|[\]\\]/g;
|
|
1768
|
+
var WHY = "writes nothing unless each file matches as often as expected, where it rewrites files whatever it matched";
|
|
1769
|
+
var GENERIC = "Run `sub --from <text> --to <text> <file>...` instead (`--regex`, `--all`, `-n N`, `--pairs -` for several in order, `--dry-run --diff` to look first).";
|
|
1770
|
+
function literalAt(text, at, regex = false) {
|
|
1771
|
+
let start = at;
|
|
1772
|
+
const isRaw = text[start] === "r" || text[start] === "R";
|
|
1773
|
+
if (isRaw || text[start] === "b")
|
|
1774
|
+
start++;
|
|
1775
|
+
const quote = text[start] ?? "";
|
|
1776
|
+
if (regex && quote === "/")
|
|
1777
|
+
return regexAt(text, start);
|
|
1778
|
+
if (quote !== "'" && quote !== '"' && quote !== "`")
|
|
1779
|
+
return;
|
|
1780
|
+
const fence = text.startsWith(quote.repeat(3), start) ? quote.repeat(3) : quote;
|
|
1781
|
+
let value = "";
|
|
1782
|
+
for (let i = start + fence.length;i < text.length; i++) {
|
|
1783
|
+
const char = text[i] ?? "";
|
|
1784
|
+
if (text.startsWith(fence, i))
|
|
1785
|
+
return { value, end: i + fence.length };
|
|
1786
|
+
if (quote === "`" && text.startsWith("${", i))
|
|
1787
|
+
return;
|
|
1788
|
+
if (char === "\\" && !isRaw) {
|
|
1789
|
+
const next = text[++i] ?? "";
|
|
1790
|
+
value += ESCAPES.get(next) ?? (next === `
|
|
1791
|
+
` ? "" : next);
|
|
1792
|
+
} else {
|
|
1793
|
+
value += char;
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
return;
|
|
1797
|
+
}
|
|
1798
|
+
function regexAt(text, start) {
|
|
1799
|
+
let source = "";
|
|
1800
|
+
let inClass = false;
|
|
1801
|
+
for (let i = start + 1;i < text.length; i++) {
|
|
1802
|
+
const char = text[i] ?? "";
|
|
1803
|
+
if (char === `
|
|
1804
|
+
`)
|
|
1805
|
+
return;
|
|
1806
|
+
if (char === "\\") {
|
|
1807
|
+
source += char + (text[++i] ?? "");
|
|
1808
|
+
continue;
|
|
1809
|
+
}
|
|
1810
|
+
if (char === "/" && !inClass) {
|
|
1811
|
+
const flags = IDENTIFIER.exec(text.slice(i + 1))?.[0] ?? "";
|
|
1812
|
+
return { value: source, end: i + 1 + flags.length, flags };
|
|
1813
|
+
}
|
|
1814
|
+
if (char === "[" || char === "]")
|
|
1815
|
+
inClass = char === "[";
|
|
1816
|
+
source += char;
|
|
1817
|
+
}
|
|
1818
|
+
return;
|
|
1819
|
+
}
|
|
1820
|
+
function skip(text, at, pattern) {
|
|
1821
|
+
const found = pattern.exec(text.slice(at));
|
|
1822
|
+
return found === null ? undefined : at + found[0].length;
|
|
1823
|
+
}
|
|
1824
|
+
function valueAt(script, at, regex = false) {
|
|
1825
|
+
const literal = literalAt(script, at, regex);
|
|
1826
|
+
const name = literal === undefined ? IDENTIFIER.exec(script.slice(at))?.[0] : undefined;
|
|
1827
|
+
if (literal !== undefined || name === undefined)
|
|
1828
|
+
return literal;
|
|
1829
|
+
const assignments = [...script.slice(0, at).matchAll(new RegExp(String.raw`(?<![\w.$])${name.replaceAll("$", String.raw`\$`)}\s*=(?!=)\s*`, "g"))];
|
|
1830
|
+
const last = assignments.at(-1);
|
|
1831
|
+
const assigned = last === undefined ? undefined : literalAt(script, last.index + last[0].length, regex);
|
|
1832
|
+
return assigned === undefined ? undefined : { ...assigned, end: at + name.length };
|
|
1833
|
+
}
|
|
1834
|
+
function scriptPaths(script) {
|
|
1835
|
+
const paths = new Set;
|
|
1836
|
+
for (const call of script.matchAll(FILE_CALL)) {
|
|
1837
|
+
const path = valueAt(script, call.index + call[0].length)?.value;
|
|
1838
|
+
if (path !== undefined && path !== "")
|
|
1839
|
+
paths.add(path);
|
|
1840
|
+
}
|
|
1841
|
+
return [...paths];
|
|
1842
|
+
}
|
|
1843
|
+
function assertedCounts(script) {
|
|
1844
|
+
const counts = new Map;
|
|
1845
|
+
for (const call of script.matchAll(COUNT_CHECK)) {
|
|
1846
|
+
const text = valueAt(script, call.index + call[0].length);
|
|
1847
|
+
const equals = text === undefined ? null : COUNT_EQUALS.exec(script.slice(text.end));
|
|
1848
|
+
if (text !== undefined && equals !== null)
|
|
1849
|
+
counts.set(text.value, Number(equals[1]));
|
|
1850
|
+
}
|
|
1851
|
+
return counts;
|
|
1852
|
+
}
|
|
1853
|
+
function scriptPairs(script, isPython) {
|
|
1854
|
+
const pairs = [];
|
|
1855
|
+
const counts = isPython ? assertedCounts(script) : new Map;
|
|
1856
|
+
for (const call of script.matchAll(REPLACE_CALL)) {
|
|
1857
|
+
const from = valueAt(script, skip(script, call.index + call[0].length, SPACE2) ?? 0, !isPython);
|
|
1858
|
+
const comma = from === undefined ? undefined : skip(script, from.end, COMMA);
|
|
1859
|
+
const to = comma === undefined ? undefined : valueAt(script, comma);
|
|
1860
|
+
if (from === undefined || to === undefined)
|
|
1861
|
+
return;
|
|
1862
|
+
if (from.flags !== undefined) {
|
|
1863
|
+
if (!REGEX_FLAGS.test(from.flags))
|
|
1864
|
+
return;
|
|
1865
|
+
pairs.push({ from: from.value, to: to.value, regex: true, ...from.flags.includes("g") ? { all: true } : {} });
|
|
1866
|
+
continue;
|
|
1867
|
+
}
|
|
1868
|
+
const limit = isPython ? PYTHON_COUNT.exec(script.slice(to.end)) : null;
|
|
1869
|
+
const count = limit === null ? counts.get(from.value) : Number(limit[1]);
|
|
1870
|
+
const all = call[1] === "replaceAll" || isPython && count === undefined;
|
|
1871
|
+
const pair = { from: from.value, to: to.value };
|
|
1872
|
+
if (all)
|
|
1873
|
+
pair.all = true;
|
|
1874
|
+
else if (count !== undefined && count !== 1)
|
|
1875
|
+
pair.count = count;
|
|
1876
|
+
pairs.push(pair);
|
|
1877
|
+
}
|
|
1878
|
+
return pairs.length === 0 ? undefined : pairs;
|
|
1879
|
+
}
|
|
1880
|
+
function sedArgs(args) {
|
|
1881
|
+
const sed = { scripts: [], files: [], isExtended: false, isInPlace: false, isPlain: true };
|
|
1882
|
+
const operands = [];
|
|
1883
|
+
for (let i = 0;i < args.length; i++) {
|
|
1884
|
+
const arg = args[i] ?? "";
|
|
1885
|
+
if (arg === "--in-place" || arg.startsWith("--in-place="))
|
|
1886
|
+
sed.isInPlace = true;
|
|
1887
|
+
else if (arg === "--regexp-extended")
|
|
1888
|
+
sed.isExtended = true;
|
|
1889
|
+
else if (arg === "--expression")
|
|
1890
|
+
sed.scripts.push(args[++i] ?? "");
|
|
1891
|
+
else if (arg.startsWith("--expression="))
|
|
1892
|
+
sed.scripts.push(arg.slice("--expression=".length));
|
|
1893
|
+
else if (arg.startsWith("--"))
|
|
1894
|
+
sed.isPlain = false;
|
|
1895
|
+
else if (arg.startsWith("-") && arg.length > 1)
|
|
1896
|
+
i += sedCluster(arg, args[i + 1], sed);
|
|
1897
|
+
else
|
|
1898
|
+
operands.push(arg);
|
|
1899
|
+
}
|
|
1900
|
+
if (sed.scripts.length === 0)
|
|
1901
|
+
sed.scripts.push(operands.shift() ?? "");
|
|
1902
|
+
sed.files = operands;
|
|
1903
|
+
return sed;
|
|
1904
|
+
}
|
|
1905
|
+
function sedCluster(arg, next, sed) {
|
|
1906
|
+
for (const [index, flag] of [...arg.slice(1)].entries()) {
|
|
1907
|
+
if (flag === "i") {
|
|
1908
|
+
sed.isInPlace = true;
|
|
1909
|
+
return 0;
|
|
1910
|
+
}
|
|
1911
|
+
if (flag === "E" || flag === "r") {
|
|
1912
|
+
sed.isExtended = true;
|
|
1913
|
+
} else if (flag === "e") {
|
|
1914
|
+
const value = index === arg.length - 2 ? next : arg.slice(index + 2);
|
|
1915
|
+
sed.scripts.push(value ?? "");
|
|
1916
|
+
return index === arg.length - 2 ? 1 : 0;
|
|
1917
|
+
} else if (flag !== "s" && flag !== "u") {
|
|
1918
|
+
sed.isPlain = false;
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
return 0;
|
|
1922
|
+
}
|
|
1923
|
+
function sedPart(script, at, delimiter) {
|
|
1924
|
+
for (let i = at;i < script.length; i++) {
|
|
1925
|
+
if (script[i] === "\\")
|
|
1926
|
+
i++;
|
|
1927
|
+
else if (script[i] === delimiter)
|
|
1928
|
+
return { part: script.slice(at, i), end: i + 1 };
|
|
1929
|
+
}
|
|
1930
|
+
return;
|
|
1931
|
+
}
|
|
1932
|
+
function sedLiteral(pattern, delimiter, metas) {
|
|
1933
|
+
let text = "";
|
|
1934
|
+
for (let i = 0;i < pattern.length; i++) {
|
|
1935
|
+
const char = pattern[i] ?? "";
|
|
1936
|
+
if (char !== "\\") {
|
|
1937
|
+
if (metas.has(char))
|
|
1938
|
+
return;
|
|
1939
|
+
text += char;
|
|
1940
|
+
continue;
|
|
1941
|
+
}
|
|
1942
|
+
const next = pattern[++i] ?? "";
|
|
1943
|
+
if (ESCAPES.has(next))
|
|
1944
|
+
text += ESCAPES.get(next);
|
|
1945
|
+
else if (next === delimiter || next === "\\" || BRE_METAS.has(next) || metas === ERE_METAS && ERE_METAS.has(next))
|
|
1946
|
+
text += next;
|
|
1947
|
+
else
|
|
1948
|
+
return;
|
|
1949
|
+
}
|
|
1950
|
+
return text;
|
|
1951
|
+
}
|
|
1952
|
+
function sedRegex(pattern, delimiter, isExtended) {
|
|
1953
|
+
let source = "";
|
|
1954
|
+
for (let i = 0;i < pattern.length; i++) {
|
|
1955
|
+
const char = pattern[i] ?? "";
|
|
1956
|
+
const posix = [...POSIX_CLASSES].find(([name]) => pattern.startsWith(name, i));
|
|
1957
|
+
if (posix !== undefined) {
|
|
1958
|
+
source += posix[1];
|
|
1959
|
+
i += posix[0].length - 1;
|
|
1960
|
+
} else if (char === "\\") {
|
|
1961
|
+
const next = pattern[++i] ?? "";
|
|
1962
|
+
if (next === delimiter)
|
|
1963
|
+
source += next.replaceAll(JS_SPECIALS, String.raw`\$&`);
|
|
1964
|
+
else
|
|
1965
|
+
source += !isExtended && BRE_ESCAPED.has(next) ? next : `\\${next}`;
|
|
1966
|
+
} else {
|
|
1967
|
+
source += !isExtended && BRE_ESCAPED.has(char) ? `\\${char}` : char;
|
|
1968
|
+
}
|
|
1969
|
+
}
|
|
1970
|
+
return source;
|
|
1971
|
+
}
|
|
1972
|
+
function sedReplacement(replacement, literal) {
|
|
1973
|
+
let text = "";
|
|
1974
|
+
for (let i = 0;i < replacement.length; i++) {
|
|
1975
|
+
const char = replacement[i] ?? "";
|
|
1976
|
+
if (char === "&") {
|
|
1977
|
+
text += literal ?? "$&";
|
|
1978
|
+
} else if (char === "$" && literal === undefined) {
|
|
1979
|
+
text += "$$";
|
|
1980
|
+
} else if (char === "\\") {
|
|
1981
|
+
const next = replacement[++i] ?? "";
|
|
1982
|
+
if (next >= "1" && next <= "9" && literal === undefined)
|
|
1983
|
+
text += `$${next}`;
|
|
1984
|
+
else if (next >= "0" && next <= "9")
|
|
1985
|
+
return;
|
|
1986
|
+
else
|
|
1987
|
+
text += ESCAPES.get(next) ?? (next === "$" && literal === undefined ? "$$" : next);
|
|
1988
|
+
} else {
|
|
1989
|
+
text += char;
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
return text;
|
|
1993
|
+
}
|
|
1994
|
+
function sedPairs(script, isExtended) {
|
|
1995
|
+
const pairs = [];
|
|
1996
|
+
for (let at = 0;at < script.length; ) {
|
|
1997
|
+
at = skip(script, at, SED_SEPARATORS) ?? at;
|
|
1998
|
+
if (at >= script.length)
|
|
1999
|
+
break;
|
|
2000
|
+
const delimiter = script[at + 1] ?? "";
|
|
2001
|
+
if (script[at] !== "s" || delimiter === "" || delimiter === "\\" || delimiter === `
|
|
2002
|
+
`)
|
|
2003
|
+
return;
|
|
2004
|
+
const pattern = sedPart(script, at + 2, delimiter);
|
|
2005
|
+
const replacement = pattern === undefined ? undefined : sedPart(script, pattern.end, delimiter);
|
|
2006
|
+
if (pattern === undefined || replacement === undefined)
|
|
2007
|
+
return;
|
|
2008
|
+
const isGlobal = script[replacement.end] === "g";
|
|
2009
|
+
at = replacement.end + (isGlobal ? 1 : 0);
|
|
2010
|
+
if (at < script.length && !SED_SEPARATOR.test(script[at] ?? ""))
|
|
2011
|
+
return;
|
|
2012
|
+
const literal = sedLiteral(pattern.part, delimiter, isExtended ? ERE_METAS : BRE_METAS);
|
|
2013
|
+
const to = sedReplacement(replacement.part, literal);
|
|
2014
|
+
if (to === undefined)
|
|
2015
|
+
return;
|
|
2016
|
+
pairs.push({ from: literal ?? sedRegex(pattern.part, delimiter, isExtended), to, ...literal === undefined ? { regex: true } : {}, ...isGlobal ? { all: true } : {} });
|
|
2017
|
+
}
|
|
2018
|
+
return pairs.length === 0 ? undefined : pairs;
|
|
2019
|
+
}
|
|
2020
|
+
function quote(text) {
|
|
2021
|
+
return `'${text.replaceAll("'", String.raw`'\''`)}'`;
|
|
2022
|
+
}
|
|
2023
|
+
function fileWord(path) {
|
|
2024
|
+
return PLAIN_WORD2.test(path) ? path : quote(path);
|
|
2025
|
+
}
|
|
2026
|
+
function subCommand(pairs, files) {
|
|
2027
|
+
const targets = files.length === 0 ? "<file>" : files.map(fileWord).join(" ");
|
|
2028
|
+
const [pair] = pairs;
|
|
2029
|
+
if (pairs.length > 1 || pair === undefined)
|
|
2030
|
+
return `sub --pairs - ${targets} <<'PAIRS'
|
|
2031
|
+
${JSON.stringify(pairs)}
|
|
2032
|
+
PAIRS`;
|
|
2033
|
+
const flags = [pair.regex === true ? " --regex" : "", pair.all === true ? " --all" : "", pair.count === undefined ? "" : ` -n ${pair.count}`].join("");
|
|
2034
|
+
return `sub --from ${quote(pair.from)} --to ${quote(pair.to)}${flags} ${targets}`;
|
|
2035
|
+
}
|
|
2036
|
+
function route(what, pairs, files) {
|
|
2037
|
+
return pairs === undefined ? `${what} ${GENERIC}` : `${what} Run this instead, which ${WHY}:
|
|
2038
|
+
${subCommand(pairs, files)}`;
|
|
2039
|
+
}
|
|
2040
|
+
function inlineScript(program, args, text) {
|
|
2041
|
+
const flags = PYTHON.test(program) ? new Set(["-c"]) : EVAL_FLAGS;
|
|
2042
|
+
const flag = args.findIndex((arg) => flags.has(arg));
|
|
2043
|
+
if (flag !== -1)
|
|
2044
|
+
return args[flag + 1] ?? null;
|
|
2045
|
+
const reads = args.every((arg) => arg === "-" || arg.startsWith("<<") || program === "bun" && arg === "run");
|
|
2046
|
+
return reads ? heredocBody(text, HEREDOC_OPENER)?.body ?? null : null;
|
|
2047
|
+
}
|
|
2048
|
+
function replaceViolation(command) {
|
|
2049
|
+
for (const piece of splitCommand(command)) {
|
|
2050
|
+
const { program, args } = parseCommand(piece.command);
|
|
2051
|
+
if (program === "sed") {
|
|
2052
|
+
const sed = sedArgs(args);
|
|
2053
|
+
if (sed.isInPlace)
|
|
2054
|
+
return route("`sed -i` rewrites files without checking what it matched.", sed.isPlain && sed.scripts.length > 0 ? sedPairs(sed.scripts.join(`
|
|
2055
|
+
`), sed.isExtended) : undefined, sed.files);
|
|
2056
|
+
continue;
|
|
2057
|
+
}
|
|
2058
|
+
const isPython = PYTHON.test(program);
|
|
2059
|
+
const script = isPython || SCRIPT_RUNNERS.has(program) ? inlineScript(program, args, piece.text) : null;
|
|
2060
|
+
if (script === null || !WRITES.test(script) || script.search(REPLACE_CALL) === -1)
|
|
2061
|
+
continue;
|
|
2062
|
+
const pairs = scriptPairs(script, isPython);
|
|
2063
|
+
return route(`A ${isPython ? "python" : program} script replacing text in a file is what \`sub\` does with a match-count check.`, pairs, scriptPaths(script));
|
|
2064
|
+
}
|
|
2065
|
+
return null;
|
|
2066
|
+
}
|
|
2067
|
+
|
|
1620
2068
|
// src/check.ts
|
|
1621
2069
|
function absolute(path, cwd) {
|
|
1622
2070
|
return path.startsWith("/") ? path : `${cwd}/${path}`;
|
|
@@ -1668,9 +2116,11 @@ async function checkCommand(command, context) {
|
|
|
1668
2116
|
const first = (messages) => messages.find((message) => message !== null) ?? null;
|
|
1669
2117
|
add("remote", first(run.map(remoteViolation)));
|
|
1670
2118
|
add("git", first(run.map((each) => gitViolation(each, context.isPushAsked))));
|
|
1671
|
-
|
|
2119
|
+
const commit = commitViolation(command, context);
|
|
2120
|
+
add("commit", commit === null ? null : `${commit} ${USE_GC}`);
|
|
1672
2121
|
add("sql", first(await Promise.all(run.map(async (each) => sqlViolation(each, async (path) => Promise.resolve(context.read(absolute(path, context.cwd))))))));
|
|
1673
2122
|
add("env", touchesEnv(command) ? USE_EV : null);
|
|
2123
|
+
add("edit", context.canSub ? first(run.map(replaceViolation)) : null);
|
|
1674
2124
|
const found = mismatches(command, context);
|
|
1675
2125
|
const refused = found.find((mismatch) => mismatch.rewrite === null || !context.canRewrite);
|
|
1676
2126
|
const rewrite = refused?.rewrite ?? null;
|
|
@@ -1693,18 +2143,25 @@ function checkPath(path) {
|
|
|
1693
2143
|
var BINS = {
|
|
1694
2144
|
check: "`check`: runs the repo's check, typecheck, lint and test steps and prints only what failed, each full log saved to a file; validate a change with it rather than chaining the scripts (`--only lint,test`, `--list`).",
|
|
1695
2145
|
fix: "`fix`: runs the repo's fixers and formatters (its fix, lint:fix and format scripts, just fmt, go fmt, cargo fmt, ruff) and lists the files they changed.",
|
|
2146
|
+
errs: "`errs [log]`, `errs -- <cmd>`: turns tsc, tsgo, eslint, bun check and bun test output into one `file:line:col: message (rule)` line per error, counting the lines it did not recognise (`--json`); use it instead of reading raw type, lint or test logs.",
|
|
1696
2147
|
db: '`db "<sql>"`: one read-only statement against the Postgres in DATABASE_URL (`-e KEY` for another) or, without one, the compose project\'s running Postgres service (`-s <service>`, `-d <database>`), credentials never shown, a write printed for the user to run; `db tables`, `db describe <table>`, `db dbs`.',
|
|
1697
2148
|
ev: "`ev`: .env files read and edited with values masked (`ev list`, `ev get KEY`, `ev grep <regex>`, `ev show <rev>:<file>`, `ev set`, `ev diff`, `ev missing`); `ev audit [dir]` finds env files others can read or git tracks, and compose files writing secrets out.",
|
|
1698
2149
|
redact: "`redact`: masks secrets in piped output, `<cmd> | redact`.",
|
|
1699
|
-
pkg: "`pkg <name>[@version]`: checks an npm package before it is added: whether the name exists or is a near miss of a popular one, the newest version at least two weeks old, licence, install scripts; `bun add`, `npm install`, `pnpm add` and `yarn add` run it first and are refused on what it flags.",
|
|
2150
|
+
pkg: "`pkg <name>[@version]`: checks an npm package before it is added: whether the name exists or is a near miss of a popular one, the newest version at least two weeks old, licence, install scripts; `bun add`, `npm install`, `pnpm add` and `yarn add` run it first and are refused on what it flags. `pkg bump <@scope/lib> [version]` waits until the registry serves a release, then `bun add --exact`s it in every consumer under ~/Workspace (`--dry-run`), `pkg consumers <name>` lists them; use it instead of a `bun info` and sleep loop and a `bun add` per repo.",
|
|
1700
2151
|
stack: "`stack port <name>`: the port a service name always hashes to, for every host port a compose file publishes; `stack audit [dir]` cross-checks release.config.ts against its compose files.",
|
|
1701
|
-
ft: "`ft`: Forgejo CI runs, logs and waits, releases, issue checklists and claims, in a repo whose origin is on Forgejo.",
|
|
2152
|
+
ft: "`ft`: Forgejo CI runs, logs and waits, releases, issue checklists and claims, milestones (`ft milestones`, `ft milestone <m>`, `ft new -m`), in a repo whose origin is on Forgejo.",
|
|
1702
2153
|
sub: "`sub --from <text> --to <text> <file>...`: replaces text, writing nothing unless each match count is as expected (1, `-n N`, `--all`), `--regex`, `--pairs <json|->` for several in order, `--dry-run --diff`; use it instead of sed -i, a bun -e or python editor.",
|
|
1703
2154
|
base: "`base [--ref <rev>] [--keep <path>...] -- <cmd>`: runs a command at HEAD (or a ref) in a temporary worktree, removed afterwards, ignored env files and node_modules/vendor linked; use it instead of git stash to see whether a test fails without your change (`--keep` the new test).",
|
|
2155
|
+
proof: "`proof [--ref <rev>] [--keep <path>...] -- <cmd>`: runs the command at HEAD (or a ref) in a temporary worktree, then in the working tree, and exits 0 only when it fails before and passes now, printing the failing side's output; use it to prove a new test fails without your change (`--keep` the test).",
|
|
1704
2156
|
gst: "`gst [--log N]`: branch, upstream, changed counts and HEAD in one line, then the changed paths by staged, unstaged and untracked; use it instead of `git status --short` and `git log --oneline`.",
|
|
1705
2157
|
gc: "`gc <title> [-m <line>]... [--ticket <line>]... -- <path>...`: commits exactly those paths, refusing with nothing staged when the message breaks a commit rule, a path has no change or other files are already staged; use it instead of chaining git add, status and commit.",
|
|
1706
|
-
waitfor: "`waitfor --file <log> --until <regex> [--fail <regex>]`, `--port <n>`, `--url <url>` (`-t 5m`): waits until the line shows, the port listens
|
|
2158
|
+
waitfor: "`waitfor --file <log> --until <regex> [--fail <regex>]`, `--port <n>`, `--url <url>`, `--pid <n>` (`-t 5m`): waits until the line shows, the port listens, the URL answers 2xx or the process exits, exits 1 on a --fail line with the lines around it, 2 on a timeout with the last lines; `waitfor task <id>` waits for a background Bash task and prints its exit code, last lines and failure lines; use it instead of sleep and tail, `until ! pgrep -f` or polling loops.",
|
|
1707
2159
|
gl: "`gl ci [ref]` waits for the GitLab pipeline covering a commit and prints each failed job's tests (exit 0/1/2), `gl runs`, `gl logs <pipeline> [-j job] [--full]`; use it instead of `glab ci` or `glab api .../pipelines/<id>/jobs` polled in a sleep loop.",
|
|
2160
|
+
peek: "`peek <file:from-to>...` prints several spans with line numbers in one call, `peek --def <symbol>` a whole declaration, `--refs <symbol>` its usages by file, `--outline <file>` the declarations and members, `--around <regex> -C 3` the lines around matches; use it instead of `sed -n N,Mp`, `grep -n` chains and whole-file reads.",
|
|
2161
|
+
"todo-scan": "`todo-scan [path...]` lists the TODO, FIXME and owner/repo#N comments as `<file>:<line>: <comment>`, `--stale` only those whose issue is closed (exit 1), through ft; use it to find the workarounds a shipped fix lets you drop.",
|
|
2162
|
+
doctor: "`doctor`: checks this host's agent-tools install, one line per check with its fix: each bin's version against the registry, tea's login, the Claude Code hooks, the Bun floor (`--json`); run it when a bin lacks a command or a flag its docs name.",
|
|
2163
|
+
"agent-tools": "`agent-tools list`: every agent-tools bin in one line, what it does and what it is reached for instead of, read from each bin's own --help (`--json` adds the synopsis and examples); run it to find the bin that fits a task.",
|
|
2164
|
+
hotspots: "`hotspots [dir] [--since <date>] [-n 20]`: ranks the source files by complexity times git churn (commits touching each in the window, 6 months by default), tests and generated files left out, `--json`; use it to pick which complex files are worth splitting first.",
|
|
1708
2165
|
out: "`out -- <cmd>`: folds long output, keeping the errors and saving the full log; run noisy builds, installs, tests and linters through it."
|
|
1709
2166
|
};
|
|
1710
2167
|
function binsSection(installed) {
|
|
@@ -1740,12 +2197,13 @@ async function bashCall(command, input, machine) {
|
|
|
1740
2197
|
isPushAsked: file !== null && machine.exists(file),
|
|
1741
2198
|
canRewrite: false,
|
|
1742
2199
|
canWrap: false,
|
|
2200
|
+
canSub: machine.has("sub"),
|
|
1743
2201
|
read: machine.read,
|
|
1744
2202
|
exists: machine.exists
|
|
1745
2203
|
});
|
|
1746
2204
|
if (verdict.violations.length > 0 || verdict.packages.length === 0 || !machine.has("pkg"))
|
|
1747
2205
|
return deny(verdict.violations);
|
|
1748
|
-
const checked = await machine.run(["pkg", ...verdict.packages], cwd);
|
|
2206
|
+
const checked = await machine.run(["pkg", "check", ...verdict.packages], cwd);
|
|
1749
2207
|
return checked.code === 1 ? deny([{ rule: "pkg", message: `pkg refused what this adds, so it was not run:
|
|
1750
2208
|
${checked.text.trim()}` }]) : null;
|
|
1751
2209
|
}
|
|
@@ -1778,9 +2236,11 @@ async function hook(event, input, machine) {
|
|
|
1778
2236
|
// src/cli.ts
|
|
1779
2237
|
var checkUsage = `guard check [options] [<command> | -]
|
|
1780
2238
|
|
|
1781
|
-
Checks a shell command against the rules: remote machines, git (stash, whole-tree add and commit, push, tags), commit messages, SQL writes, env files and
|
|
2239
|
+
Checks a shell command against the rules: remote machines, git (stash, whole-tree add and commit, push, tags), commit messages, SQL writes, env files, package managers and in-place edits (sed -i and replace scripts, pointed at sub).
|
|
1782
2240
|
Prints each broken rule with the route to take instead and exits 1, prints nothing and exits 0 when the command is clean.
|
|
1783
|
-
The command is read from stdin with - or when none is given; a rule may rewrite it instead (the pinned package manager, a noisy run through out), printed as \`runs as: <command
|
|
2241
|
+
The command is read from stdin with - or when none is given; a rule may rewrite it instead (the pinned package manager, a noisy run through out), printed as \`runs as: <command>\`.
|
|
2242
|
+
|
|
2243
|
+
Exits 0 when the command is clean, 1 when a rule is broken, 2 on a usage error.`;
|
|
1784
2244
|
var checkOptions = {
|
|
1785
2245
|
json: jsonOption("{violations, command, notes, packages} for a hook"),
|
|
1786
2246
|
cwd: { type: "string", description: "the directory the command runs in (default: here)" },
|
|
@@ -1789,7 +2249,9 @@ var checkOptions = {
|
|
|
1789
2249
|
};
|
|
1790
2250
|
var pathUsage = `guard path [--json] <path>...
|
|
1791
2251
|
|
|
1792
|
-
Checks the paths a file tool would read, edit or write: env files are refused
|
|
2252
|
+
Checks the paths a file tool would read, edit or write: env files are refused.
|
|
2253
|
+
|
|
2254
|
+
Exits 0 when every path is clean, 1 when one is refused, 2 on a usage error.`;
|
|
1793
2255
|
var pathOptions = {
|
|
1794
2256
|
json: jsonOption("{violations} for a hook")
|
|
1795
2257
|
};
|
|
@@ -1797,7 +2259,9 @@ var hookUsage = `guard hook <${EVENTS.join("|")}>
|
|
|
1797
2259
|
|
|
1798
2260
|
Answers a Claude Code hook: reads the event JSON on stdin and prints the reply JSON, nothing when the call goes ahead untouched.
|
|
1799
2261
|
pre-tool-use refuses a Bash call breaking a rule, an install pkg refuses, and a Read, Edit or Write of an env file; user-prompt-submit remembers whether the prompt asked for a push; session-start lists the installed agent-tools bins.
|
|
1800
|
-
A failure exits 1, which Claude Code reports without blocking the call
|
|
2262
|
+
A failure exits 1, which Claude Code reports without blocking the call.
|
|
2263
|
+
|
|
2264
|
+
Exits 0 when it answered, 1 on a failure or an unknown event (never 2, which would block the call).`;
|
|
1801
2265
|
function readText(path) {
|
|
1802
2266
|
try {
|
|
1803
2267
|
return readFileSync(path, "utf8");
|
|
@@ -1824,6 +2288,7 @@ ${checkUsage}`);
|
|
|
1824
2288
|
isPushAsked: values["push-asked"] === true,
|
|
1825
2289
|
canRewrite: true,
|
|
1826
2290
|
canWrap: values["no-wrap"] !== true && Bun.which("out") !== null,
|
|
2291
|
+
canSub: Bun.which("sub") !== null,
|
|
1827
2292
|
read: readText,
|
|
1828
2293
|
exists: existsSync
|
|
1829
2294
|
};
|
|
@@ -1882,6 +2347,8 @@ process6.exitCode = await runCli({
|
|
|
1882
2347
|
name: "guard",
|
|
1883
2348
|
version,
|
|
1884
2349
|
about: "the safety rules a Claude Code Bash call is held to, each refusal naming the route",
|
|
2350
|
+
replaces: "a hook script per rule, each wording its own refusal",
|
|
2351
|
+
examples: [['guard check "git push --force"', "the rules a command breaks, with their routes"], ["guard path .env", "whether a file tool may open a path"]],
|
|
1885
2352
|
commands: {
|
|
1886
2353
|
check: { run: check, summary: "check a shell command, printing each broken rule with its route" },
|
|
1887
2354
|
path: { run: async (args) => Promise.resolve(path(args)), summary: "check a path a file tool would open" },
|
package/package.json
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kaynooo/guard-tools",
|
|
3
3
|
"type": "module",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.4.0",
|
|
5
5
|
"description": "guard: the safety rules a Claude Code Bash call is held to (git, remote machines, SQL writes, env files, package managers, commit messages), each refusal naming the route.",
|
|
6
|
-
"author": "Kaynooo <kaynooo
|
|
6
|
+
"author": "Kaynooo <contact@kaynooo.fr>",
|
|
7
7
|
"license": "MIT",
|
|
8
|
-
"homepage": "https://git.
|
|
8
|
+
"homepage": "https://git.kaynooo.fr/kaynooo/agent-tools/src/branch/main/packages/guard#readme",
|
|
9
9
|
"repository": {
|
|
10
10
|
"type": "git",
|
|
11
|
-
"url": "git+https://git.
|
|
11
|
+
"url": "git+https://git.kaynooo.fr/kaynooo/agent-tools.git",
|
|
12
12
|
"directory": "packages/guard"
|
|
13
13
|
},
|
|
14
|
-
"bugs": "https://git.
|
|
14
|
+
"bugs": "https://git.kaynooo.fr/kaynooo/agent-tools/issues",
|
|
15
15
|
"keywords": [
|
|
16
16
|
"claude-code",
|
|
17
17
|
"cli",
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"bun": ">=1.4.3"
|
|
30
30
|
},
|
|
31
31
|
"publishConfig": {
|
|
32
|
-
"registry": "https://git.
|
|
32
|
+
"registry": "https://git.kaynooo.fr/api/packages/kaynooo/npm/"
|
|
33
33
|
},
|
|
34
34
|
"scripts": {
|
|
35
35
|
"build": "$npm_execpath build src/cli.ts --target bun --outdir dist",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"devDependencies": {
|
|
39
39
|
"@kaynooo/cli-kit": "workspace:*",
|
|
40
40
|
"@kaynooo/db-tools": "workspace:*",
|
|
41
|
+
"@kaynooo/edit-tools": "workspace:*",
|
|
41
42
|
"@kaynooo/env-tools": "workspace:*",
|
|
42
43
|
"@kaynooo/git-tools": "workspace:*",
|
|
43
44
|
"@kaynooo/out-tools": "workspace:*",
|