jackherd 1.7.1 → 1.8.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 (153) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/INSTALL.md +2 -0
  3. package/{NOTICE → NOTICE.txt} +1 -1
  4. package/README.md +94 -1033
  5. package/assets/icons/bark-dark.svg +1 -1
  6. package/assets/icons/bark.svg +1 -1
  7. package/assets/icons/bowl-dark.svg +1 -1
  8. package/assets/icons/bowl.svg +1 -1
  9. package/assets/icons/decide-dark.svg +1 -1
  10. package/assets/icons/decide.svg +1 -1
  11. package/assets/icons/ear-dark.svg +1 -1
  12. package/assets/icons/ear.svg +1 -1
  13. package/assets/icons/lanes-dark.svg +1 -1
  14. package/assets/icons/lanes.svg +1 -1
  15. package/assets/icons/plan-dark.svg +1 -1
  16. package/assets/icons/plan.svg +1 -1
  17. package/dist/{chunk-OBUWTQ6Z.js → chunk-M6DDZEW4.js} +24 -10
  18. package/dist/config.d.ts +11 -5
  19. package/dist/config.js +53 -21
  20. package/dist/plan-format.d.ts +73 -7
  21. package/dist/plan-format.js +180 -100
  22. package/docs/workflow-dark.svg +1 -1
  23. package/docs/workflow.svg +1 -1
  24. package/herdr-plugin/herdr-plugin.toml +1 -1
  25. package/install.sh +1 -1
  26. package/kit/hooks/hooks/approval-gate/herdr-panes.ts +2 -12
  27. package/kit/hooks/hooks/approval-gate/index.ts +10 -8
  28. package/kit/hooks/hooks/approval-gate/run.ts +21 -12
  29. package/kit/hooks/hooks/ask-hint/decision.ts +3 -3
  30. package/kit/hooks/hooks/git-guard/decision.ts +189 -15
  31. package/kit/hooks/hooks/git-guard/index.ts +13 -3
  32. package/kit/hooks/hooks/git-guard/push-target.ts +85 -0
  33. package/kit/hooks/hooks/git-guard/repository.ts +63 -0
  34. package/kit/hooks/hooks/merge-guard/check-free.ts +1 -13
  35. package/kit/hooks/hooks/merge-guard/decision.ts +342 -102
  36. package/kit/hooks/hooks/merge-guard/index.ts +6 -4
  37. package/kit/hooks/hooks/merge-guard/plain-path.ts +15 -0
  38. package/kit/hooks/hooks/merge-guard/plan-approval-record.ts +23 -0
  39. package/kit/hooks/hooks/merge-guard/plan-approval-settings.ts +142 -0
  40. package/kit/hooks/hooks/merge-guard/plan-approval-sources.ts +196 -0
  41. package/kit/hooks/hooks/merge-guard/plan-approval.ts +168 -0
  42. package/kit/hooks/hooks/merge-guard/plan-growth.ts +40 -0
  43. package/kit/hooks/hooks/merge-guard/plan-pointers.ts +194 -0
  44. package/kit/hooks/hooks/merge-guard/pull-request.ts +67 -21
  45. package/kit/hooks/hooks/merge-guard/run.ts +118 -29
  46. package/kit/hooks/hooks/merge-guard/settings.ts +8 -8
  47. package/kit/hooks/hooks/merge-guard/worker.ts +3 -3
  48. package/kit/hooks/hooks/shared/base-branch.ts +23 -0
  49. package/kit/hooks/hooks/shared/board-lanes.ts +93 -0
  50. package/kit/hooks/hooks/shared/command-text-runners.ts +71 -0
  51. package/kit/hooks/hooks/shared/command.ts +14 -2
  52. package/kit/hooks/hooks/shared/commit.ts +2 -0
  53. package/kit/hooks/hooks/shared/dialog-answers.ts +4 -1
  54. package/kit/hooks/hooks/shared/json.ts +10 -0
  55. package/kit/hooks/hooks/shared/lane.ts +57 -0
  56. package/kit/hooks/hooks/shared/once.ts +8 -0
  57. package/kit/hooks/hooks/shared/own-pane.ts +31 -0
  58. package/kit/hooks/hooks/shared/pane-agent.ts +24 -0
  59. package/kit/hooks/hooks/shared/plan-open-phases.ts +114 -0
  60. package/kit/hooks/hooks/shared/program-position.ts +148 -5
  61. package/kit/hooks/hooks/shared/shell-aliases.ts +258 -0
  62. package/kit/hooks/hooks/shared/shell-snapshots.ts +68 -0
  63. package/kit/hooks/hooks/shared/shell.ts +167 -20
  64. package/kit/hooks/hooks.json +11 -2
  65. package/kit/hooks/statusline.sh +29 -16
  66. package/kit/review/skills/diagnose/SKILL.md +1 -1
  67. package/kit/review/skills/review/SKILL.md +2 -0
  68. package/kit/rules/rules/kit/testing.md +2 -5
  69. package/kit/rules/rules/kit/writing-for-agents.md +3 -1
  70. package/kit/workflow/agents/spec-review.md +1 -1
  71. package/kit/workflow/skills/follow/SKILL.md +3 -3
  72. package/kit/workflow/skills/grill/SKILL.md +1 -1
  73. package/kit/workflow/skills/implement/SKILL.md +10 -11
  74. package/kit/workflow/skills/orchestrate/SKILL.md +155 -117
  75. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +17 -0
  76. package/kit/workflow/skills/orchestrate/references/herd.md +27 -0
  77. package/kit/workflow/skills/orchestrate/references/plan-commit.md +22 -0
  78. package/kit/workflow/skills/orchestrate/references/sensitive-input.md +23 -0
  79. package/kit/workflow/skills/plan/SKILL.md +5 -2
  80. package/kit/workflow/skills/plan/references/template-English.md +6 -0
  81. package/kit/workflow/skills/plan/references/template-German.md +6 -0
  82. package/package.json +3 -2
  83. package/recipes/README.md +21 -0
  84. package/recipes/github-issues.md +23 -0
  85. package/recipes/github-projects.md +22 -0
  86. package/recipes/jira-cli.md +26 -0
  87. package/recipes/jira-rest.md +24 -0
  88. package/recipes/linear.md +25 -0
  89. package/recipes/sentry.md +18 -0
  90. package/recipes/sources/jira.ts +51 -0
  91. package/recipes/sources/linear.ts +19 -0
  92. package/recipes/sources/sentry.ts +12 -0
  93. package/recipes/sources.md +80 -0
  94. package/recipes/status-page.md +19 -0
  95. package/schemas/jackherd.schema.json +42 -18
  96. package/src/check/command.ts +2 -2
  97. package/src/check/parallel-report.ts +19 -9
  98. package/src/cli.ts +1 -0
  99. package/src/config/context-shares.ts +130 -0
  100. package/src/config/read-config.ts +12 -4
  101. package/src/config/schema.ts +75 -20
  102. package/src/context/auto-compact.ts +68 -0
  103. package/src/context/command.ts +68 -17
  104. package/src/context/coordinator-name.ts +27 -9
  105. package/src/context/index.ts +0 -1
  106. package/src/context/level.ts +18 -11
  107. package/src/context/report.ts +6 -3
  108. package/src/plan-format/dependencies.ts +144 -32
  109. package/src/plan-format/index.ts +6 -0
  110. package/src/plan-format/lanes.ts +6 -0
  111. package/src/plan-format/progress.ts +19 -8
  112. package/src/plans/command.ts +14 -5
  113. package/src/plans/current-phase.ts +63 -7
  114. package/src/plans/current-task.ts +51 -0
  115. package/src/plans/ensure-panes.ts +25 -8
  116. package/src/plans/frame.ts +99 -0
  117. package/src/plans/git.ts +8 -0
  118. package/src/plans/herd-memory.ts +107 -0
  119. package/src/plans/lane-plans.ts +5 -3
  120. package/src/plans/lane-summary.ts +222 -0
  121. package/src/plans/layout-phase.ts +148 -0
  122. package/src/plans/layout-summary.ts +140 -0
  123. package/src/plans/layout.ts +164 -420
  124. package/src/plans/live.ts +11 -6
  125. package/src/plans/plan-panes.ts +15 -0
  126. package/src/plans/planned.ts +9 -42
  127. package/src/plans/selection.ts +2 -6
  128. package/src/plans/settings.ts +2 -7
  129. package/src/plans/snapshot.ts +38 -39
  130. package/src/plans/symbols.ts +2 -2
  131. package/src/plans/texts.ts +32 -31
  132. package/src/plans/view.ts +0 -1
  133. package/src/plans/worked-phases.ts +2 -2
  134. package/src/settled/board-row.ts +133 -0
  135. package/src/settled/check.ts +129 -0
  136. package/src/settled/command.ts +65 -0
  137. package/src/settled/evidence-version.ts +14 -0
  138. package/src/settled/options.ts +69 -0
  139. package/src/settled/send.ts +27 -0
  140. package/src/settled/settle.ts +87 -0
  141. package/src/settled/topics.ts +56 -0
  142. package/src/sync/render.ts +13 -4
  143. package/src/sync/sync.ts +25 -5
  144. package/src/terminal/herdr.ts +46 -10
  145. package/src/terminal/none.ts +1 -0
  146. package/src/terminal/types.ts +15 -2
  147. package/src/vocabulary/index.ts +47 -11
  148. package/src/watch/appointments.ts +205 -0
  149. package/src/watch/command.ts +62 -19
  150. package/src/watch/lane-report.ts +11 -3
  151. package/src/watch/texts.ts +35 -59
  152. package/src/watch/watch-events.ts +21 -28
  153. package/kit/hooks/hooks/approval-gate/lane.ts +0 -14
