jackherd 1.7.1 → 1.8.1

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