@codyswann/lisa 4.73.1 → 4.73.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/all/copy-overwrite/scripts/lib/history-secret-git.mjs +158 -5
  2. package/all/copy-overwrite/scripts/lib/npm-update-contract.mjs +25 -1
  3. package/all/copy-overwrite/scripts/lisa-history-secrets.mjs +17 -3
  4. package/all/copy-overwrite/scripts/lisa-hooks/block-blind-automerge.sh +86 -7
  5. package/all/copy-overwrite/scripts/lisa-hooks/block-managed-file-edits.sh +7 -1
  6. package/all/copy-overwrite/scripts/lisa-hooks/block-no-verify.sh +62 -7
  7. package/all/copy-overwrite/scripts/lisa-rails-prepush.mjs +4 -1
  8. package/all/copy-overwrite/scripts/lisa-work-item.mjs +191 -5
  9. package/all/copy-overwrite/scripts/npm-updater-helper-graph.json +5 -5
  10. package/all/deletions.json +19 -2
  11. package/dist/codex/scripts/block-no-verify.sh +62 -7
  12. package/dist/core/config.d.ts +16 -0
  13. package/dist/core/config.d.ts.map +1 -1
  14. package/dist/core/config.js.map +1 -1
  15. package/dist/core/lisa-owned-hash-ledger.d.ts.map +1 -1
  16. package/dist/core/lisa-owned-hash-ledger.js +78 -0
  17. package/dist/core/lisa-owned-hash-ledger.js.map +1 -1
  18. package/dist/core/lisa.d.ts +31 -0
  19. package/dist/core/lisa.d.ts.map +1 -1
  20. package/dist/core/lisa.js +69 -4
  21. package/dist/core/lisa.js.map +1 -1
  22. package/dist/core/nightly-e2e-guard-behavior-certificate.js +2 -2
  23. package/dist/core/retire-unmodified.d.ts +66 -0
  24. package/dist/core/retire-unmodified.d.ts.map +1 -0
  25. package/dist/core/retire-unmodified.js +244 -0
  26. package/dist/core/retire-unmodified.js.map +1 -0
  27. package/dist/core/upstream-evidence-manifest.d.ts.map +1 -1
  28. package/dist/core/upstream-evidence-manifest.js +22 -16
  29. package/dist/core/upstream-evidence-manifest.js.map +1 -1
  30. package/dist/opencode/plugin-templates/block-managed-file-edits.sh +7 -1
  31. package/dist/opencode/plugin-templates/block-no-verify.sh +62 -7
  32. package/package.json +4 -4
  33. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  34. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  35. package/plugins/lisa/hooks/auto-update.mjs +69 -1
  36. package/plugins/lisa/hooks/block-blind-automerge.sh +86 -7
  37. package/plugins/lisa/hooks/block-managed-file-edits.sh +7 -1
  38. package/plugins/lisa/hooks/block-no-verify.agy.sh +62 -7
  39. package/plugins/lisa/hooks/block-no-verify.sh +62 -7
  40. package/plugins/lisa-agy/hooks/block-blind-automerge.sh +86 -7
  41. package/plugins/lisa-agy/hooks/block-managed-file-edits.sh +7 -1
  42. package/plugins/lisa-agy/hooks/block-no-verify.agy.sh +62 -7
  43. package/plugins/lisa-agy/plugin.json +1 -1
  44. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  45. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  46. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  47. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  49. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  50. package/plugins/lisa-copilot/hooks/auto-update.mjs +69 -1
  51. package/plugins/lisa-copilot/hooks/block-blind-automerge.sh +86 -7
  52. package/plugins/lisa-copilot/hooks/block-managed-file-edits.sh +7 -1
  53. package/plugins/lisa-copilot/hooks/block-no-verify.sh +62 -7
  54. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  55. package/plugins/lisa-cursor/hooks/auto-update.mjs +69 -1
  56. package/plugins/lisa-cursor/hooks/block-blind-automerge.sh +86 -7
  57. package/plugins/lisa-cursor/hooks/block-managed-file-edits.sh +7 -1
  58. package/plugins/lisa-cursor/hooks/block-no-verify.sh +62 -7
  59. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  60. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  61. package/plugins/lisa-expo-agy/plugin.json +1 -1
  62. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  64. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  65. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  66. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  67. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  68. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  69. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  70. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  71. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  72. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  73. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  74. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  75. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  76. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  77. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  78. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  79. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  80. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  81. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  82. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  83. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  84. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  85. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  86. package/plugins/lisa-rails-agy/plugin.json +1 -1
  87. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  88. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  89. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  90. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  91. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  92. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  93. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  94. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  95. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  96. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  97. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  98. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  99. package/plugins/src/base/hooks/auto-update.mjs +69 -1
  100. package/plugins/src/base/hooks/block-blind-automerge.sh +86 -7
  101. package/plugins/src/base/hooks/block-managed-file-edits.sh +7 -1
  102. package/plugins/src/base/hooks/block-no-verify.agy.sh +62 -7
  103. package/plugins/src/base/hooks/block-no-verify.sh +62 -7
  104. package/scripts/check-shipped-surface-removals.mjs +22 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-phaser",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Phaser 4 game-development rules for TypeScript projects",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Ruby on Rails-specific skills and hooks for RuboCop and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-rails",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Ruby on Rails-specific hooks — RuboCop linting/formatting and ast-grep scanning on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "TypeScript-specific hooks for formatting, linting, and ast-grep scanning on edit.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-typescript",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "TypeScript-specific hooks — Prettier formatting, ESLint linting, ast-grep scanning, and error-suppression blocking on edit",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "Distributable LLM Wiki kernel — ingest, query, lint, and maintain a git-native markdown knowledge base across Claude and Codex.",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lisa-wiki",
