@kaynooo/guard-tools 0.2.1 → 0.3.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.
Files changed (3) hide show
  1. package/README.md +2 -1
  2. package/dist/cli.js +431 -30
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -14,10 +14,11 @@ guard check --json --cwd "$PWD" - < command.txt
14
14
  | --- | --- | --- |
15
15
  | `remote` | `ssh`, `scp`, `sftp`, `mosh`, `rsync` to a host, `DOCKER_HOST=ssh://`, `docker -H ssh://` | `src/remote.ts` |
16
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` |
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
@@ -343,10 +343,15 @@ var USAGE_PREFIX = /^usage:\s*/i;
343
343
  function usageLine(usage, colors = paint(process3.stdout)) {
344
344
  return `${colors.bold("Usage:")} ${usage.replace(USAGE_PREFIX, "").replaceAll(OPTIONAL, (part) => colors.dim(part))}`;
345
345
  }
346
- function helpText(usage, options, colors = paint(process3.stdout)) {
347
- return [usageLine(usage, colors), "", colors.bold("Options:"), ...optionRows(options, colors)].join(`
346
+ function helpText(usage, options, colors = paint(process3.stdout), examples = []) {
347
+ return [usageLine(usage, colors), "", colors.bold("Options:"), ...optionRows(options, colors), ...exampleRows(examples, colors)].join(`
348
348
  `);
349
349
  }