@@ -0,0 +1,63 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { baseBranchOf } from '../shared/base-branch'
4
+ import { mainCheckoutOf } from '../shared/checkout'
5
+ import { commandOutput } from '../shared/command'
6
+ import { parseJsonObject } from '../shared/json'
7
+ import type { PushContext } from './push-target'
8
+
9
+ const CONFIG_PATH = join('.jackherd', 'config.json')
10
+
11
+ /** `%(push)` is `refs/remotes/<remote>/<branch>`; the branch is what follows the remote's own prefix. */
12
+ export const pushBranchOf = (remote: string, pushRef: string): string | undefined => {
13
+ const prefix = `refs/remotes/${remote}/`
14
+ return pushRef.startsWith(prefix) ? pushRef.slice(prefix.length) : undefined
15
+ }
16
+
17
+ const git = (cwd: string, ...args: string[]): string => commandOutput(['git', ...args], cwd)
18
+
19
+ /**
20
+ * The checkout's `.jackherd/config.json`: `{}` when there is none, `null` when it cannot be read or holds no JSON
21
+ * object, so a broken config refuses the push instead of guarding `main` while the project means another branch.
22
+ */
23
+ const projectConfigOf = (checkout: string): Record<string, unknown> | null => {
24
+ const path = join(checkout, CONFIG_PATH)
25
+ if (!existsSync(path)) return {}
26
+ try {
27
+ return parseJsonObject(readFileSync(path, 'utf8')) ?? null
28
+ } catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ const currentBranch = (cwd: string): string | undefined => {
34
+ try {
35
+ return git(cwd, 'symbolic-ref', '--quiet', '--short', 'HEAD')
36
+ } catch {
37
+ return undefined
38
+ }
39
+ }
40
+
41
+ /**
42
+ * The current branch and the branch its push goes to, as git's own push settings decide (`push.default`,
43
+ * `remote.pushDefault`, the upstream); undefined on a detached HEAD or a push ref git names oddly.
44
+ */
45
+ const defaultPushBranches = (cwd: string): string[] | undefined => {
46
+ const branch = currentBranch(cwd)
47
+ if (branch === undefined) return undefined
48
+ const [remote = '', pushRef = ''] = git(
49
+ cwd,
50
+ 'for-each-ref',
51
+ '--format=%(push:remotename)%00%(push)',
52
+ `refs/heads/${branch}`,
53
+ ).split('\0')
54
+ if (pushRef === '') return [branch]
55
+ const pushBranch = pushBranchOf(remote, pushRef)
56
+ return pushBranch === undefined ? undefined : [branch, pushBranch]
57
+ }
58
+
59
+ /** The repository at `cwd`: its git state, and the base branch from the main checkout's `.jackherd/config.json`. */
60
+ export const repositoryPushContext = (cwd: string): PushContext => ({
61
+ baseBranch: () => baseBranchOf(projectConfigOf(mainCheckoutOf(cwd))),
62
+ defaultPushBranches: () => defaultPushBranches(cwd),
63
+ })
@@ -5,6 +5,7 @@
5
5
  */
6
6
 
7
7
  import { runGh } from './gh'
8
+ import { isPlainPath } from './plain-path'
8
9
  import type { RepositoryFacts } from './pull-request'
9
10
 
10
11
  /** What GitHub lists per file: the name, and the old name of a renamed file. */
@@ -13,19 +14,6 @@ const FILE_ENTRY_QUERY = '.[] | [.filename, .previous_filename] | @json'
13
14
  /** GitHub lists at most this many files of a pull request and says nothing when it stops there. */
14
15
  export const MAX_LISTED_FILES = 3000
15
16
 
16
- const isControlCharacter = (character: string): boolean => {
17
- const code = character.charCodeAt(0)
18
- return code < 0x20 || code === 0x7f
19
- }
20
-
21
- /** A repository path as GitHub lists it: relative, no empty, `.` or `..` segment, no backslash, no control character. */
22
- const isPlainPath = (file: string): boolean =>
23
- file !== '' &&
24
- !file.startsWith('/') &&
25
- !file.includes('\\') &&
26
- ![...file].some(isControlCharacter) &&
27
- !file.split('/').some((segment) => segment === '' || segment === '.' || segment === '..')
28
-
29
17
  const isNameOrNull = (value: unknown): value is string | null => value === null || typeof value === 'string'
30
18
 
31
19
  /**
@@ -1,27 +1,68 @@
1
+ import { commandTextRunnerIndex, searchableText } from '../shared/command-text-runners'
2
+ import { FULL_COMMIT } from '../shared/commit'
1
3
  import {
2
4
  inlineScript,
3
5
  isOption,
4
6
  isProgramPosition,
5
7
  isShell,
6
8
  programName,
9
+ runsInterpreterProgram,
7
10
  } from '../shared/program-position'
8
- import { parseShell, ShellParseError, type SimpleCommand, type Word } from '../shared/shell'
11
+ import {
12
+ expandingHereDocumentAdvice,
13
+ isExpanded,
14
+ parseShell,
15
+ ShellParseError,
16
+ type SimpleCommand,
17
+ UNREADABLE_ADVICE,
18
+ type Word,
19
+ } from '../shared/shell'
20
+ import {
21
+ definesShellAlias,
22
+ mayRunDefinedFunction,
23
+ NO_DEFINITIONS,
24
+ type ShellDefinitionHit,
25
+ type ShellDefinitions,
26
+ shellDefinitionAt,
27
+ shellDefinitionHint,
28
+ } from '../shared/shell-aliases'
9
29
 
10
30
  export type MergeGuardDecision = { kind: 'pass' } | { kind: 'ask'; reason: string }
11
31
 
32
+ /**
33
+ * A plain `gh pr merge <number>`, with the head commit it names in `--match-head-commit <sha>`, if any: GitHub then
34
+ * merges only that commit, so what was checked is what gets merged.
35
+ */
36
+ export type PlainMerge = { pullRequest: number; headCommit: string | undefined }
37
+
38
+ /** An approval that no longer covers the merge, and what the question should tell the user about it. */
39
+ export type MergeNote = { note: string }
40
+
12
41
  export type GuardPolicy = {
13
42
  /** The guarded commands as words: a program and the plain words after it, such as `gh pr merge`. */
14
43
  commands: readonly string[]
15
44
  /**
16
- * Whether the merge of this pull request may pass without a question: the user approved it, or every file of it
17
- * matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
18
- * A throw counts as not approved.
45
+ * Whether the merge of this pull request may pass without a question: the user approved its branch or the plan of
46
+ * its lane, or every file of it matches `merge.withoutChecks`. Asked only for a plain `gh pr merge <number>`.
47
+ * A throw counts as not approved; a note is not approved either, and the question carries it.
19
48
  */
20
- isMergeApproved: (pullRequest: number) => boolean
49
+ isMergeApproved: (merge: PlainMerge) => boolean | MergeNote
50
+ /** The aliases and functions of the user's shell, read only when a word at a program position may be one; none by default. */
51
+ shellDefinitions?: () => ShellDefinitions
21
52
  }
22
53
 
23
- /** A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly. */
24
- type Finding = { command: string; pullRequest: number | undefined }
54
+ /**
55
+ * A guarded command the script runs or could run, and the pull request a merge names, when it names one plainly; or
56
+ * the shell alias or function that may run one (`definition`, whose name is then the `command`), or a program whose
57
+ * name is unknown (`note` says why it may run one).
58
+ */
59
+ type Finding = { command: string; merge: PlainMerge | undefined; definition?: ShellDefinitionHit; note?: string }
60
+
61
+ /** What the search looks for: the guarded commands, and the shell definitions that may stand for one of them. */
62
+ type Search = { commands: readonly string[]; definitions: () => ShellDefinitions }
63
+
64
+ /** The parts of a `gh api` call that decide whether it writes, and where. */
65
+ type ApiCall = { method: Word | undefined; fields: boolean; override: boolean; endpoints: Word[] }
25
66
 
26
67
  /** The one command whose guard an approval can lift. Every other guarded command asks each time. */
27
68
  const MERGE_COMMAND = 'gh pr merge'
@@ -35,44 +76,59 @@ const OPTIONS_WITH_VALUE = new Set(['-R', '--repo', '--hostname'])
35
76
  */
36
77
  const APPROVABLE_FLAGS = new Set(['--squash', '-s', '--merge', '-m', '--rebase', '-r', '--delete-branch', '-d'])
37
78
  const PULL_REQUEST_NUMBER = /^[1-9]\d*$/
79
+ const HEAD_COMMIT_FLAG = '--match-head-commit'
38
80
  const GLOB = /[*?[]/
39
81
 
40
- /** Programs that run text they are given or read from elsewhere, so the words may not stand at the program. */
41
- const OPAQUE_RUNNERS = new Set([
42
- 'xargs',
43
- 'find',
44
- 'trap',
45
- 'coproc',
46
- 'repeat',
47
- 'script',
48
- 'su',
49
- 'flock',
50
- 'ssh',
51
- 'parallel',
52
- 'ionice',
53
- 'chrt',
54
- 'taskset',
55
- 'strace',
56
- 'unbuffer',
57
- 'chroot',
58
- 'runuser',
59
- 'systemd-run',
60
- 'nsenter',
61
- 'unshare',
62
- 'firejail',
63
- 'pkexec',
64
- 'entr',
65
- 'watchexec',
66
- 'busybox',
67
- ])
68
-
69
82
  const SOURCING = new Set(['source', '.'])
70
83
 
71
- /** Interpreters that run program text given on the command line, and the options that give it. */
72
- const INTERPRETERS = new Set(['python', 'python3', 'node', 'bun', 'deno', 'perl', 'ruby', 'php', 'lua'])
73
- const INLINE_PROGRAM_OPTIONS = new Set(['-c', '-e', '-E', '-p', '-pe', '--eval', '--command', '-r'])
74
84
  const INPUT_PATH = /^(\/dev\/stdin|\/dev\/fd\/|\/proc\/self\/fd\/)/
75
85
 
86
+ /** `gh api` is guarded only for a write that merges, writes a file, starts or reruns a workflow or moves a ref (D43); every other call passes. */
87
+ const API_COMMAND = 'gh api'
88
+ const API_WORD = 'api'
89
+ const READ_METHOD = 'GET'
90
+ const API_METHOD_OPTIONS = new Set(['-X', '--method'])
91
+ /** Options that add a request body, which makes the method POST unless one is named. */
92
+ const API_BODY_OPTIONS = new Set(['-f', '-F', '--field', '--raw-field', '--input'])
93
+ const API_OPTIONS_WITH_VALUE = new Set([
94
+ '-H',
95
+ '--header',
96
+ '-q',
97
+ '--jq',
98
+ '-t',
99
+ '--template',
100
+ '--hostname',
101
+ '--cache',
102
+ '-p',
103
+ '--preview',
104
+ ])
105
+ /** A header that makes GitHub take another method than the request's own, so a GET may write. */
106
+ const METHOD_OVERRIDE_HEADER = /^\s*x-http-method(-override)?\s*:/i
107
+ const HEADER_OPTIONS = new Set(['-H', '--header'])
108
+ /**
109
+ * Endpoints that merge a pull request or a branch, write a file (a commit), dispatch a workflow or repository event,
110
+ * rerun a workflow (a deploy), or create, move or delete a ref.
111
+ */
112
+ const DANGEROUS_ENDPOINTS = [
113
+ /(^|\/)pulls\/[^/]+\/merge\/?$/,
114
+ /(^|\/)merges\/?$/,
115
+ /(^|\/)contents(\/|$)/,
116
+ /(^|\/)dispatches\/?$/,
117
+ /(^|\/)actions\/(runs|jobs)\/[^/]+\/(rerun|rerun-failed-jobs)\/?$/,
118
+ /(^|\/)git\/refs(\/|$)/,
119
+ ]
120
+ /** What the fixed end of an endpoint after an expansion may be, when the expansion can stand for the rest of the path. */
121
+ const DANGEROUS_ENDINGS = [
122
+ /(^|\/)merges?\/?$/,
123
+ /(^|\/)contents(\/|$)/,
124
+ /(^|\/)dispatches\/?$/,
125
+ /(^|\/)(rerun|rerun-failed-jobs)\/?$/,
126
+ /(^|\/)refs(\/|$)/,
127
+ ]
128
+ const DANGEROUS_MENTION = /\b(merges?|contents|dispatches|rerun|refs)\b/
129
+ const EXPANDED_FUNCTION_NOTE =
130
+ 'The program is expanded from a variable or a substitution and may name a shell function that runs one of guard.commands in .jackherd/config.json, so the user confirms it.'
131
+
76
132
  const PASS: MergeGuardDecision = { kind: 'pass' }
77
133
 
78
134
  /**
@@ -109,30 +165,152 @@ const matchesFrom = (operands: Word[], expected: string[]): boolean => {
109
165
  return operand.value === expected[0] && matchesFrom(rest, expected.slice(1))
110
166
  }
111
167
 
112
- /** The number of `gh pr merge <number>` in its plain form: literal words only, merge-method and delete-branch flags. */
113
- const plainPullRequest = (words: SimpleCommand, expected: string[]): number | undefined => {
168
+ /** The operands without one `--match-head-commit <full sha>`, and that sha; undefined for any other use of the flag. */
169
+ const withoutHeadCommit = (operands: string[]): { rest: string[]; headCommit: string | undefined } | undefined => {
170
+ const at = operands.indexOf(HEAD_COMMIT_FLAG)
171
+ if (at === -1) return { rest: operands, headCommit: undefined }
172
+ const headCommit = operands[at + 1]
173
+ const rest = [...operands.slice(0, at), ...operands.slice(at + 2)]
174
+ const plain = headCommit !== undefined && FULL_COMMIT.test(headCommit) && !rest.includes(HEAD_COMMIT_FLAG)
175
+ return plain ? { rest, headCommit } : undefined
176
+ }
177
+
178
+ /**
179
+ * `gh pr merge <number>` in its plain form: literal words only, merge-method and delete-branch flags, and at most one
180
+ * `--match-head-commit <full sha>`.
181
+ */
182
+ const plainMerge = (words: SimpleCommand, expected: string[]): PlainMerge | undefined => {
114
183
  const after = words.slice(1)
115
184
  const [first, second, ...rest] = after
116
185
  if (after.some(isWildcard) || first?.value !== expected[0] || second?.value !== expected[1]) return undefined
117
- const numbers = rest.filter(({ value }) => !APPROVABLE_FLAGS.has(value))
118
- const only = numbers.length === 1 && PULL_REQUEST_NUMBER.test(numbers[0].value) ? numbers[0].value : undefined
119
- return only === undefined ? undefined : Number(only)
186
+ const bound = withoutHeadCommit(rest.map(({ value }) => value))
187
+ const numbers = bound?.rest.filter((value) => !APPROVABLE_FLAGS.has(value)) ?? []
188
+ const only = numbers.length === 1 && PULL_REQUEST_NUMBER.test(numbers[0]) ? numbers[0] : undefined
189
+ return only === undefined ? undefined : { pullRequest: Number(only), headCommit: bound?.headCommit }
190
+ }
191
+
192
+ /** An expansion in a word's value: the marker, with the name it keeps after it (`$REPO`, `${REPO}`). */
193
+ const EXPANSION_IN_VALUE = /\$(\{[^}]*\}|\w+)?/g
194
+ /** Everything up to and including the last expansion of a value. */
195
+ const UP_TO_LAST_EXPANSION = /^[\s\S]*\$(\{[^}]*\}|\w+)?/
196
+
197
+ /** A word that is nothing but expansions: it may become any number of words, options among them. */
198
+ const isOpaque = ({ value }: Word): boolean => isExpanded(value) && value.replace(EXPANSION_IN_VALUE, '') === ''
199
+
200
+ const isReadMethod = ({ value }: Word): boolean => !isExpanded(value) && value.toUpperCase() === READ_METHOD
201
+
202
+ /** An option of `gh api` split into its name and the value attached with `=` (or glued to `-X`), if any. */
203
+ const apiOption = ({ value, literal }: Word): { name: string; attached: Word | undefined } => {
204
+ if (value.startsWith('-X') && value.length > 2) return { name: '-X', attached: { value: value.slice(2), literal } }
205
+ const at = value.indexOf('=')
206
+ if (at === -1 || !value.startsWith('--')) return { name: value, attached: undefined }
207
+ return { name: value.slice(0, at), attached: { value: value.slice(at + 1), literal } }
208
+ }
209
+
210
+ /** A short body option with its value glued on, as in `-fbody=x`. */
211
+ const isGluedBodyOption = (value: string): boolean => /^-[fF]./.test(value)
212
+
213
+ const readApiCall = (args: Word[]): ApiCall => {
214
+ const call: ApiCall = { method: undefined, fields: false, override: false, endpoints: [] }
215
+ for (let index = 0; index < args.length; index++) {
216
+ if (!isOption(args[index])) {
217
+ call.endpoints.push(args[index])
218
+ continue
219
+ }
220
+ const { name, attached } = apiOption(args[index])
221
+ const takesNext =
222
+ attached === undefined &&
223
+ (API_METHOD_OPTIONS.has(name) || API_BODY_OPTIONS.has(name) || API_OPTIONS_WITH_VALUE.has(name))
224
+ if (API_METHOD_OPTIONS.has(name)) call.method = attached ?? args[index + 1]
225
+ if (API_BODY_OPTIONS.has(name) || isGluedBodyOption(name)) call.fields = true
226
+ const header = HEADER_OPTIONS.has(name) ? (attached ?? args[index + 1]) : undefined
227
+ if (header !== undefined && METHOD_OVERRIDE_HEADER.test(header.value)) call.override = true
228
+ if (takesNext) index++
229
+ }
230
+ return call
231
+ }
232
+
233
+ const decodePercent = (path: string): string => {
234
+ try {
235
+ return decodeURIComponent(path)
236
+ } catch {
237
+ return path
238
+ }
239
+ }
240
+
241
+ /** The path as GitHub routes it: without query and fragment, percent-decoded, in lower case, with single slashes. */
242
+ const normalizedPath = (value: string): string =>
243
+ decodePercent(value.split(/[?#]/)[0])
244
+ .toLowerCase()
245
+ .replace(/\/{2,}/g, '/')
246
+
247
+ /** After an expansion, only the endpoint's fixed end is known, and an empty end could be any path. */
248
+ const isDangerousEndpoint = ({ value }: Word): boolean => {
249
+ const path = normalizedPath(value)
250
+ if (DANGEROUS_ENDPOINTS.some((pattern) => pattern.test(path))) return true
251
+ if (!isExpanded(path)) return false
252
+ const ending = path.replace(UP_TO_LAST_EXPANSION, '')
253
+ return ending === '' || DANGEROUS_ENDINGS.some((pattern) => pattern.test(ending))
254
+ }
255
+
256
+ /**
257
+ * Whether the arguments after `gh api` write to a dangerous endpoint: a method other than GET, named or implied by a
258
+ * request body. A word made only of expansions could be any argument, so it counts as such a write.
259
+ */
260
+ const writesDangerously = (args: Word[]): boolean => {
261
+ if (args.some(isOpaque)) return true
262
+ const { method, fields, override, endpoints } = readApiCall(args)
263
+ const writes = override || (method === undefined ? fields : !isReadMethod(method))
264
+ return writes && endpoints.some(isDangerousEndpoint)
265
+ }
266
+
267
+ /** The words after the first plain `api` behind the program at `index`; all of them when `api` came from an expansion. */
268
+ const apiArguments = (words: SimpleCommand, index: number): Word[] => {
269
+ const after = words.slice(index + 1)
270
+ const at = after.findIndex((word) => word.literal && word.value === API_WORD)
271
+ return at === -1 ? after : after.slice(at + 1)
272
+ }
273
+
274
+ /** Every guarded command counts wherever it is found, except a `gh api` call that does not write dangerously. */
275
+ const isGuardedUse = (command: string, words: SimpleCommand, programIndex: number): boolean =>
276
+ command !== API_COMMAND || writesDangerously(apiArguments(words, programIndex))
277
+
278
+ /** In text the parse cannot see, `api` followed by the end of a dangerous endpoint is the sign; the method is unknown. */
279
+ const mentionsDangerousApiCall = (script: string): boolean => {
280
+ const at = findWord(script, API_WORD, 0)
281
+ return at !== -1 && DANGEROUS_MENTION.test(script.slice(at))
120
282
  }
121
283
 
122
284
  /** `sole`: the script is this one simple command, written plainly, the only shape an approval can lift. */
123
285
  const findingOf = (command: string, words: SimpleCommand, index: number, sole: boolean): Finding | undefined => {
124
286
  const expected = command.split(' ').slice(1)
125
287
  if (!operandLists(words.slice(index + 1)).some((operands) => matchesFrom(operands, expected))) return undefined
288
+ if (!isGuardedUse(command, words, index)) return undefined
126
289
  const plain = sole && words[index].literal && words[index].value === 'gh'
127
- return { command, pullRequest: plain ? plainPullRequest(words.slice(index), expected) : undefined }
290
+ return { command, merge: plain ? plainMerge(words.slice(index), expected) : undefined }
291
+ }
292
+
293
+ const programsOf = (commands: readonly string[]): string[] => [
294
+ ...new Set(commands.map((command) => command.split(' ')[0])),
295
+ ]
296
+
297
+ /** A shell alias or function at the word that may run one of the guarded programs: it hides the words after them. */
298
+ const definitionFinding = (words: SimpleCommand, index: number, { commands, definitions }: Search): Finding[] => {
299
+ const definition = shellDefinitionAt(words, index, definitions, programsOf(commands))
300
+ return definition ? [{ command: definition.name, merge: undefined, definition }] : []
128
301
  }
129
302
 
130
- const callsOfWord = (words: SimpleCommand, index: number, commands: readonly string[], sole: boolean): Finding[] => {
303
+ const expandedFunctionFinding = (words: SimpleCommand, { commands, definitions }: Search, text: string): Finding[] =>
304
+ mayRunDefinedFunction(words, definitions, programsOf(commands), text)
305
+ ? [{ command: '', merge: undefined, note: EXPANDED_FUNCTION_NOTE }]
306
+ : []
307
+
308
+ const callsOfWord = (words: SimpleCommand, index: number, search: Search, sole: boolean): Finding[] => {
131
309
  if (!isProgramPosition(words, index)) return []
132
310
  const program = programName(words[index])
133
- return commands
134
- .filter((command) => command.split(' ')[0] === program)
135
- .flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
311
+ const guarded = search.commands.filter((command) => command.split(' ')[0] === program)
312
+ if (guarded.length === 0) return definitionFinding(words, index, search)
313
+ return guarded.flatMap((command) => findingOf(command, words, index, sole && index === 0) ?? [])
136
314
  }
137
315
 
138
316
  const isWordCharacter = (character: string | undefined): boolean => character !== undefined && /\w/.test(character)
@@ -155,25 +333,19 @@ const namesInOrder = (script: string, words: string[]): boolean => {
155
333
  return true
156
334
  }
157
335
 
158
- const callsNaming = (script: string, commands: readonly string[], wordsOf: (command: string) => string[]): Finding[] =>
159
- commands
160
- .filter((command) => namesInOrder(script, wordsOf(command)))
161
- .map((command) => ({ command, pullRequest: undefined }))
162
-
163
- /** Where the script cannot be parsed, a guarded program named as a whole word is enough to ask. */
164
- const namedPrograms = (script: string, commands: readonly string[]): Finding[] =>
165
- callsNaming(script, commands, (command) => command.split(' ').slice(0, 1))
166
-
167
336
  /** Where the program is hidden or the words are handed over as text, the command words after the program are the sign. */
168
337
  const mentionedCommandWords = (script: string, commands: readonly string[]): Finding[] =>
169
- callsNaming(script, commands, (command) => command.split(' ').slice(1))
338
+ commands
339
+ .filter((command) =>
340
+ command === API_COMMAND ? mentionsDangerousApiCall(script) : namesInOrder(script, command.split(' ').slice(1)),
341
+ )
342
+ .map((command) => ({ command, merge: undefined }))
170
343
 
171
344
  /** A program expanded from a variable or a substitution could be the guarded one. */
172
345
  const runsExpandedProgram = (words: SimpleCommand): boolean =>
173
346
  words.some((word, index) => !word.literal && isProgramPosition(words, index))
174
347
 
175
- const runsOpaquely = (words: SimpleCommand): boolean =>
176
- words.some((word, index) => isProgramPosition(words, index) && OPAQUE_RUNNERS.has(programName(word)))
348
+ const runsOpaquely = (words: SimpleCommand): boolean => commandTextRunnerIndex(words) !== -1
177
349
 
178
350
  /**
179
351
  * A shell, anywhere a program can stand, that reads its script from stdin (no operand, or `-s`), from a process
@@ -190,14 +362,14 @@ const readsScriptFromElsewhere = (words: SimpleCommand): boolean =>
190
362
  return fromInput || operands.some((each) => !each.literal || INPUT_PATH.test(each.value))
191
363
  })
192
364
 
193
- /** An interpreter handed program text (`python3 -c`, `node -e`) or git handed an alias runs text the parse cannot see. */
194
- const runsProgramText = (words: SimpleCommand): boolean =>
195
- words.some((word, index) => {
196
- if (!isProgramPosition(words, index)) return false
197
- const rest = words.slice(index + 1)
198
- if (programName(word) === 'git') return rest.some(({ value }) => value.startsWith('alias.'))
199
- return INTERPRETERS.has(programName(word)) && rest.some(({ value }) => INLINE_PROGRAM_OPTIONS.has(value))
200
- })
365
+ /** Git handed an alias (`git -c alias.m=!…`) runs text the parse cannot see. */
366
+ const definesGitAlias = (words: SimpleCommand): boolean =>
367
+ words.some(
368
+ (word, index) =>
369
+ isProgramPosition(words, index) &&
370
+ programName(word) === 'git' &&
371
+ words.slice(index + 1).some(({ value }) => value.startsWith('alias.')),
372
+ )
201
373
 
202
374
  /** An inline script that is itself an expansion (`eval "$X"`) hides its text from the parse of the call. */
203
375
  const runsInlineExpansion = (words: SimpleCommand): boolean =>
@@ -217,71 +389,139 @@ const containsInOrder = (words: SimpleCommand, command: string): boolean => {
217
389
  if (next === -1) return false
218
390
  from = next + 1
219
391
  }
220
- return true
392
+ return isGuardedUse(command, words, at)
221
393
  }
222
394
 
223
- const callsInCommand = (words: SimpleCommand, commands: readonly string[], sole: boolean, whole: string): Finding[] => {
395
+ /** `text` is the searchable text of the script around the command: a variable there may hold a function's name. */
396
+ const callsInCommand = (words: SimpleCommand, search: Search, sole: boolean, whole: string, text: string): Finding[] => {
224
397
  const direct = words.flatMap((_word, index) => {
225
398
  const script = inlineScript(words, index)
226
- return script === undefined ? callsOfWord(words, index, commands, sole) : callsIn(script, commands, false, whole)
399
+ return script === undefined ? callsOfWord(words, index, search, sole) : callsIn(script, search, false, whole)
227
400
  })
228
401
  const found = new Set(direct.map(({ command }) => command))
229
- const byWords = commands
402
+ const byWords = search.commands
230
403
  .filter((command) => !found.has(command) && containsInOrder(words, command))
231
- .map((command) => ({ command, pullRequest: undefined }))
232
- return [...direct, ...byWords]
404
+ .map((command) => ({ command, merge: undefined }))
405
+ return [...direct, ...byWords, ...expandedFunctionFinding(words, search, text)]
233
406
  }
234
407
 
235
- /** Each check reads the whole script: a shell that reads stdin, an expanded program or an opaque runner hides what runs. */
408
+ /**
409
+ * Each check reads the whole script: a shell that reads stdin, an expanded program, an opaque runner or an interpreter
410
+ * handed program text hides what runs.
411
+ */
236
412
  const hidesWhatRuns = (words: SimpleCommand): boolean =>
237
413
  runsExpandedProgram(words) ||
238
414
  runsOpaquely(words) ||
239
415
  readsScriptFromElsewhere(words) ||
240
416
  runsInlineExpansion(words) ||
241
- runsProgramText(words)
417
+ runsInterpreterProgram(words) ||
418
+ definesGitAlias(words) ||
419
+ definesShellAlias(words)
242
420
 
243
421
  const callsInScriptWide = (whole: string, parsed: SimpleCommand[], commands: readonly string[]): Finding[] =>
244
- parsed.some(hidesWhatRuns) ? mentionedCommandWords(whole, commands) : []
422
+ parsed.some(hidesWhatRuns) ? mentionedCommandWords(searchableText(whole, parsed), commands) : []
245
423
 
246
424
  /** `whole` is the text of the outermost script: a nested script's input comes from there, so the words are searched in it. */
247
- const callsIn = (script: string, commands: readonly string[], top: boolean, whole: string): Finding[] => {
248
- try {
249
- const parsed = parseShell(script)
250
- const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
251
- return [
252
- ...parsed.flatMap((words) => callsInCommand(words, commands, sole, whole)),
253
- ...callsInScriptWide(whole, parsed, commands),
254
- ]
255
- } catch (error) {
256
- if (error instanceof ShellParseError) return namedPrograms(whole, commands)
257
- throw error
258
- }
425
+ const callsIn = (script: string, search: Search, top: boolean, whole: string): Finding[] => {
426
+ const parsed = parseShell(script)
427
+ const sole = top && parsed.length === 1 && !script.trimStart().startsWith('(')
428
+ const text = searchableText(whole, parsed)
429
+ return [
430
+ ...parsed.flatMap((words) => callsInCommand(words, search, sole, whole, text)),
431
+ ...callsInScriptWide(whole, parsed, search.commands),
432
+ ]
259
433
  }
260
434
 
261
- const isApproved = ({ command, pullRequest }: Finding, policy: GuardPolicy): boolean => {
262
- if (command !== MERGE_COMMAND || pullRequest === undefined) return false
435
+ const approvalOf = ({ command, merge }: Finding, policy: GuardPolicy): boolean | MergeNote => {
436
+ if (command !== MERGE_COMMAND || merge === undefined) return false
263
437
  try {
264
- return policy.isMergeApproved(pullRequest)
438
+ return policy.isMergeApproved(merge)
265
439
  } catch {
266
440
  return false
267
441
  }
268
442
  }
269
443
 
270
- const askBecause = (commands: string[]): MergeGuardDecision => ({
444
+ const guardedListSentence = (commands: string[]): string[] =>
445
+ commands.length === 0
446
+ ? []
447
+ : [
448
+ `${commands.map((command) => `\`${command}\``).join(' and ')} is on the project's guarded list (guard.commands in .jackherd/config.json), so the user confirms it.`,
449
+ ]
450
+
451
+ const askBecause = (findings: Finding[], notes: string[], script: string): MergeGuardDecision => {
452
+ const guarded = findings
453
+ .filter(({ definition, note }) => definition === undefined && note === undefined)
454
+ .map(({ command }) => command)
455
+ const definitions = [
456
+ ...new Map(
457
+ findings.flatMap(({ definition }) => (definition ? [[definition.name, definition] as const] : [])),
458
+ ).values(),
459
+ ]
460
+ return {
461
+ kind: 'ask',
462
+ reason: [
463
+ ...guardedListSentence([...new Set(guarded)]),
464
+ ...definitions.map(
465
+ (definition) =>
466
+ `${shellDefinitionHint(definition)} It may run a command of guard.commands in .jackherd/config.json, so the user confirms it.`,
467
+ ),
468
+ ...new Set(findings.flatMap(({ note }) => note ?? [])),
469
+ ...notes,
470
+ expandingHereDocumentAdvice(script),
471
+ ]
472
+ .filter((part) => part !== '')
473
+ .join(' '),
474
+ }
475
+ }
476
+
477
+ /**
478
+ * Quotes split a word (`g''h`), a variable builds it, an expansion hides a command: in text the parse cannot follow,
479
+ * no word search can tell whether a guarded command runs, so every unreadable script asks (fail closed).
480
+ */
481
+ const askUnreadable = ({ message }: ShellParseError, script: string): MergeGuardDecision => ({
271
482
  kind: 'ask',
272
- reason: `${commands.map((command) => `\`${command}\``).join(' and ')} is on the project's guarded list (guard.commands in .jackherd/config.json), so the user confirms it.`,
483
+ reason: [
484
+ `The command could not be read (${message}), so it may run a command of guard.commands in .jackherd/config.json and the user confirms it.`,
485
+ UNREADABLE_ADVICE,
486
+ expandingHereDocumentAdvice(script),
487
+ ]
488
+ .filter((part) => part !== '')
489
+ .join(' '),
273
490
  })
274
491
 
492
+ const findingsOrUnreadable = (command: string, search: Search): Finding[] | ShellParseError => {
493
+ try {
494
+ return callsIn(command, search, true, command)
495
+ } catch (error) {
496
+ if (error instanceof ShellParseError) return error
497
+ throw error
498
+ }
499
+ }
500
+
275
501
  /**
276
502
  * Decides on a Bash command before it runs: `ask` when it runs, or could run, a guarded command (`gh pr merge`,
277
- * `gh workflow run` by default), also behind options before the verb, a chain, a wrapper, `sh -c`, `eval`, a
278
- * script piped into a shell or a program held in a variable. A script that is exactly
279
- * `gh pr merge <number>` with merge-method and delete-branch flags passes when the user approved it
280
- * (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A command that merely names the words (an argument, a commit message) is none.
503
+ * `gh workflow run`, `gh run rerun` and a `gh api` write to a merge, a file, a dispatch, a rerun or a ref by default), also behind options before the
504
+ * verb, a chain, a wrapper, `sh -c`, `eval`, a script piped into a shell, an interpreter's program text, a shell alias
505
+ * or function, or a program held in a variable. A script that is exactly `gh pr merge <number>` with merge-method and
506
+ * delete-branch flags and at most one `--match-head-commit <sha>` passes when the user approved it
507
+ * (`isMergeApproved`); any other shape of the merge, and every other guarded command, asks. A command that merely
508
+ * names the words (an argument, a commit message) is none. A script the parse cannot read asks whatever it names,
509
+ * unless the list is empty.
281
510
  */
282
511
  export const decideBashCommand = (command: string, policy: GuardPolicy): MergeGuardDecision => {
283
- const findings = callsIn(command, policy.commands, true, command)
512
+ if (policy.commands.length === 0) return PASS
513
+ const search = { commands: policy.commands, definitions: policy.shellDefinitions ?? (() => NO_DEFINITIONS) }
514
+ const findings = findingsOrUnreadable(command, search)
515
+ if (findings instanceof ShellParseError) return askUnreadable(findings, command)
284
516
  if (findings.length === 0) return PASS
285
- const unapproved = findings.filter((finding) => !isApproved(finding, policy))
286
- return unapproved.length === 0 ? PASS : askBecause([...new Set(unapproved.map(({ command: name }) => name))])
517
+ const unapproved = findings
518
+ .map((finding) => ({ finding, approval: approvalOf(finding, policy) }))
519
+ .filter(({ approval }) => approval !== true)
520
+ if (unapproved.length === 0) return PASS
521
+ const notes = unapproved.flatMap(({ approval }) => (typeof approval === 'object' ? [approval.note] : []))
522
+ return askBecause(
523
+ unapproved.map(({ finding }) => finding),
524
+ [...new Set(notes)],
525
+ command,
526
+ )
287
527
  }