3
- "version": "4.73.1",
3
+ "version": "4.73.2",
4
4
  "description": "LLM Wiki — a distributable, git-native markdown knowledge base for Claude Code and Codex",
5
5
  "author": {
6
6
  "name": "Cody Swann"
@@ -42,6 +42,7 @@ import { execFile } from "child_process";
42
42
  import {
43
43
  existsSync,
44
44
  lstatSync,
45
+ readlinkSync,
45
46
  mkdirSync,
46
47
  openSync,
47
48
  closeSync,
@@ -580,6 +581,70 @@ async function applyUpdate(ctx, update) {
580
581
  }
581
582
  }
582
583
 
584
+ /**
585
+ * What each pending file holds right now, so the later commit can prove it is
586
+ * still committing the update's bytes and not an edit made since
587
+ * (CodySwannGT/lisa#4393).
588
+ *
589
+ * A path maps to `"<git mode> <identity>"`, or to null when it is absent (the
590
+ * update deleted it). A regular file is `100644`/`100755` plus its
591
+ * `git hash-object` blob id, so an executable-bit change alone is caught. A
592
+ * symlink is `120000 link:<target>`: hash-object follows a link and hashes what
593
+ * it reaches, so a link retargeted at identical bytes would otherwise read as
594
+ * unchanged. Anything else (a directory, a submodule) is left OUT: it cannot be
595
+ * proved, and the reader treats a missing entry as "differs", so it never
596
+ * auto-commits on it. `lisa-work-item.mjs` recomputes these the same way.
597
+ * @param {(argv: string[], options: object) => Promise<string>} run Runner.
598
+ * @param {string} cwd Project directory.
599
+ * @param {readonly string[]} files Pending paths.
600
+ * @returns {Promise<Record<string, string | null> | null>} Digests, or null when they could not be read.
601
+ */
602
+ export async function workingTreeDigests(run, cwd, files) {
603
+ const digests = {};
604
+ const present = [];
605
+ for (const file of files) {
606
+ let stat;
607
+ try {
608
+ stat = lstatSync(path.join(cwd, file));
609
+ } catch {
610
+ digests[file] = null;
611
+ continue;
612
+ }
613
+ if (stat.isSymbolicLink()) {
614
+ // A link that vanishes before it can be read is left out, which the
615
+ // reader treats as "differs".
616
+ try {
617
+ digests[file] = `120000 link:${readlinkSync(path.join(cwd, file))}`;
618
+ } catch {
619
+ continue;
620
+ }
621
+ } else if (stat.isFile())
622
+ present.push([file, stat.mode & 0o111 ? "100755" : "100644"]);
623
+ }
624
+ if (present.length > 0) {
625
+ const ids = (
626
+ await run(
627
+ ["git", "hash-object", "--", ...present.map(([file]) => file)],
628
+ {
629
+ cwd,
630
+ }
631
+ )
632
+ )
633
+ .split("\n")
634
+ .map(line => line.trim())
635
+ .filter(Boolean);
636
+ if (
637
+ ids.length !== present.length ||
638
+ !ids.every(id => /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/u.test(id))
639
+ )
640
+ return null;
641
+ present.forEach(([file, mode], index) => {
642
+ digests[file] = `${mode} ${ids[index]}`;
643
+ });
644
+ }
645
+ return digests;
646
+ }
647
+
583
648
  /**
584
649
  * Commit the update now, or leave it pending for the first work-item binding.
585
650
  * @param {object} ctx Context.
@@ -635,10 +700,13 @@ async function settle(ctx, update) {
635
700
  projectDir,
636
701
  await gitPath("lisa/pending-update.json")
637
702
  );
703
+ const digests = await workingTreeDigests(run, projectDir, changed).catch(
704
+ () => null
705
+ );
638
706
  mkdirSync(path.dirname(marker), { recursive: true });
639
707
  writeFileSync(
640
708
  marker,
641
- `${JSON.stringify({ from: update.from, to: update.to, files: changed }, null, 2)}\n`
709
+ `${JSON.stringify({ from: update.from, to: update.to, files: changed, ...(digests ? { digests } : {}) }, null, 2)}\n`
642
710
  );
643
711
  return `It is NOT committed yet (${decision.reason}). The ${changed.length} changed file(s) are left in the working tree and recorded as a pending update; binding a work item on a feature branch (\`lisa-work-item.mjs link\` / \`attach-branch\`, which /lisa:track runs) commits them first, as their own commit. Do not fold these files into a feature commit.`;
644
712
  }
@@ -208,6 +208,7 @@ if [ -z "$command_str" ]; then
208
208
  fi
209
209
 
210
210
  if ! BLOCK_BLIND_AUTOMERGE_COMMAND="$command_str" python3 - <<'PY'
211
+ import io
211
212
  import json
212
213
  import os
213
214
  import re
@@ -241,7 +242,41 @@ MERGE_SEPARATE_VALUE = {
241
242
 
242
243
  COMMAND_SEPARATORS = {
243
244
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
245
+ # `|&` pipes stdout AND stderr into the next command, so it ends a command
246
+ # exactly as `|` does. shlex emits it as one token, and before it was listed
247
+ # here `echo hi |& git commit --no-verify` read as a single `echo` command
248
+ # whose arguments happened to include git. The case terminators end a
249
+ # command the same way.
250
+ "|&", ";;", ";&", ";;&",
244
251
  }
252
+ # shlex also GLUES adjacent punctuation into one token across distinct
253
+ # operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
254
+ # test can recognise. An operator-only token is therefore re-split into the
255
+ # operators it is made of, longest first. Redirections are kept whole and are
256
+ # deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
257
+ # commit's argv scope.
258
+ OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
259
+ SHELL_OPERATOR = re.compile("|".join(
260
+ re.escape(operator) for operator in sorted(
261
+ COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
262
+ key=lambda operator: (-len(operator), operator),
263
+ )
264
+ ))
265
+
266
+
267
+ def split_operators(token):
268
+ """Split a glued operator-only token into the shell operators it holds.
269
+
270
+ Args:
271
+ token: One shlex token that was not read inside a quote or escape.
272
+
273
+ Returns:
274
+ The operators, longest first, or the token itself when it is not
275
+ made only of shell punctuation.
276
+ """
277
+ if OPERATOR_ONLY.match(token):
278
+ return SHELL_OPERATOR.findall(token)
279
+ return [token]
245
280
 
246
281
  # Shell wrappers whose `-c` argument is a command in a string rather than argv.
247
282
  # The outer argv names bash, so the arming is invisible without recursing once.
@@ -419,12 +454,40 @@ def shell_tokens(text):
419
454
  Returns:
420
455
  The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
421
456
  """
422
- lexer = shlex.shlex(
423
- line_boundaries_as_separators(text), posix=True, punctuation_chars=True
424
- )
457
+ stream = OperatorStream(line_boundaries_as_separators(text))
458
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
425
459
  lexer.whitespace_split = True
426
460
  lexer.commenters = ""
427
- return list(lexer)
461
+ stream.lexer = lexer
462
+ tokens = []
463
+ while True:
464
+ stream.literal_operator = False
465
+ token = lexer.get_token()
466
+ if token is None:
467
+ return tokens
468
+ # Quoted punctuation is an argument, never a boundary: a branch can be
469
+ # named `|&`, and `gh pr merge '|&' --auto` must keep `--auto` in the
470
+ # merge's own argv. Only real operator runs are split.
471
+ if stream.literal_operator:
472
+ tokens.append(
473
+ chr(0) + token if token in COMMAND_SEPARATORS else token
474
+ )
475
+ else:
476
+ tokens.extend(split_operators(token))
477
+
478
+
479
+ class OperatorStream(io.StringIO):
480
+ """Remember punctuation read while shlex is inside a quote or escape."""
481
+
482
+ lexer = None
483
+ literal_operator = False
484
+
485
+ def read(self, size=-1):
486
+ value = super().read(size)
487
+ if (self.lexer is not None and self.lexer.state in {"'", '"', chr(92)}
488
+ and any(char in "();|&<>" for char in value)):
489
+ self.literal_operator = True
490
+ return value
428
491
 
429
492
 
430
493
  def is_gh(token):
@@ -490,6 +553,22 @@ def arms_auto_merge(argv):
490
553
  return False
491
554
 
492
555
 
556
+ def literal_value(token):
557
+ """The argument a token stands for, without the quoted-separator marker.
558
+
559
+ `shell_tokens` prefixes a QUOTED separator with NUL so no scan stops at it.
560
+ That marker is for boundary checks only: a value handed to a gh probe
561
+ (a selector, a base ref, a repo) must be the argument the shell passes.
562
+
563
+ Args:
564
+ token: One token from `shell_tokens`.
565
+
566
+ Returns:
567
+ The token as the shell would pass it.
568
+ """
569
+ return token[1:] if token.startswith(chr(0)) else token
570
+
571
+
493
572
  def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
494
573
  """The PR the command names, as gh itself would read it.
495
574
 
@@ -515,7 +594,7 @@ def pr_selector(argv, separate_value=MERGE_SEPARATE_VALUE):
515
594
  index += 1
516
595
  continue
517
596
  if not token.startswith("-"):
518
- return token
597
+ return literal_value(token)
519
598
  return None
520
599
 
521
600
 
@@ -535,7 +614,7 @@ def retarget_target(argv):
535
614
  if token in COMMAND_SEPARATORS:
536
615
  return None
537
616
  if token in BASE_FLAGS and index < len(argv):
538
- return argv[index]
617
+ return literal_value(argv[index])
539
618
  if token.startswith("--base="):
540
619
  return token.split("=", 1)[1]
541
620
  if token in EDIT_SEPARATE_VALUE:
@@ -559,7 +638,7 @@ def repo_flag(tokens, start):
559
638
  if token in COMMAND_SEPARATORS:
560
639
  break
561
640
  if token in {"-R", "--repo"} and index + 1 < len(tokens):
562
- return ["--repo", tokens[index + 1]]
641
+ return ["--repo", literal_value(tokens[index + 1])]
563
642
  if token.startswith("--repo="):
564
643
  return ["--repo", token.split("=", 1)[1]]
565
644
  index += 1
@@ -594,7 +594,13 @@ ENV_SPLIT_UNRESOLVED = 42
594
594
  BACKSLASH = chr(92)
595
595
  NEWLINE = chr(10)
596
596
 
597
- STATEMENT_SEPARATORS = {";", "|", "||", "&", "&&", "(", ")", "&|"}
597
+ # `|&` pipes stdout and stderr into the next command, and the case terminators
598
+ # end a statement as `;` does. Without them `printf x |& tee <managed>` stayed
599
+ # one statement whose command word was `printf`, so the `tee` write target was
600
+ # never examined.
601
+ STATEMENT_SEPARATORS = {
602
+ ";", "|", "||", "&", "&&", "(", ")", "&|", "|&", ";;", ";&", ";;&",
603
+ }
598
604
  # Ordinary output, noclobber override, and combined stdout/stderr output.
599
605
  # shlex separates fd prefixes (e.g. 10>>) from the operator automatically.
600
606
  REDIRECT_OUT = {">", ">>", ">|", "&>", "&>>", ">&"}
@@ -170,7 +170,41 @@ def disables_verification(token):
170
170
  # tokens, so the invocation's boundary is a token the scan can stop at.
171
171
  COMMAND_SEPARATORS = {
172
172
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
173
+ # `|&` pipes stdout AND stderr into the next command, so it ends a command
174
+ # exactly as `|` does. shlex emits it as one token, and before it was listed
175
+ # here `echo hi |& git commit --no-verify` read as a single `echo` command
176
+ # whose arguments happened to include git. The case terminators end a
177
+ # command the same way.
178
+ "|&", ";;", ";&", ";;&",
173
179
  }
180
+ # shlex also GLUES adjacent punctuation into one token across distinct
181
+ # operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
182
+ # test can recognise. An operator-only token is therefore re-split into the
183
+ # operators it is made of, longest first. Redirections are kept whole and are
184
+ # deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
185
+ # commit's argv scope.
186
+ OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
187
+ SHELL_OPERATOR = re.compile("|".join(
188
+ re.escape(operator) for operator in sorted(
189
+ COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
190
+ key=lambda operator: (-len(operator), operator),
191
+ )
192
+ ))
193
+
194
+
195
+ def split_operators(token):
196
+ """Split a glued operator-only token into the shell operators it holds.
197
+
198
+ Args:
199
+ token: One shlex token that was not read inside a quote or escape.
200
+
201
+ Returns:
202
+ The operators, longest first, or the token itself when it is not
203
+ made only of shell punctuation.
204
+ """
205
+ if OPERATOR_ONLY.match(token):
206
+ return SHELL_OPERATOR.findall(token)
207
+ return [token]
174
208
 
175
209
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
176
210
  MAX_SHELL_NESTING = 8
@@ -324,14 +358,28 @@ def shell_tokens(text):
324
358
  Returns:
325
359
  The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
326
360
  """
327
- lexer = shlex.shlex(
328
- line_boundaries_as_separators(text), posix=True, punctuation_chars=True
329
- )
361
+ stream = OperatorStream(line_boundaries_as_separators(text))
362
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
330
363
  lexer.whitespace_split = True
331
364
  # shlex treats `#` as a comment introducer by default and would silently
332
365
  # truncate the rest of the line; `shlex.split` disables it, and so must this.
333
366
  lexer.commenters = ""
334
- return list(lexer)
367
+ stream.lexer = lexer
368
+ tokens = []
369
+ while True:
370
+ stream.literal_operator = False
371
+ token = lexer.get_token()
372
+ if token is None:
373
+ return tokens
374
+ # Quoted punctuation is an argument, never a boundary: it stays whole,
375
+ # and a quoted separator is marked so no scan stops at it (a path or
376
+ # branch can be named `|&`). Only real operator runs are split.
377
+ if stream.literal_operator:
378
+ tokens.append(
379
+ chr(0) + token if token in COMMAND_SEPARATORS else token
380
+ )
381
+ else:
382
+ tokens.extend(split_operators(token))
335
383
 
336
384
 
337
385
  def cluster_skips_verification(cluster):
@@ -947,6 +995,10 @@ def resolve_script(token):
947
995
  """
948
996
  if COMPUTED_VALUE.search(token):
949
997
  return (None, "a computed path the guard cannot resolve before the shell does")
998
+ # A quoted separator carries a NUL marker from shell_tokens; the path the
999
+ # shell passes is the argument without it.
1000
+ if token.startswith(chr(0)):
1001
+ token = token[1:]
950
1002
  text = os.path.expanduser(token.strip().strip("'\""))
951
1003
  # `bash -` and a bare `-` read the script from stdin; there is no file.
952
1004
  if not text or text == "-":
@@ -1897,9 +1949,12 @@ def flat_tokens(text):
1897
1949
  # shlex otherwise makes quoted ";" indistinguishable from a real
1898
1950
  # command boundary. Keep literal destinations as data, so a setter
1899
1951
  # cannot masquerade as a one-operand configuration read.
1900
- if token in COMMAND_SEPARATORS and stream.literal_operator:
1901
- token = "\x00" + token
1902
- tokens.append(token)
1952
+ if stream.literal_operator:
1953
+ if token in COMMAND_SEPARATORS:
1954
+ token = "\x00" + token
1955
+ tokens.append(token)
1956
+ else:
1957
+ tokens.extend(split_operators(token))
1903
1958
  return [
1904
1959
  token
1905
1960
  for index, token in enumerate(tokens)
@@ -268,7 +268,41 @@ def disables_verification(token):
268
268
  # tokens, so the invocation's boundary is a token the scan can stop at.
269
269
  COMMAND_SEPARATORS = {
270
270
  ";", "|", "||", "&", "&&", "(", ")", "<", ">", ">>", "<<", "&|",
271
+ # `|&` pipes stdout AND stderr into the next command, so it ends a command
272
+ # exactly as `|` does. shlex emits it as one token, and before it was listed
273
+ # here `echo hi |& git commit --no-verify` read as a single `echo` command
274
+ # whose arguments happened to include git. The case terminators end a
275
+ # command the same way.
276
+ "|&", ";;", ";&", ";;&",
271
277
  }
278
+ # shlex also GLUES adjacent punctuation into one token across distinct
279
+ # operators: `(echo hi)|&git ...` arrives as `)|&`, which no set membership
280
+ # test can recognise. An operator-only token is therefore re-split into the
281
+ # operators it is made of, longest first. Redirections are kept whole and are
282
+ # deliberately NOT separators, so `git commit 2>&1 -n` keeps its `-n` inside the
283
+ # commit's argv scope.
284
+ OPERATOR_ONLY = re.compile(r"^[();<>|&]+$")
285
+ SHELL_OPERATOR = re.compile("|".join(
286
+ re.escape(operator) for operator in sorted(
287
+ COMMAND_SEPARATORS | {">|", "&>", "&>>", ">&", "<&", "<>", "<<<"},
288
+ key=lambda operator: (-len(operator), operator),
289
+ )
290
+ ))
291
+
292
+
293
+ def split_operators(token):
294
+ """Split a glued operator-only token into the shell operators it holds.
295
+
296
+ Args:
297
+ token: One shlex token that was not read inside a quote or escape.
298
+
299
+ Returns:
300
+ The operators, longest first, or the token itself when it is not
301
+ made only of shell punctuation.
302
+ """
303
+ if OPERATOR_ONLY.match(token):
304
+ return SHELL_OPERATOR.findall(token)
305
+ return [token]
272
306
 
273
307
  SHELL_PROGRAMS = {"bash", "dash", "ksh", "sh", "zsh"}
274
308
  MAX_SHELL_NESTING = 8
@@ -422,14 +456,28 @@ def shell_tokens(text):
422
456
  Returns:
423
457
  The token list, with `;`, `|`, `&&`, `(` and friends standing alone.
424
458
  """
425
- lexer = shlex.shlex(
426
- line_boundaries_as_separators(text), posix=True, punctuation_chars=True
427
- )
459
+ stream = OperatorStream(line_boundaries_as_separators(text))
460
+ lexer = shlex.shlex(stream, posix=True, punctuation_chars=True)
428
461
  lexer.whitespace_split = True
429
462
  # shlex treats `#` as a comment introducer by default and would silently
430
463
  # truncate the rest of the line; `shlex.split` disables it, and so must this.
431
464
  lexer.commenters = ""
432
- return list(lexer)
465
+ stream.lexer = lexer
466
+ tokens = []
467
+ while True:
468
+ stream.literal_operator = False
469
+ token = lexer.get_token()
470
+ if token is None:
471
+ return tokens
472
+ # Quoted punctuation is an argument, never a boundary: it stays whole,
473
+ # and a quoted separator is marked so no scan stops at it (a path or
474
+ # branch can be named `|&`). Only real operator runs are split.
475
+ if stream.literal_operator:
476
+ tokens.append(
477
+ chr(0) + token if token in COMMAND_SEPARATORS else token
478
+ )
479
+ else:
480
+ tokens.extend(split_operators(token))
433
481
 
434
482
 
435
483
  def cluster_skips_verification(cluster):
@@ -1045,6 +1093,10 @@ def resolve_script(token):
1045
1093
  """
1046
1094
  if COMPUTED_VALUE.search(token):
1047
1095
  return (None, "a computed path the guard cannot resolve before the shell does")
1096
+ # A quoted separator carries a NUL marker from shell_tokens; the path the
1097
+ # shell passes is the argument without it.
1098
+ if token.startswith(chr(0)):
1099
+ token = token[1:]
1048
1100
  text = os.path.expanduser(token.strip().strip("'\""))
1049
1101
  # `bash -` and a bare `-` read the script from stdin; there is no file.
1050
1102
  if not text or text == "-":
@@ -1995,9 +2047,12 @@ def flat_tokens(text):
1995
2047
  # shlex otherwise makes quoted ";" indistinguishable from a real
1996
2048
  # command boundary. Keep literal destinations as data, so a setter
1997
2049
  # cannot masquerade as a one-operand configuration read.
1998
- if token in COMMAND_SEPARATORS and stream.literal_operator:
1999
- token = "\x00" + token
2000
- tokens.append(token)
2050
+ if stream.literal_operator:
2051
+ if token in COMMAND_SEPARATORS:
2052
+ token = "\x00" + token
2053
+ tokens.append(token)
2054
+ else:
2055
+ tokens.extend(split_operators(token))
2001
2056
  return [
2002
2057
  token
2003
2058
  for index, token in enumerate(tokens)