350
+ function exampleRows(examples, colors = paint(process3.stdout)) {
351
+ if (examples.length === 0)
352
+ return [];
353
+ return ["", colors.bold("Examples:"), ...examples.map((example) => typeof example === "string" ? ` ${example}` : ` ${example[0]} ${colors.dim(`# ${example[1]}`)}`)];
354
+ }
350
355
  function optionRows(options, colors = paint(process3.stdout)) {
351
356
  const rows = Object.entries(options).map(([name, option]) => {
352
357
  const short = option.short === undefined ? " " : `${colors.cyan(`-${option.short}`)}, `;
@@ -356,8 +361,27 @@ function optionRows(options, colors = paint(process3.stdout)) {
356
361
  const width = Math.max(0, ...rows.map((row) => row.width));
357
362
  return rows.map((row) => ` ${row.flag}${" ".repeat(width - row.width)} ${colors.dim(row.description)}`);
358
363
  }
364
+ var OUTPUT_OPTIONS = {
365
+ color: { type: "string", description: "auto (on a terminal alone), always or never" },
366
+ plain: { type: "boolean", description: "plain text, no colour or spinner: --color never" }
367
+ };
368
+ function applyOutput(color, plain, env = process3.env) {
369
+ const when = plain === true ? "never" : color ?? "auto";
370
+ if (when === "auto")
371
+ return;
372
+ if (when !== "always" && when !== "never")
373
+ badUsage(`--color takes auto, always or never, not ${when}`);
374
+ if (when === "always") {
375
+ env.FORCE_COLOR = "1";
376
+ delete env.NO_COLOR;
377
+ } else {
378
+ env.NO_COLOR = "1";
379
+ delete env.FORCE_COLOR;
380
+ }
381
+ }
359
382
  function parse(args, usage, options, settings = {}) {
360
- const all = { ...options, help: { type: "boolean", short: "h", description: "show this help" } };
383
+ const output = Object.fromEntries(Object.entries(OUTPUT_OPTIONS).filter(([name]) => !(name in options)));
384
+ const all = { ...options, ...output, help: { type: "boolean", short: "h", description: "show this help" } };
361
385
  if (completion.active)
362
386
  throw new Completing(all);
363
387
  const separator = settings.rest === true ? args.indexOf("--") : -1;
@@ -369,8 +393,10 @@ function parse(args, usage, options, settings = {}) {
369
393
  } catch (error) {
370
394
  badUsage(parseError(error));
371
395
  }
372
- if (parsed.values.help === true) {
373
- console.log(helpText(usage, all));
396
+ const values = parsed.values;
397
+ applyOutput("color" in output ? values.color : undefined, "plain" in output ? values.plain : undefined);
398
+ if (values.help === true) {
399
+ console.log(helpText(usage, all, undefined, settings.examples));
374
400
  throw new HelpShown;
375
401
  }
376
402
  if (settings.positionals !== undefined)
@@ -417,6 +443,37 @@ function distance(a, b) {
417
443
  }
418
444
 
419
445
  // ../kit/src/cli.ts
446
+ var LEVEL_OPTIONS = {
447
+ ...OUTPUT_OPTIONS,
448
+ help: { type: "boolean", short: "h", description: "show this help" }
449
+ };
450
+ var TOP_OPTIONS = { ...LEVEL_OPTIONS, version: { type: "boolean", short: "v", description: "print the version" } };
451
+ function commandRows(commands, colors) {
452
+ const width = Math.max(...Object.keys(commands).map((name) => name.length));
453
+ const groups = new Map([["Commands:", []]]);
454
+ for (const [name, { summary, group }] of Object.entries(commands)) {
455
+ const heading = group === undefined ? "Commands:" : `${group}:`;
456
+ const rows = groups.get(heading) ?? [];
457
+ rows.push(` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`);
458
+ groups.set(heading, rows);
459
+ }
460
+ return [...groups].filter(([, rows]) => rows.length > 0).flatMap(([heading, rows], index) => [...index === 0 ? [] : [""], colors.bold(heading), ...rows]);
461
+ }
462
+ function takeOutputFlags(argv) {
463
+ let index = 0;
464
+ for (;index < argv.length; index++) {
465
+ const word = argv[index];
466
+ if (word === "--plain")
467
+ applyOutput(undefined, true);
468
+ else if (word.startsWith("--color="))
469
+ applyOutput(word.slice("--color=".length), undefined);
470
+ else if (word === "--color" && argv[index + 1] !== undefined)
471
+ applyOutput(argv[++index], undefined);
472
+ else
473
+ break;
474
+ }
475
+ return argv.slice(index);
476
+ }
420
477
  function closest(name, names) {
421
478
  const started = names.find((known) => known.startsWith(name));
422
479
  if (started !== undefined)
@@ -424,20 +481,23 @@ function closest(name, names) {
424
481
  const [best] = names.map((known) => ({ known, far: distance(name, known) })).sort((a, b) => a.far - b.far);
425
482
  return best !== undefined && best.far <= (name.length < 4 ? 1 : 2) ? best.known : undefined;
426
483
  }
427
- async function help({ path, header, commands, defaultCommand }, stream) {
484
+ async function help({ path, header, commands, defaultCommand, examples }, stream) {
428
485
  const colors = paint(stream);
429
- const width = Math.max(...Object.keys(commands).map((name) => name.length));
430
486
  const prefix = path.join(" ");
431
487
  const fallback = defaultCommand === undefined ? undefined : commands[defaultCommand];
432
488
  const options = fallback !== undefined && "run" in fallback ? await optionsOf(fallback.run) : undefined;
489
+ const own = Object.entries(options ?? {}).filter(([name]) => !(name in TOP_OPTIONS));
433
490
  return [
434
491
  header(colors),
435
492
  "",
436
493
  usageLine(`${prefix} ${defaultCommand === undefined ? colors.cyan("<command>") : `[${colors.cyan("<command>")}]`} [options]`, colors),
437
494
  "",
438
- colors.bold("Commands:"),
439
- ...Object.entries(commands).map(([name, { summary }]) => ` ${colors.cyan(name.padEnd(width))} ${colors.dim(summary)}`),
440
- ...options === undefined || Object.keys(options).length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(options, colors)],
495
+ ...commandRows(commands, colors),
496
+ ...own.length === 0 ? [] : ["", `${colors.bold(`Options of ${defaultCommand}:`)} ${colors.dim("the command run when none is named")}`, ...optionRows(Object.fromEntries(own), colors)],
497
+ "",
498
+ colors.bold("Options:"),
499
+ ...optionRows(path.length === 1 ? TOP_OPTIONS : LEVEL_OPTIONS, colors),
500
+ ...exampleRows(examples ?? [], colors),
441
501
  "",
442
502
  `${colors.dim("Run")} ${prefix} ${colors.cyan("<command>")} --help ${colors.dim("for its options.")}`
443
503
  ].join(`
@@ -460,9 +520,15 @@ function reportThrown(prefix, error, redact = (text) => text) {
460
520
  printError(prefix, redact(named ?? (error instanceof Error ? error.message : String(error))), error instanceof Exit ? error.hint ?? help : undefined, cause);
461
521
  return error instanceof Exit ? error.code : 1;
462
522
  }
463
- async function dispatch(level, argv, { version, redact }) {
464
- const [name, ...args] = argv;
523
+ async function dispatch(level, typed, { version, redact }) {
465
524
  const prefix = level.path.join(" ");
525
+ let argv;
526
+ try {
527
+ argv = takeOutputFlags(typed);
528
+ } catch (error) {
529
+ return reportThrown(prefix, error);
530
+ }
531
+ const [name, ...args] = argv;
466
532
  if (name === undefined && level.path.length > 1) {
467
533
  console.error(await help(level, process4.stderr));
468
534
  return 2;
@@ -488,7 +554,7 @@ async function dispatch(level, argv, { version, redact }) {
488
554
  }
489
555
  if ("commands" in command) {
490
556
  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 });
557
+ return dispatch({ path: [...level.path, name], header: (colors) => `${colors.bold(`${prefix} ${name}`)} ${summary}`, commands: command.commands, aliases: command.aliases, defaultCommand: undefined, examples: command.examples }, rest, { version, redact });
492
558
  }
493
559
  try {
494
560
  return await command.run(rest);
@@ -534,7 +600,7 @@ async function printCandidates(found) {
534
600
  async function runCli(cli, argv = process4.argv.slice(2)) {
535
601
  if (argv[0] === COMPLETE)
536
602
  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 });
603
+ 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, examples: cli.examples }, argv, { version: cli.version, redact: cli.redact });
538
604
  }
539
605
  // ../kit/src/process.ts
540
606
  import { constants } from "os";
@@ -558,7 +624,7 @@ async function runCommand(argv, { cwd, env, stdin = "ignore" } = {}) {
558
624
  return { code: exitCodeOf(proc.exitCode, proc.signalCode), chunks, output, text: output.toString() };
559
625
  }
560
626
  // package.json
561
- var version = "0.2.1";
627
+ var version = "0.3.0";
562
628
 
563
629
  // ../kit/src/shell.ts
564
630
  var SEPARATORS = new Set([`
@@ -571,6 +637,7 @@ var LEADING_TILDE = /^~(?=\/|$)/;
571
637
  var LEADING_TABS = /^\t+/;
572
638
  var OPENERS = /^[({]+/;
573
639
  var CLOSERS = /[)}]+$/;
640
+ var DOUBLE_QUOTED_ESCAPES = new Set(["$", "`", '"', "\\"]);
574
641
  var HEREDOC = /^<<(-?)[ \t]*(?:'([^']*)'|"([^"]*)"|([^\s;&|<>()-][^\s;&|<>()]*))/;
575
642
  var WRAPPERS = new Set(["command", "env", "sudo", "exec", "nohup", "time"]);
576
643
  function separatorWidth(command, i) {
@@ -758,7 +825,9 @@ function words(text) {
758
825
  if (isContinuation)
759
826
  continue;
760
827
  isStarted = true;
761
- if (char === "\\" && quote !== "'")
828
+ if (char === "\\" && quote === '"' && !DOUBLE_QUOTED_ESCAPES.has(text[i + 1] ?? ""))
829
+ current += char;
830
+ else if (char === "\\" && quote !== "'")
762
831
  current += text[++i] ?? "";
763
832
  else if (quote !== null && char === quote)
764
833
  quote = null;
@@ -799,7 +868,10 @@ function parseCommand(text) {
799
868
  break;
800
869
  }
801
870
  }
802
- const last = (tokens.at(-1) ?? "").replace(CLOSERS, "");
871
+ const closers = CLOSERS.exec(text.trimEnd())?.[0].length ?? 0;
872
+ const word = tokens.at(-1) ?? "";
873
+ const trimmed = word.length - (CLOSERS.exec(word)?.[0].length ?? 0);
874
+ const last = word.slice(0, Math.max(trimmed, word.length - closers));
803
875
  if (tokens.length > 0)
804
876
  tokens.splice(-1, 1, ...last === "" ? [] : [last]);
805
877
  const [program = "", ...args] = tokens;
@@ -1129,13 +1201,14 @@ var TRAILER = /^(?:co-authored-by|signed-off-by|reviewed-by|acked-by|tested-by|r
1129
1201
  var BULLET = /^\s*(?:[-*+]|\d+[.)])\s/;
1130
1202
  var SENTENCE_END = /[.!?:;)\]`'"]$/;
1131
1203
  var LOWER_START = /^\p{Ll}/u;
1132
- var FORMAT = "A commit message is a `type(scope): title` line, then optionally a blank line, one or two lines on what changed and one `#<n> - <title>` line per ticket.";
1133
- var TITLE = `The title is no Conventional Commit, its type and scope taken from the repository's history. ${FORMAT}`;
1134
- var TITLE_LINE = `The title takes one line and a blank line follows it. ${FORMAT}`;
1135
- var TRAILERS = `No trailers, ever: no Co-Authored-By, Signed-off-by or "Generated with" line, no --signoff or --trailer. ${FORMAT}`;
1136
- var BULLETS = `The body names what changed in a line or two, never a bullet list narrating the diff. ${FORMAT}`;
1137
- var TOO_LONG = `The body holds at most two lines besides the ticket lines; what goes beyond belongs in the reply. ${FORMAT}`;
1138
- var WRAPPED = `A body sentence stays on one line however long it runs: never hard-wrap it. ${FORMAT}`;
1204
+ 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.';
1205
+ var TITLE = `The title is no Conventional Commit. ${RULE}`;
1206
+ var TITLE_LINE = `The title takes one line and a blank line follows it. ${RULE}`;
1207
+ var TRAILERS = `No trailers, ever: no Co-Authored-By, Signed-off-by or "Generated with" line, no --signoff or --trailer. ${RULE}`;
1208
+ var BULLETS = `The body names what changed in a line or two, never a bullet list narrating the diff. ${RULE}`;
1209
+ var TOO_LONG = `The body holds at most two lines besides the ticket lines; what goes beyond belongs in the reply. ${RULE}`;
1210
+ var WRAPPED = `A body sentence stays on one line however long it runs: never hard-wrap it. ${RULE}`;
1211
+ 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
1212
  function isDescription(line) {
1140
1213
  return line !== undefined && line !== "" && !TICKET.test(line);
1141
1214
  }
@@ -1449,7 +1522,7 @@ var COMMIT_VALUE_LONGS = new Set(["--message", "--file", "--reuse-message", "--r
1449
1522
  var SHORT_CLUSTER = /^-[A-Z]+$/i;
1450
1523
  var STAGES_ALL = /[Au]/;
1451
1524
  var ALL_TAGS = new Set(["--tags", "--follow-tags"]);
1452
- var STAGE_BY_PATH = "Stage by path (`git add <path>...`, then `git status`): the tree carries unrelated work and peer sessions edit it live.";
1525
+ var STAGE_BY_PATH = `Stage by path: the tree carries unrelated work and peer sessions edit it live. ${USE_GC}`;
1453
1526
  var STASH = "Never `git stash`: a pop with nothing saved applies someone else's old stash. To see how a test behaves without your change, run it at HEAD with `base -- <cmd>` (a temporary worktree, the working tree untouched), `--keep <path>` to carry the new test over, `--ref <rev>` for another commit.";
1454
1527
  var ADD_ALL = `\`git add\` over the whole tree is refused. ${STAGE_BY_PATH}`;
1455
1528
  var COMMIT_ALL = `\`git commit -a\` stages the whole tree and is refused. ${STAGE_BY_PATH}`;
@@ -1617,6 +1690,330 @@ function remoteViolation(command) {
1617
1690
  return splitCommand(command).some((piece) => isRemote(piece.command)) ? REMOTE : null;
1618
1691
  }
1619
1692
 
1693
+ // src/replace.ts
1694
+ var PYTHON = /^python[\d.]*$/;
1695
+ var SCRIPT_RUNNERS = new Set(["bun", "node"]);
1696
+ var EVAL_FLAGS = new Set(["-e", "--eval", "-p", "--print"]);
1697
+ var WRITES = /\bwrite_text\(|\bBun\.write\(|\bwriteFileSync\(|\bwriteFile\(|\bopen\([^)\n]*,\s*['"](?:w|a|r\+)/;
1698
+ var REPLACE_CALL = /\.(replace|replaceAll)\(/g;
1699
+ var FILE_CALL = /\b(?:Path|open|Bun\.file|Bun\.write|readFileSync|writeFileSync|readFile|writeFile)\(\s*/g;
1700
+ var IDENTIFIER = /^[A-Z_$][\w$]*/i;
1701
+ var COUNT_CHECK = /\.count\(/g;
1702
+ var COUNT_EQUALS = /^\s*\)\s*==\s*(\d+)/;
1703
+ var PYTHON_COUNT = /^\s*,\s*(\d+)\s*\)/;
1704
+ var SPACE2 = /^\s*/;
1705
+ var COMMA = /^\s*,\s*/;
1706
+ var REGEX_FLAGS = /^[gm]*$/;
1707
+ var PLAIN_WORD2 = /^[\w@%+=:,./*?-]+$/;
1708
+ var SED_SEPARATORS = /^[\s;]*/;
1709
+ var SED_SEPARATOR = /[\s;]/;
1710
+ var BRE_ESCAPED = new Map([["(", "("], [")", ")"], ["{", "{"], ["}", "}"], ["+", "+"], ["?", "?"], ["|", "|"]]);
1711
+ 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`]]);
1712
+ var ESCAPES = new Map([["n", `
1713
+ `], ["t", "\t"]]);
1714
+ var BRE_METAS = new Set([".", "[", "]", "*", "^", "$"]);
1715
+ var ERE_METAS = new Set([...BRE_METAS, "+", "?", "(", ")", "{", "}", "|"]);
1716
+ var JS_SPECIALS = /[.*+?^${}()|[\]\\]/g;
1717
+ var WHY = "writes nothing unless each file matches as often as expected, where it rewrites files whatever it matched";
1718
+ 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).";
1719
+ function literalAt(text, at, regex = false) {
1720
+ let start = at;
1721
+ const isRaw = text[start] === "r" || text[start] === "R";
1722
+ if (isRaw || text[start] === "b")
1723
+ start++;
1724
+ const quote = text[start] ?? "";
1725
+ if (regex && quote === "/")
1726
+ return regexAt(text, start);
1727
+ if (quote !== "'" && quote !== '"' && quote !== "`")
1728
+ return;
1729
+ const fence = text.startsWith(quote.repeat(3), start) ? quote.repeat(3) : quote;
1730
+ let value = "";
1731
+ for (let i = start + fence.length;i < text.length; i++) {
1732
+ const char = text[i] ?? "";
1733
+ if (text.startsWith(fence, i))
1734
+ return { value, end: i + fence.length };
1735
+ if (quote === "`" && text.startsWith("${", i))
1736
+ return;
1737
+ if (char === "\\" && !isRaw) {
1738
+ const next = text[++i] ?? "";
1739
+ value += ESCAPES.get(next) ?? (next === `
1740
+ ` ? "" : next);
1741
+ } else {
1742
+ value += char;
1743
+ }
1744
+ }
1745
+ return;
1746
+ }
1747
+ function regexAt(text, start) {
1748
+ let source = "";
1749
+ let inClass = false;
1750
+ for (let i = start + 1;i < text.length; i++) {
1751
+ const char = text[i] ?? "";
1752
+ if (char === `
1753
+ `)
1754
+ return;
1755
+ if (char === "\\") {
1756
+ source += char + (text[++i] ?? "");
1757
+ continue;
1758
+ }
1759
+ if (char === "/" && !inClass) {
1760
+ const flags = IDENTIFIER.exec(text.slice(i + 1))?.[0] ?? "";
1761
+ return { value: source, end: i + 1 + flags.length, flags };
1762
+ }
1763
+ if (char === "[" || char === "]")
1764
+ inClass = char === "[";
1765
+ source += char;
1766
+ }
1767
+ return;
1768
+ }
1769
+ function skip(text, at, pattern) {
1770
+ const found = pattern.exec(text.slice(at));
1771
+ return found === null ? undefined : at + found[0].length;
1772
+ }
1773
+ function valueAt(script, at, regex = false) {
1774
+ const literal = literalAt(script, at, regex);
1775
+ const name = literal === undefined ? IDENTIFIER.exec(script.slice(at))?.[0] : undefined;
1776
+ if (literal !== undefined || name === undefined)
1777
+ return literal;
1778
+ const assignments = [...script.slice(0, at).matchAll(new RegExp(String.raw`(?<![\w.$])${name.replaceAll("$", String.raw`\$`)}\s*=(?!=)\s*`, "g"))];
1779
+ const last = assignments.at(-1);
1780
+ const assigned = last === undefined ? undefined : literalAt(script, last.index + last[0].length, regex);
1781
+ return assigned === undefined ? undefined : { ...assigned, end: at + name.length };
1782
+ }
1783
+ function scriptPaths(script) {
1784
+ const paths = new Set;
1785
+ for (const call of script.matchAll(FILE_CALL)) {
1786
+ const path = valueAt(script, call.index + call[0].length)?.value;
1787
+ if (path !== undefined && path !== "")
1788
+ paths.add(path);
1789
+ }
1790
+ return [...paths];
1791
+ }
1792
+ function assertedCounts(script) {
1793
+ const counts = new Map;
1794
+ for (const call of script.matchAll(COUNT_CHECK)) {
1795
+ const text = valueAt(script, call.index + call[0].length);
1796
+ const equals = text === undefined ? null : COUNT_EQUALS.exec(script.slice(text.end));
1797
+ if (text !== undefined && equals !== null)
1798
+ counts.set(text.value, Number(equals[1]));
1799
+ }
1800
+ return counts;
1801
+ }
1802
+ function scriptPairs(script, isPython) {
1803
+ const pairs = [];
1804
+ const counts = isPython ? assertedCounts(script) : new Map;
1805
+ for (const call of script.matchAll(REPLACE_CALL)) {
1806
+ const from = valueAt(script, skip(script, call.index + call[0].length, SPACE2) ?? 0, !isPython);
1807
+ const comma = from === undefined ? undefined : skip(script, from.end, COMMA);
1808
+ const to = comma === undefined ? undefined : valueAt(script, comma);
1809
+ if (from === undefined || to === undefined)
1810
+ return;
1811
+ if (from.flags !== undefined) {
1812
+ if (!REGEX_FLAGS.test(from.flags))
1813
+ return;
1814
+ pairs.push({ from: from.value, to: to.value, regex: true, ...from.flags.includes("g") ? { all: true } : {} });
1815
+ continue;
1816
+ }
1817
+ const limit = isPython ? PYTHON_COUNT.exec(script.slice(to.end)) : null;
1818
+ const count = limit === null ? counts.get(from.value) : Number(limit[1]);
1819
+ const all = call[1] === "replaceAll" || isPython && count === undefined;
1820
+ const pair = { from: from.value, to: to.value };
1821
+ if (all)
1822
+ pair.all = true;
1823
+ else if (count !== undefined && count !== 1)
1824
+ pair.count = count;
1825
+ pairs.push(pair);
1826
+ }
1827
+ return pairs.length === 0 ? undefined : pairs;
1828
+ }
1829
+ function sedArgs(args) {
1830
+ const sed = { scripts: [], files: [], isExtended: false, isInPlace: false, isPlain: true };
1831
+ const operands = [];
1832
+ for (let i = 0;i < args.length; i++) {
1833
+ const arg = args[i] ?? "";
1834
+ if (arg === "--in-place" || arg.startsWith("--in-place="))
1835
+ sed.isInPlace = true;
1836
+ else if (arg === "--regexp-extended")
1837
+ sed.isExtended = true;
1838
+ else if (arg === "--expression")
1839
+ sed.scripts.push(args[++i] ?? "");
1840
+ else if (arg.startsWith("--expression="))
1841
+ sed.scripts.push(arg.slice("--expression=".length));
1842
+ else if (arg.startsWith("--"))
1843
+ sed.isPlain = false;
1844
+ else if (arg.startsWith("-") && arg.length > 1)
1845
+ i += sedCluster(arg, args[i + 1], sed);
1846
+ else
1847
+ operands.push(arg);
1848
+ }
1849
+ if (sed.scripts.length === 0)
1850
+ sed.scripts.push(operands.shift() ?? "");
1851
+ sed.files = operands;
1852
+ return sed;
1853
+ }
1854
+ function sedCluster(arg, next, sed) {
1855
+ for (const [index, flag] of [...arg.slice(1)].entries()) {
1856
+ if (flag === "i") {
1857
+ sed.isInPlace = true;
1858
+ return 0;
1859
+ }
1860
+ if (flag === "E" || flag === "r") {
1861
+ sed.isExtended = true;
1862
+ } else if (flag === "e") {
1863
+ const value = index === arg.length - 2 ? next : arg.slice(index + 2);
1864
+ sed.scripts.push(value ?? "");
1865
+ return index === arg.length - 2 ? 1 : 0;
1866
+ } else if (flag !== "s" && flag !== "u") {
1867
+ sed.isPlain = false;
1868
+ }
1869
+ }
1870
+ return 0;
1871
+ }
1872
+ function sedPart(script, at, delimiter) {
1873
+ for (let i = at;i < script.length; i++) {
1874
+ if (script[i] === "\\")
1875
+ i++;
1876
+ else if (script[i] === delimiter)
1877
+ return { part: script.slice(at, i), end: i + 1 };
1878
+ }
1879
+ return;
1880
+ }
1881
+ function sedLiteral(pattern, delimiter, metas) {
1882
+ let text = "";
1883
+ for (let i = 0;i < pattern.length; i++) {
1884
+ const char = pattern[i] ?? "";
1885
+ if (char !== "\\") {
1886
+ if (metas.has(char))
1887
+ return;
1888
+ text += char;
1889
+ continue;
1890
+ }
1891
+ const next = pattern[++i] ?? "";
1892
+ if (ESCAPES.has(next))
1893
+ text += ESCAPES.get(next);
1894
+ else if (next === delimiter || next === "\\" || BRE_METAS.has(next) || metas === ERE_METAS && ERE_METAS.has(next))
1895
+ text += next;
1896
+ else
1897
+ return;
1898
+ }
1899
+ return text;
1900
+ }
1901
+ function sedRegex(pattern, delimiter, isExtended) {
1902
+ let source = "";
1903
+ for (let i = 0;i < pattern.length; i++) {
1904
+ const char = pattern[i] ?? "";
1905
+ const posix = [...POSIX_CLASSES].find(([name]) => pattern.startsWith(name, i));
1906
+ if (posix !== undefined) {
1907
+ source += posix[1];
1908
+ i += posix[0].length - 1;
1909
+ } else if (char === "\\") {
1910
+ const next = pattern[++i] ?? "";
1911
+ if (next === delimiter)
1912
+ source += next.replaceAll(JS_SPECIALS, String.raw`\$&`);
1913
+ else
1914
+ source += !isExtended && BRE_ESCAPED.has(next) ? next : `\\${next}`;
1915
+ } else {
1916
+ source += !isExtended && BRE_ESCAPED.has(char) ? `\\${char}` : char;
1917
+ }
1918
+ }
1919
+ return source;
1920
+ }
1921
+ function sedReplacement(replacement, literal) {
1922
+ let text = "";
1923
+ for (let i = 0;i < replacement.length; i++) {
1924
+ const char = replacement[i] ?? "";
1925
+ if (char === "&") {
1926
+ text += literal ?? "$&";
1927
+ } else if (char === "$" && literal === undefined) {
1928
+ text += "$$";
1929
+ } else if (char === "\\") {
1930
+ const next = replacement[++i] ?? "";
1931
+ if (next >= "1" && next <= "9" && literal === undefined)
1932
+ text += `$${next}`;
1933
+ else if (next >= "0" && next <= "9")
1934
+ return;
1935
+ else
1936
+ text += ESCAPES.get(next) ?? (next === "$" && literal === undefined ? "$$" : next);
1937
+ } else {
1938
+ text += char;
1939
+ }
1940
+ }
1941
+ return text;
1942
+ }
1943
+ function sedPairs(script, isExtended) {
1944
+ const pairs = [];
1945
+ for (let at = 0;at < script.length; ) {
1946
+ at = skip(script, at, SED_SEPARATORS) ?? at;
1947
+ if (at >= script.length)
1948
+ break;
1949
+ const delimiter = script[at + 1] ?? "";
1950
+ if (script[at] !== "s" || delimiter === "" || delimiter === "\\" || delimiter === `
1951
+ `)
1952
+ return;
1953
+ const pattern = sedPart(script, at + 2, delimiter);
1954
+ const replacement = pattern === undefined ? undefined : sedPart(script, pattern.end, delimiter);
1955
+ if (pattern === undefined || replacement === undefined)
1956
+ return;
1957
+ const isGlobal = script[replacement.end] === "g";
1958
+ at = replacement.end + (isGlobal ? 1 : 0);
1959
+ if (at < script.length && !SED_SEPARATOR.test(script[at] ?? ""))
1960
+ return;
1961
+ const literal = sedLiteral(pattern.part, delimiter, isExtended ? ERE_METAS : BRE_METAS);
1962
+ const to = sedReplacement(replacement.part, literal);
1963
+ if (to === undefined)
1964
+ return;
1965
+ pairs.push({ from: literal ?? sedRegex(pattern.part, delimiter, isExtended), to, ...literal === undefined ? { regex: true } : {}, ...isGlobal ? { all: true } : {} });
1966
+ }
1967
+ return pairs.length === 0 ? undefined : pairs;
1968
+ }
1969
+ function quote(text) {
1970
+ return `'${text.replaceAll("'", String.raw`'\''`)}'`;
1971
+ }
1972
+ function fileWord(path) {
1973
+ return PLAIN_WORD2.test(path) ? path : quote(path);
1974
+ }
1975
+ function subCommand(pairs, files) {
1976
+ const targets = files.length === 0 ? "<file>" : files.map(fileWord).join(" ");
1977
+ const [pair] = pairs;
1978
+ if (pairs.length > 1 || pair === undefined)
1979
+ return `sub --pairs - ${targets} <<'PAIRS'
1980
+ ${JSON.stringify(pairs)}
1981
+ PAIRS`;
1982
+ const flags = [pair.regex === true ? " --regex" : "", pair.all === true ? " --all" : "", pair.count === undefined ? "" : ` -n ${pair.count}`].join("");
1983
+ return `sub --from ${quote(pair.from)} --to ${quote(pair.to)}${flags} ${targets}`;
1984
+ }
1985
+ function route(what, pairs, files) {
1986
+ return pairs === undefined ? `${what} ${GENERIC}` : `${what} Run this instead, which ${WHY}:
1987
+ ${subCommand(pairs, files)}`;
1988
+ }
1989
+ function inlineScript(program, args, text) {
1990
+ const flags = PYTHON.test(program) ? new Set(["-c"]) : EVAL_FLAGS;
1991
+ const flag = args.findIndex((arg) => flags.has(arg));
1992
+ if (flag !== -1)
1993
+ return args[flag + 1] ?? null;
1994
+ const reads = args.every((arg) => arg === "-" || arg.startsWith("<<") || program === "bun" && arg === "run");
1995
+ return reads ? heredocBody(text, HEREDOC_OPENER)?.body ?? null : null;
1996
+ }
1997
+ function replaceViolation(command) {
1998
+ for (const piece of splitCommand(command)) {
1999
+ const { program, args } = parseCommand(piece.command);
2000
+ if (program === "sed") {
2001
+ const sed = sedArgs(args);
2002
+ if (sed.isInPlace)
2003
+ return route("`sed -i` rewrites files without checking what it matched.", sed.isPlain && sed.scripts.length > 0 ? sedPairs(sed.scripts.join(`
2004
+ `), sed.isExtended) : undefined, sed.files);
2005
+ continue;
2006
+ }
2007
+ const isPython = PYTHON.test(program);
2008
+ const script = isPython || SCRIPT_RUNNERS.has(program) ? inlineScript(program, args, piece.text) : null;
2009
+ if (script === null || !WRITES.test(script) || script.search(REPLACE_CALL) === -1)
2010
+ continue;
2011
+ const pairs = scriptPairs(script, isPython);
2012
+ return route(`A ${isPython ? "python" : program} script replacing text in a file is what \`sub\` does with a match-count check.`, pairs, scriptPaths(script));
2013
+ }
2014
+ return null;
2015
+ }
2016
+
1620
2017
  // src/check.ts
1621
2018
  function absolute(path, cwd) {
1622
2019
  return path.startsWith("/") ? path : `${cwd}/${path}`;
@@ -1668,9 +2065,11 @@ async function checkCommand(command, context) {
1668
2065
  const first = (messages) => messages.find((message) => message !== null) ?? null;
1669
2066
  add("remote", first(run.map(remoteViolation)));
1670
2067
  add("git", first(run.map((each) => gitViolation(each, context.isPushAsked))));
1671
- add("commit", commitViolation(command, context));
2068
+ const commit = commitViolation(command, context);
2069
+ add("commit", commit === null ? null : `${commit} ${USE_GC}`);
1672
2070
  add("sql", first(await Promise.all(run.map(async (each) => sqlViolation(each, async (path) => Promise.resolve(context.read(absolute(path, context.cwd))))))));
1673
2071
  add("env", touchesEnv(command) ? USE_EV : null);
2072
+ add("edit", context.canSub ? first(run.map(replaceViolation)) : null);
1674
2073
  const found = mismatches(command, context);
1675
2074
  const refused = found.find((mismatch) => mismatch.rewrite === null || !context.canRewrite);
1676
2075
  const rewrite = refused?.rewrite ?? null;
@@ -1696,14 +2095,14 @@ var BINS = {
1696
2095
  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
2096
  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
2097
  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.",
2098
+ 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
2099
  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
2100
  ft: "`ft`: Forgejo CI runs, logs and waits, releases, issue checklists and claims, in a repo whose origin is on Forgejo.",
1702
2101
  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
2102
  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).",
1704
2103
  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
2104
  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 or the URL answers 2xx, exits 1 on a --fail line with the lines around it, 2 on a timeout with the last lines; use it instead of sleep and tail or polling loops.",
2105
+ 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
2106
  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.",
1708
2107
  out: "`out -- <cmd>`: folds long output, keeping the errors and saving the full log; run noisy builds, installs, tests and linters through it."
1709
2108
  };
@@ -1740,12 +2139,13 @@ async function bashCall(command, input, machine) {
1740
2139
  isPushAsked: file !== null && machine.exists(file),
1741
2140
  canRewrite: false,
1742
2141
  canWrap: false,
2142
+ canSub: machine.has("sub"),
1743
2143
  read: machine.read,
1744
2144
  exists: machine.exists
1745
2145
  });
1746
2146
  if (verdict.violations.length > 0 || verdict.packages.length === 0 || !machine.has("pkg"))
1747
2147
  return deny(verdict.violations);
1748
- const checked = await machine.run(["pkg", ...verdict.packages], cwd);
2148
+ const checked = await machine.run(["pkg", "check", ...verdict.packages], cwd);
1749
2149
  return checked.code === 1 ? deny([{ rule: "pkg", message: `pkg refused what this adds, so it was not run:
1750
2150
  ${checked.text.trim()}` }]) : null;
1751
2151
  }
@@ -1778,7 +2178,7 @@ async function hook(event, input, machine) {
1778
2178
  // src/cli.ts
1779
2179
  var checkUsage = `guard check [options] [<command> | -]
1780
2180
 
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 package managers.
2181
+ 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
2182
  Prints each broken rule with the route to take instead and exits 1, prints nothing and exits 0 when the command is clean.
1783
2183
  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>\`.`;
1784
2184
  var checkOptions = {
@@ -1824,6 +2224,7 @@ ${checkUsage}`);
1824
2224
  isPushAsked: values["push-asked"] === true,
1825
2225
  canRewrite: true,
1826
2226
  canWrap: values["no-wrap"] !== true && Bun.which("out") !== null,
2227
+ canSub: Bun.which("sub") !== null,
1827
2228
  read: readText,
1828
2229
  exists: existsSync
1829
2230
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@kaynooo/guard-tools",
3
3
  "type": "module",
4
- "version": "0.2.1",
4
+ "version": "0.3.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
6
  "author": "Kaynooo <kaynooo@kevyn.fr>",
7
7
  "license": "MIT",
@@ -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:*",