jackherd 0.0.1 → 1.0.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 (250) hide show
  1. package/CHANGELOG.md +61 -0
  2. package/LICENSE +21 -0
  3. package/NOTICE +38 -0
  4. package/README.md +1121 -2
  5. package/assets/branding/README.md +62 -0
  6. package/assets/branding/jackherd-icon-256.png +0 -0
  7. package/assets/branding/jackherd-icon-512.png +0 -0
  8. package/assets/branding/jackherd-icon-64.png +0 -0
  9. package/assets/branding/jackherd-icon-small-32.png +0 -0
  10. package/assets/branding/jackherd-icon-small-64.png +0 -0
  11. package/assets/branding/jackherd-icon-small.svg +1 -0
  12. package/assets/branding/jackherd-icon.svg +1 -0
  13. package/assets/branding/jackherd-logo-1024.png +0 -0
  14. package/assets/branding/jackherd-logo-512.png +0 -0
  15. package/assets/branding/jackherd-logo-dark-1024.png +0 -0
  16. package/assets/branding/jackherd-logo-dark-512.png +0 -0
  17. package/assets/branding/jackherd-logo-dark.svg +1 -0
  18. package/assets/branding/jackherd-logo-mark-1024.png +0 -0
  19. package/assets/branding/jackherd-logo-mark-512.png +0 -0
  20. package/assets/branding/jackherd-logo-mark.svg +1 -0
  21. package/assets/branding/jackherd-logo.svg +1 -0
  22. package/dist/chunk-R6MGHCD7.js +93 -0
  23. package/dist/config.d.ts +167 -0
  24. package/dist/config.js +401 -0
  25. package/dist/plan-format.d.ts +325 -0
  26. package/dist/plan-format.js +636 -0
  27. package/docs/lanes.mmd +9 -0
  28. package/docs/lanes.svg +1 -0
  29. package/docs/skills.mmd +10 -0
  30. package/docs/skills.svg +1 -0
  31. package/docs/workflow.svg +172 -0
  32. package/herdr-plugin/herdr-plugin.toml +22 -0
  33. package/herdr-plugin/open-plans.ts +43 -0
  34. package/herdr-plugin/restore-plans.ts +107 -0
  35. package/install.sh +157 -0
  36. package/kit/hooks/hooks/git-guard/decision.ts +438 -0
  37. package/kit/hooks/hooks/git-guard/index.ts +38 -0
  38. package/kit/hooks/hooks/git-guard/shell.ts +407 -0
  39. package/kit/hooks/hooks/watch-guard/background.ts +36 -0
  40. package/kit/hooks/hooks/watch-guard/decision.ts +53 -0
  41. package/kit/hooks/hooks/watch-guard/index.ts +11 -0
  42. package/kit/hooks/hooks/watch-guard/shell-input.ts +32 -0
  43. package/kit/hooks/hooks/watch-guard/starts.ts +154 -0
  44. package/kit/hooks/hooks/watch-guard/text-fallback.ts +40 -0
  45. package/kit/hooks/hooks.json +30 -0
  46. package/kit/hooks/statusline.sh +72 -0
  47. package/kit/review/agents/devops.md +11 -0
  48. package/kit/review/agents/public-content.md +39 -0
  49. package/kit/review/agents/security-audit.md +11 -0
  50. package/kit/review/agents/uncle-bob.md +11 -0
  51. package/kit/review/skills/devops/SKILL.md +50 -0
  52. package/kit/review/skills/diagnose/SKILL.md +140 -0
  53. package/kit/review/skills/diagnose/references/feedback-loops.md +59 -0
  54. package/kit/review/skills/diagnose/scripts/hitl-loop.template.sh +71 -0
  55. package/kit/review/skills/pre-commit/SKILL.md +100 -0
  56. package/kit/review/skills/review/SKILL.md +43 -0
  57. package/kit/review/skills/security-audit/SKILL.md +65 -0
  58. package/kit/review/skills/uncle-bob/SKILL.md +43 -0
  59. package/kit/rules/rules/kit/arc42.md +89 -0
  60. package/kit/rules/rules/kit/architecture-files.md +87 -0
  61. package/kit/rules/rules/kit/code-style.md +159 -0
  62. package/kit/rules/rules/kit/pull-requests.md +59 -0
  63. package/kit/rules/rules/kit/testing.md +91 -0
  64. package/kit/rules/rules/kit/writing-for-agents.md +110 -0
  65. package/kit/workflow/agents/explore.md +25 -0
  66. package/kit/workflow/agents/implement.md +16 -0
  67. package/kit/workflow/agents/plan.md +13 -0
  68. package/kit/workflow/agents/spec-review.md +96 -0
  69. package/kit/workflow/skills/follow/SKILL.md +48 -0
  70. package/kit/workflow/skills/grill/SKILL.md +60 -0
  71. package/kit/workflow/skills/implement/SKILL.md +107 -0
  72. package/kit/workflow/skills/orchestrate/SKILL.md +565 -0
  73. package/kit/workflow/skills/orchestrate/handover/decision.ts +247 -0
  74. package/kit/workflow/skills/orchestrate/handover/index.ts +171 -0
  75. package/kit/workflow/skills/plan/SKILL.md +118 -0
  76. package/kit/workflow/skills/plan/references/template-English.md +92 -0
  77. package/kit/workflow/skills/plan/references/template-German.md +92 -0
  78. package/kit/workflow/skills/start/SKILL.md +47 -0
  79. package/package.json +63 -5
  80. package/schemas/jackherd.schema.json +601 -0
  81. package/src/check/changes.ts +45 -0
  82. package/src/check/command-line.ts +12 -0
  83. package/src/check/command.ts +136 -0
  84. package/src/check/deferral-arrows.ts +41 -0
  85. package/src/check/execute.ts +29 -0
  86. package/src/check/glob.ts +15 -0
  87. package/src/check/plan-structure-check.ts +135 -0
  88. package/src/check/plan.ts +106 -0
  89. package/src/check/public-content-check.ts +32 -0
  90. package/src/check/public-content-facts.ts +177 -0
  91. package/src/check/public-content.ts +216 -0
  92. package/src/check/report.ts +55 -0
  93. package/src/check/result.ts +42 -0
  94. package/src/check/roots.ts +36 -0
  95. package/src/check/routing.ts +22 -0
  96. package/src/check/schema.ts +83 -0
  97. package/src/check/settings.ts +44 -0
  98. package/src/claude/paths.ts +14 -0
  99. package/src/cli.ts +46 -0
  100. package/src/config/command.ts +45 -0
  101. package/src/config/detect.ts +196 -0
  102. package/src/config/index.ts +2 -0
  103. package/src/config/json-schema.ts +5 -0
  104. package/src/config/legacy-names.ts +56 -0
  105. package/src/config/link-kinds.ts +25 -0
  106. package/src/config/location.ts +46 -0
  107. package/src/config/read-config.ts +42 -0
  108. package/src/config/repository-facts.ts +19 -0
  109. package/src/config/repository-values.ts +46 -0
  110. package/src/config/schema.ts +302 -0
  111. package/src/context/command.ts +39 -0
  112. package/src/context/coordinator-name.ts +16 -0
  113. package/src/context/fill.ts +69 -0
  114. package/src/context/index.ts +10 -0
  115. package/src/context/level.ts +22 -0
  116. package/src/context/options.ts +29 -0
  117. package/src/context/report.ts +42 -0
  118. package/src/context/session-log.ts +91 -0
  119. package/src/plan-format/drift.ts +69 -0
  120. package/src/plan-format/header.ts +27 -0
  121. package/src/plan-format/index.ts +9 -0
  122. package/src/plan-format/lanes.ts +128 -0
  123. package/src/plan-format/markdown.ts +98 -0
  124. package/src/plan-format/phase-tally.ts +55 -0
  125. package/src/plan-format/phases.ts +224 -0
  126. package/src/plan-format/plan-structure.ts +150 -0
  127. package/src/plan-format/progress.ts +202 -0
  128. package/src/plan-format/sprint-document.ts +69 -0
  129. package/src/plan-format/terminal-text.ts +28 -0
  130. package/src/plans/board-choice.ts +60 -0
  131. package/src/plans/board-pick.ts +72 -0
  132. package/src/plans/board-rows.ts +62 -0
  133. package/src/plans/command.ts +60 -0
  134. package/src/plans/ensure-panes.ts +71 -0
  135. package/src/plans/error-text.ts +5 -0
  136. package/src/plans/fetch.ts +96 -0
  137. package/src/plans/git.ts +145 -0
  138. package/src/plans/github.ts +35 -0
  139. package/src/plans/lane-discovery.ts +34 -0
  140. package/src/plans/lane-plans.ts +80 -0
  141. package/src/plans/layout.ts +881 -0
  142. package/src/plans/link-chips.ts +74 -0
  143. package/src/plans/live.ts +336 -0
  144. package/src/plans/markdown-html.ts +140 -0
  145. package/src/plans/options.ts +15 -0
  146. package/src/plans/phase-source.ts +71 -0
  147. package/src/plans/plan-chips.ts +75 -0
  148. package/src/plans/plan-panes.ts +206 -0
  149. package/src/plans/planned.ts +117 -0
  150. package/src/plans/pull-requests.ts +107 -0
  151. package/src/plans/restart-decision.ts +42 -0
  152. package/src/plans/selection.ts +352 -0
  153. package/src/plans/settings.ts +68 -0
  154. package/src/plans/single-flight.ts +25 -0
  155. package/src/plans/snapshot.ts +300 -0
  156. package/src/plans/sprint-export.ts +103 -0
  157. package/src/plans/sprint-html.ts +301 -0
  158. package/src/plans/sprint-page.ts +287 -0
  159. package/src/plans/supervisor.ts +124 -0
  160. package/src/plans/symbols.ts +54 -0
  161. package/src/plans/texts.ts +103 -0
  162. package/src/plans/unlisted-lanes.ts +45 -0
  163. package/src/plans/view.ts +68 -0
  164. package/src/plans/worked-phases.ts +53 -0
  165. package/src/sync/checksum.ts +13 -0
  166. package/src/sync/choice.ts +14 -0
  167. package/src/sync/command-context.ts +113 -0
  168. package/src/sync/command.ts +94 -0
  169. package/src/sync/error-message.ts +1 -0
  170. package/src/sync/gitignore.ts +45 -0
  171. package/src/sync/global-kit.ts +176 -0
  172. package/src/sync/herdr-plugin.ts +175 -0
  173. package/src/sync/if-changed.ts +109 -0
  174. package/src/sync/init-command.ts +239 -0
  175. package/src/sync/init.ts +136 -0
  176. package/src/sync/kit-hooks.ts +18 -0
  177. package/src/sync/kit-path.ts +77 -0
  178. package/src/sync/kit-source.ts +169 -0
  179. package/src/sync/kit.ts +9 -0
  180. package/src/sync/legacy-files.ts +116 -0
  181. package/src/sync/manifest.ts +44 -0
  182. package/src/sync/next-steps.ts +42 -0
  183. package/src/sync/plan-file.ts +154 -0
  184. package/src/sync/plan-hooks.ts +165 -0
  185. package/src/sync/project-fs.ts +132 -0
  186. package/src/sync/render.ts +60 -0
  187. package/src/sync/report.ts +96 -0
  188. package/src/sync/settings-hooks.ts +90 -0
  189. package/src/sync/settings-migration.ts +94 -0
  190. package/src/sync/settings-offer.ts +168 -0
  191. package/src/sync/setup-questions.ts +70 -0
  192. package/src/sync/setup.ts +52 -0
  193. package/src/sync/sync-plan.ts +120 -0
  194. package/src/sync/sync-result.ts +9 -0
  195. package/src/sync/sync.ts +307 -0
  196. package/src/sync/tools.ts +52 -0
  197. package/src/terminal/herdr.ts +91 -0
  198. package/src/terminal/index.ts +13 -0
  199. package/src/terminal/none.ts +7 -0
  200. package/src/terminal/open.ts +39 -0
  201. package/src/terminal/text.ts +159 -0
  202. package/src/terminal/types.ts +30 -0
  203. package/src/terminal/width.ts +90 -0
  204. package/src/update/command.ts +65 -0
  205. package/src/update/package-manager.ts +56 -0
  206. package/src/update/update.ts +105 -0
  207. package/src/version/installed-version.ts +32 -0
  208. package/src/version/release.ts +22 -0
  209. package/src/version/skill-diff.ts +63 -0
  210. package/src/version/started-kit.ts +14 -0
  211. package/src/vocabulary/index.ts +132 -0
  212. package/src/watch/base-tip.ts +18 -0
  213. package/src/watch/command.ts +919 -0
  214. package/src/watch/coordinator-fill.ts +76 -0
  215. package/src/watch/feedback-read.ts +73 -0
  216. package/src/watch/feedback.ts +308 -0
  217. package/src/watch/gh.ts +22 -0
  218. package/src/watch/github-read.ts +175 -0
  219. package/src/watch/herdr.ts +11 -0
  220. package/src/watch/json-guards.ts +9 -0
  221. package/src/watch/kit-update.ts +18 -0
  222. package/src/watch/lane-fill.ts +36 -0
  223. package/src/watch/lane-message.ts +99 -0
  224. package/src/watch/lane-progress.ts +44 -0
  225. package/src/watch/lane-report-file.ts +53 -0
  226. package/src/watch/lane-report.ts +270 -0
  227. package/src/watch/lane-search.ts +41 -0
  228. package/src/watch/lane-sources.ts +46 -0
  229. package/src/watch/lane-watch.ts +121 -0
  230. package/src/watch/monitor-tool.ts +19 -0
  231. package/src/watch/options.ts +79 -0
  232. package/src/watch/plan-badges-read.ts +30 -0
  233. package/src/watch/plan-badges.ts +66 -0
  234. package/src/watch/plan-views.ts +59 -0
  235. package/src/watch/preview-read.ts +60 -0
  236. package/src/watch/previews.ts +234 -0
  237. package/src/watch/pull-requests.ts +117 -0
  238. package/src/watch/single-report.ts +15 -0
  239. package/src/watch/skill-change.ts +61 -0
  240. package/src/watch/source-dry-run-command.ts +35 -0
  241. package/src/watch/source-dry-run.ts +124 -0
  242. package/src/watch/source-fields.ts +9 -0
  243. package/src/watch/source-path.ts +40 -0
  244. package/src/watch/source-run.ts +90 -0
  245. package/src/watch/sources.ts +344 -0
  246. package/src/watch/stream-output.ts +14 -0
  247. package/src/watch/texts.ts +76 -0
  248. package/src/watch/watch-events.ts +399 -0
  249. package/src/watch/watcher-lock.ts +145 -0
  250. package/src/watch/workflow-runs.ts +96 -0
@@ -0,0 +1,76 @@
1
+ import { CONTEXT_LEVEL, type ContextLevel, type ContextReading } from '../context'
2
+ import type { ContextReport } from '../context/report'
3
+ import type { HerdrAgent } from './lane-watch'
4
+ import type { WatchTexts } from './texts'
5
+
6
+ export type CoordinatorLevel = ContextLevel
7
+
8
+ /** The coordinator's fill as `jh context <name> --coordinator` reports it. */
9
+ export type CoordinatorReading = ContextReading
10
+
11
+ /** One read of the coordinator's fill: the reading, or why there is no value. */
12
+ export type CoordinatorRead = { reading: CoordinatorReading; note?: string } | { reason: string }
13
+
14
+ /** Whether a reading fires the `coordinator` or `lane` event, and the level the state file keeps afterwards. */
15
+ export type LevelStep = { fire: boolean; level: ContextLevel | null }
16
+
17
+ /** The reason a read gives when the coordinator's log has no value, as `jh context` prints it. */
18
+ const NO_VALUE_REASON = 'null'
19
+
20
+ const RANK: Record<ContextLevel, number> = { ok: 0, warn: 1, alert: 2 }
21
+
22
+ export const isContextLevel = (value: unknown): value is ContextLevel =>
23
+ typeof value === 'string' && (Object.values(CONTEXT_LEVEL) as string[]).includes(value)
24
+
25
+ /**
26
+ * Fires when the level rises above the stored one (a missing entry counts as `ok`). A drop is stored
27
+ * without firing, so a later rise reports again; no value (`current` null) keeps what was stored.
28
+ */
29
+ export const levelStep = (previous: ContextLevel | null | undefined, current: ContextLevel | null): LevelStep => {
30
+ if (current === null) return { fire: false, level: previous ?? null }
31
+ return { fire: RANK[current] > RANK[previous ?? CONTEXT_LEVEL.ok], level: current }
32
+ }
33
+
34
+ /** The name of the agent in the watcher's own pane; null when the pane is unknown or its agent has no name. */
35
+ export const coordinatorNameOf = (agents: readonly HerdrAgent[], pane: string | undefined): string | null =>
36
+ (pane && agents.find((agent) => agent.pane_id === pane)?.name) || null
37
+
38
+ /** How the coordinator is found: an agent of its name, the unnamed agent of the watcher's own pane, or not at all. */
39
+ export type CoordinatorTarget = { kind: 'named' } | { kind: 'unnamedPane'; pane: string } | { kind: 'absent' }
40
+
41
+ /** Where to read the coordinator `name` from: by name when an agent carries it, else by the watcher's own pane if that agent has no name. */
42
+ export const coordinatorTargetOf = (
43
+ agents: readonly HerdrAgent[],
44
+ name: string,
45
+ pane: string | undefined,
46
+ ): CoordinatorTarget => {
47
+ if (agents.some((agent) => agent.name === name)) return { kind: 'named' }
48
+ const own = pane ? agents.find((agent) => agent.pane_id === pane && !agent.name) : undefined
49
+ return own ? { kind: 'unnamedPane', pane: own.pane_id } : { kind: 'absent' }
50
+ }
51
+
52
+ /** A read with its target's explanation: the rename command for an unnamed pane, `no agent named` instead of `null`. */
53
+ export const explainedRead = (
54
+ read: CoordinatorRead,
55
+ target: CoordinatorTarget,
56
+ name: string,
57
+ texts: WatchTexts,
58
+ ): CoordinatorRead => {
59
+ if (target.kind === 'named') return read
60
+ if (target.kind === 'absent') return { reason: texts.noAgentNamed(name) }
61
+ return 'reading' in read ? { ...read, note: texts.unnamedPane(name) } : { reason: texts.unnamedPane(name) }
62
+ }
63
+
64
+ /** The share and level of a context report, null without one. */
65
+ export const contextReadingOf = (report: ContextReport | null): ContextReading | null =>
66
+ report && { share: report.share, level: report.level }
67
+
68
+ /** The read of a context report with the coordinator's thresholds; no report is no value. */
69
+ export const coordinatorReadOf = (report: ContextReport | null): CoordinatorRead => {
70
+ const reading = contextReadingOf(report)
71
+ return reading ? { reading } : { reason: NO_VALUE_REASON }
72
+ }
73
+
74
+ /** The reading of a read, null when it gave a reason instead. */
75
+ export const readingOf = (read: CoordinatorRead | null): CoordinatorReading | null =>
76
+ read && 'reading' in read ? read.reading : null
@@ -0,0 +1,73 @@
1
+ import { errorText } from '../plans/error-text'
2
+ import { FEEDBACK_FORM, type FeedbackItem, type FeedbackTarget, parseFeedbackLines } from './feedback'
3
+ import { type GhAnswer, runGh } from './gh'
4
+
5
+ export type FeedbackItemsRead = { ok: true; items: FeedbackItem[] } | { ok: false; error: string }
6
+
7
+ const PAGE = 'per_page=100'
8
+ const AUTHOR = 'author: (.user.login // "ghost")'
9
+ const TEXT = 'body: (.body // "")'
10
+ const NUMBER_FROM = (url: string) => `number: (.${url} | split("/") | last | tonumber)`
11
+
12
+ /** One object per line, the fields `parseFeedbackLines` reads. */
13
+ const COMMENT_FIELDS = `.[] | {id, ${NUMBER_FROM('issue_url')}, ${AUTHOR}, createdAt: .created_at, updatedAt: .updated_at, ${TEXT}}`
14
+ const INLINE_FIELDS = `.[] | {id, ${NUMBER_FROM('pull_request_url')}, ${AUTHOR}, createdAt: .created_at, updatedAt: .updated_at, path, line: (.line // .original_line), ${TEXT}}`
15
+ /** A pending review has no `submitted_at` yet and is nobody's feedback. */
16
+ const REVIEW_FIELDS = `.[] | select(.submitted_at != null) | {id, ${AUTHOR}, state, createdAt: .submitted_at, updatedAt: .submitted_at, ${TEXT}}`
17
+
18
+ const api = (root: string, path: string, fields: string): Promise<GhAnswer> =>
19
+ runGh(['api', '--paginate', `repos/{owner}/{repo}/${path}`, '--jq', fields], root)
20
+
21
+ /** The repository's pull request comments updated since `since`, the one list a preview author's comment is read from. */
22
+ export const readCommentItems = async (root: string, since: string): Promise<FeedbackItemsRead> => {
23
+ const answer = await api(root, `issues/comments?since=${since}&${PAGE}`, COMMENT_FIELDS)
24
+ if (!answer.ok) return answer
25
+ try {
26
+ return { ok: true, items: parseFeedbackLines(answer.stdout, FEEDBACK_FORM.comment) }
27
+ } catch (error) {
28
+ return { ok: false, error: errorText(error) }
29
+ }
30
+ }
31
+
32
+ /**
33
+ * The feedback on the targets over REST, which kept answering where GitHub's GraphQL list ran into 504s: the
34
+ * repository's pull request comments and inline comments updated since `since` (new and edited ones alike), and the
35
+ * reviews of each target, which have no list across the repository. No target, no call.
36
+ */
37
+ export const readFeedbackItems = async (
38
+ root: string,
39
+ targets: readonly FeedbackTarget[],
40
+ since: string,
41
+ ): Promise<FeedbackItemsRead> => {
42
+ if (targets.length === 0) return { ok: true, items: [] }
43
+ const [comments, inline, ...reviews] = await Promise.all([
44
+ readCommentItems(root, since),
45
+ api(root, `pulls/comments?since=${since}&${PAGE}`, INLINE_FIELDS),
46
+ ...targets.map(({ number }) => api(root, `pulls/${number}/reviews?${PAGE}`, REVIEW_FIELDS)),
47
+ ])
48
+ if (!comments.ok) return comments
49
+ const failed = [inline, ...reviews].find((answer) => !answer.ok)
50
+ if (failed && !failed.ok) return { ok: false, error: failed.error }
51
+ try {
52
+ const text = (answer: GhAnswer) => (answer.ok ? answer.stdout : '')
53
+ return {
54
+ ok: true,
55
+ items: [
56
+ ...comments.items,
57
+ ...parseFeedbackLines(text(inline), FEEDBACK_FORM.inline),
58
+ ...reviews.flatMap((answer, index) =>
59
+ parseFeedbackLines(text(answer), FEEDBACK_FORM.review, targets[index].number),
60
+ ),
61
+ ],
62
+ }
63
+ } catch (error) {
64
+ return { ok: false, error: errorText(error) }
65
+ }
66
+ }
67
+
68
+ /** The login of the account `gh` runs as, whose comments are the lanes' own answers; null when it cannot be read. */
69
+ export const readOwnLogin = async (root: string): Promise<string | null> => {
70
+ const answer = await runGh(['api', 'user', '--jq', '.login'], root)
71
+ const login = answer.ok ? answer.stdout.trim() : ''
72
+ return login === '' ? null : login
73
+ }
@@ -0,0 +1,308 @@
1
+ import { isRecord } from './json-guards'
2
+ import { PULL_REQUEST_STATE, type WatchedPullRequest } from './pull-requests'
3
+
4
+ /** The three forms feedback takes on a pull request: a review, an inline review comment, a pull request comment. */
5
+ export const FEEDBACK_FORM = { review: 'review', inline: 'inline', comment: 'comment' } as const
6
+
7
+ export type FeedbackForm = (typeof FEEDBACK_FORM)[keyof typeof FEEDBACK_FORM]
8
+
9
+ export const FEEDBACK_EVENT = 'feedback'
10
+
11
+ /**
12
+ * One piece of feedback as the watcher reads it; `state` is a review's (`APPROVED`, …), null for a comment. A review's
13
+ * `updatedAt` is when it was submitted: GitHub reports no later edit of a review, so an edited review is not new.
14
+ */
15
+ export type FeedbackItem = {
16
+ form: FeedbackForm
17
+ id: number
18
+ pullRequest: number
19
+ author: string
20
+ createdAt: string
21
+ updatedAt: string
22
+ state: string | null
23
+ path: string | null
24
+ line: number | null
25
+ body: string
26
+ }
27
+
28
+ /** An open pull request of a lane whose feedback is read. */
29
+ export type FeedbackTarget = { number: number; lane: string; headCommit: string }
30
+
31
+ /**
32
+ * What the watcher remembers of one pull request's feedback: `seen` maps `<form>:<id>` to the `updatedAt` last seen,
33
+ * `count` the feedback reported since `headCommit`, which a push resets. `changesRequested` is true while a reviewer's
34
+ * latest decisive review requests changes; a push keeps it, and a state from before it was stored lacks it.
35
+ */
36
+ export type FeedbackRecord = FeedbackTarget & {
37
+ seen: Record<string, string>
38
+ count: number
39
+ changesRequested?: boolean
40
+ }
41
+
42
+ /** `since` is the epoch milliseconds of the last read, null before the first. */
43
+ export type FeedbackState = { since: number | null; pullRequests: Record<string, FeedbackRecord> }
44
+
45
+ /**
46
+ * One read of the feedback, already cut to the targets and to the authors that count. `changesRequested` names the
47
+ * targets whose reviews request changes, from every author, ignored ones too: their review blocks the merge all the same.
48
+ */
49
+ export type FeedbackRead = {
50
+ readAt: number
51
+ targets: FeedbackTarget[]
52
+ items: FeedbackItem[]
53
+ changesRequested: number[]
54
+ }
55
+
56
+ /** Whose feedback does not count: `own` is the account the lanes push with, `ignore` the project's list. */
57
+ export type FeedbackFilter = { own: string; ignore: readonly string[] }
58
+
59
+ /** `place` is `<path>:<line>` for an inline comment, else null; `text` the first line of the body. */
60
+ export type FeedbackEntry = { place: string | null; text: string }
61
+
62
+ export type FeedbackEvent = {
63
+ kind: typeof FEEDBACK_EVENT
64
+ lane: string
65
+ number: number
66
+ author: string
67
+ /** `review <state>`, `inline`, `comment`, or either of the last two with ` edited`. */
68
+ label: string
69
+ entries: FeedbackEntry[]
70
+ }
71
+
72
+ /** How far a read reaches back before the last one: room for a clock that differs from GitHub's. */
73
+ const SINCE_MARGIN_MS = 5 * 60_000
74
+ /** The latest time a JavaScript date holds; a stored `since` past it cannot come from a read. */
75
+ const LATEST_TIME_MS = 8.64e15
76
+ /** GitHub's review states as the REST API spells them. */
77
+ const REVIEW_STATE = {
78
+ approved: 'APPROVED',
79
+ changesRequested: 'CHANGES_REQUESTED',
80
+ commented: 'COMMENTED',
81
+ dismissed: 'DISMISSED',
82
+ } as const
83
+ /** The review states that decide a reviewer's verdict, as GitHub's own review decision counts them. */
84
+ const DECISIVE_REVIEW_STATES: readonly string[] = [
85
+ REVIEW_STATE.approved,
86
+ REVIEW_STATE.changesRequested,
87
+ REVIEW_STATE.dismissed,
88
+ ]
89
+ const EDITED = 'edited'
90
+ const MARKDOWN_HEADING = /^#{1,6}\s/
91
+
92
+ export const emptyFeedbackState = (): FeedbackState => ({ since: null, pullRequests: {} })
93
+
94
+ const isNullableString = (value: unknown): boolean => value === null || value === undefined || typeof value === 'string'
95
+
96
+ const isNullableNumber = (value: unknown): boolean => value === null || value === undefined || typeof value === 'number'
97
+
98
+ const isFeedbackLine = (value: unknown): value is Record<string, unknown> =>
99
+ isRecord(value) &&
100
+ typeof value.id === 'number' &&
101
+ typeof value.author === 'string' &&
102
+ typeof value.createdAt === 'string' &&
103
+ typeof value.updatedAt === 'string' &&
104
+ typeof value.body === 'string' &&
105
+ isNullableNumber(value.number) &&
106
+ isNullableString(value.state) &&
107
+ isNullableString(value.path) &&
108
+ isNullableNumber(value.line)
109
+
110
+ const itemOf = (line: Record<string, unknown>, form: FeedbackForm, pullRequest: number): FeedbackItem => ({
111
+ form,
112
+ id: line.id as number,
113
+ pullRequest,
114
+ author: line.author as string,
115
+ createdAt: line.createdAt as string,
116
+ updatedAt: line.updatedAt as string,
117
+ state: (line.state as string | undefined) ?? null,
118
+ path: (line.path as string | undefined) ?? null,
119
+ line: (line.line as number | undefined) ?? null,
120
+ body: line.body as string,
121
+ })
122
+
123
+ /**
124
+ * Reads the output of `gh api --paginate --jq '.[] | {…}'`, one JSON object per line. A comment names its pull
125
+ * request in `number`; reviews are read per pull request, which `pullRequest` names. Throws on any other shape.
126
+ */
127
+ export const parseFeedbackLines = (text: string, form: FeedbackForm, pullRequest?: number): FeedbackItem[] =>
128
+ text
129
+ .split('\n')
130
+ .filter((line) => line.trim() !== '')
131
+ .map((line) => {
132
+ const value: unknown = JSON.parse(line)
133
+ const number = isRecord(value) ? (value.number ?? pullRequest) : undefined
134
+ if (!isFeedbackLine(value) || typeof number !== 'number') throw new Error(`gh api ${form}: unexpected answer`)
135
+ return itemOf(value, form, number)
136
+ })
137
+
138
+ /** A review without text that only carries inline comments; those are reported on their own. */
139
+ const isReviewShell = (item: FeedbackItem): boolean =>
140
+ item.form === FEEDBACK_FORM.review && item.state === REVIEW_STATE.commented && item.body.trim() === ''
141
+
142
+ /** GitHub logins ignore case. */
143
+ const isFiltered = (author: string, filter: FeedbackFilter): boolean =>
144
+ [filter.own, ...filter.ignore].some((name) => name.toLowerCase() === author.toLowerCase())
145
+
146
+ /**
147
+ * True when the latest decisive review of any author requests changes. `items` hold every review of the pull request,
148
+ * since reviews are read whole on every read.
149
+ */
150
+ const requestsChanges = (items: readonly FeedbackItem[]): boolean => {
151
+ const verdicts = new Map<string, string>()
152
+ const reviews = items.filter((item) => item.form === FEEDBACK_FORM.review)
153
+ for (const { author, state } of reviews.sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))) {
154
+ if (DECISIVE_REVIEW_STATES.includes(state ?? '')) verdicts.set(author.toLowerCase(), state ?? '')
155
+ }
156
+ return [...verdicts.values()].includes(REVIEW_STATE.changesRequested)
157
+ }
158
+
159
+ /** The read with only the targets' feedback that counts: no own answers, no ignored authors, no empty review shells. */
160
+ export const feedbackReadOf = (
161
+ readAt: number,
162
+ targets: FeedbackTarget[],
163
+ items: readonly FeedbackItem[],
164
+ filter: FeedbackFilter,
165
+ ): FeedbackRead => {
166
+ const numbers = new Set(targets.map(({ number }) => number))
167
+ const counted = items.filter(
168
+ (item) => numbers.has(item.pullRequest) && !isFiltered(item.author, filter) && !isReviewShell(item),
169
+ )
170
+ const changesRequested = targets
171
+ .map(({ number }) => number)
172
+ .filter((number) => requestsChanges(items.filter((item) => item.pullRequest === number)))
173
+ return { readAt, targets, items: counted, changesRequested }
174
+ }
175
+
176
+ const keyOf = (item: FeedbackItem): string => `${item.form}:${item.id}`
177
+
178
+ const itemsOf = (read: FeedbackRead, number: number): FeedbackItem[] =>
179
+ read.items.filter((item) => item.pullRequest === number).sort((a, b) => a.updatedAt.localeCompare(b.updatedAt))
180
+
181
+ const isNew = (record: FeedbackRecord, item: FeedbackItem): boolean => record.seen[keyOf(item)] !== item.updatedAt
182
+
183
+ const labelOf = (record: FeedbackRecord, item: FeedbackItem): string => {
184
+ if (item.form === FEEDBACK_FORM.review) return `review ${(item.state ?? '').toLowerCase().replaceAll('_', ' ')}`
185
+ const edited = keyOf(item) in record.seen || item.createdAt !== item.updatedAt
186
+ return edited ? `${item.form} ${EDITED}` : item.form
187
+ }
188
+
189
+ /** The first line with text that is no Markdown heading; the first heading when the body has nothing else. */
190
+ export const firstLine = (body: string): string => {
191
+ const lines = body
192
+ .split('\n')
193
+ .map((line) => line.trim())
194
+ .filter((line) => line !== '')
195
+ return lines.find((line) => !MARKDOWN_HEADING.test(line)) ?? lines[0] ?? ''
196
+ }
197
+
198
+ const entryOf = (item: FeedbackItem): FeedbackEntry => ({
199
+ place: item.path === null ? null : `${item.path}${item.line === null ? '' : `:${item.line}`}`,
200
+ text: firstLine(item.body),
201
+ })
202
+
203
+ /** The new and edited feedback of one pull request, one event per author and label, in the order first seen. */
204
+ const eventsOfRecord = (record: FeedbackRecord, items: readonly FeedbackItem[]): FeedbackEvent[] => {
205
+ const groups = new Map<string, FeedbackEvent>()
206
+ for (const item of items.filter((candidate) => isNew(record, candidate))) {
207
+ const label = labelOf(record, item)
208
+ const key = `${item.author}\n${label}`
209
+ const event = groups.get(key) ?? {
210
+ kind: FEEDBACK_EVENT,
211
+ lane: record.lane,
212
+ number: record.number,
213
+ author: item.author,
214
+ label,
215
+ entries: [],
216
+ }
217
+ event.entries.push(entryOf(item))
218
+ groups.set(key, event)
219
+ }
220
+ return [...groups.values()]
221
+ }
222
+
223
+ /**
224
+ * The feedback that is new or edited since `previous`. A pull request without a record is a baseline: what it
225
+ * carries at its first read fires nothing, as with checks.
226
+ */
227
+ export const feedbackEvents = (previous: FeedbackState, read: FeedbackRead): FeedbackEvent[] =>
228
+ read.targets.flatMap((target) => {
229
+ const record = previous.pullRequests[String(target.number)]
230
+ return record ? eventsOfRecord({ ...record, lane: target.lane }, itemsOf(read, target.number)) : []
231
+ })
232
+
233
+ const recordOf = (
234
+ record: FeedbackRecord | undefined,
235
+ target: FeedbackTarget,
236
+ items: FeedbackItem[],
237
+ changesRequested: boolean,
238
+ ) => {
239
+ const reported = record ? items.filter((item) => isNew(record, item)).length : 0
240
+ const kept = record && record.headCommit === target.headCommit ? record.count : 0
241
+ const seen = Object.fromEntries(items.map((item) => [keyOf(item), item.updatedAt]))
242
+ return {
243
+ ...target,
244
+ seen: { ...record?.seen, ...seen },
245
+ count: kept + reported,
246
+ changesRequested,
247
+ }
248
+ }
249
+
250
+ /** The state after `read`: every target's feedback marked seen, its count, and the targets only. */
251
+ export const recordFeedback = (previous: FeedbackState, read: FeedbackRead): FeedbackState => ({
252
+ since: read.readAt,
253
+ pullRequests: Object.fromEntries(
254
+ read.targets.map((target) => [
255
+ String(target.number),
256
+ recordOf(
257
+ previous.pullRequests[String(target.number)],
258
+ target,
259
+ itemsOf(read, target.number),
260
+ read.changesRequested.includes(target.number),
261
+ ),
262
+ ]),
263
+ ),
264
+ })
265
+
266
+ /** The `since` of the next read as GitHub takes it: the last read minus a margin, now minus it on the first. */
267
+ export const feedbackSince = (state: Pick<FeedbackState, 'since'>, now: number): string =>
268
+ new Date((state.since ?? now) - SINCE_MARGIN_MS).toISOString()
269
+
270
+ /**
271
+ * The pull requests whose feedback is read: the open ones of lane branches when GitHub's list answered, else the
272
+ * ones the state follows, so a failing list does not stop the feedback.
273
+ */
274
+ export const feedbackTargets = (
275
+ pullRequests: readonly WatchedPullRequest[] | null,
276
+ laneBranches: Readonly<Record<string, string>>,
277
+ state: FeedbackState,
278
+ ): FeedbackTarget[] => {
279
+ if (!pullRequests)
280
+ return Object.values(state.pullRequests).map(({ number, lane, headCommit }) => ({ number, lane, headCommit }))
281
+ return pullRequests.flatMap(({ number, branch, state: lifecycle, headCommit }) => {
282
+ const lane = Object.keys(laneBranches).find((name) => laneBranches[name] === branch)
283
+ return lane && lifecycle === PULL_REQUEST_STATE.open ? [{ number, lane, headCommit }] : []
284
+ })
285
+ }
286
+
287
+ const isPullRequestNumber = (value: unknown): boolean =>
288
+ typeof value === 'number' && Number.isSafeInteger(value) && value > 0
289
+
290
+ /** An epoch time a read can have stamped, or null before the first read. */
291
+ export const isReadTime = (value: unknown): boolean =>
292
+ value === null || (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 && value <= LATEST_TIME_MS)
293
+
294
+ const isFeedbackRecord = (value: unknown): boolean =>
295
+ isRecord(value) &&
296
+ isPullRequestNumber(value.number) &&
297
+ typeof value.lane === 'string' &&
298
+ typeof value.headCommit === 'string' &&
299
+ typeof value.count === 'number' &&
300
+ (value.changesRequested === undefined || typeof value.changesRequested === 'boolean') &&
301
+ isRecord(value.seen) &&
302
+ Object.values(value.seen).every((entry) => typeof entry === 'string')
303
+
304
+ export const isFeedbackState = (value: unknown): value is FeedbackState =>
305
+ isRecord(value) &&
306
+ isReadTime(value.since) &&
307
+ isRecord(value.pullRequests) &&
308
+ Object.values(value.pullRequests).every(isFeedbackRecord)
@@ -0,0 +1,22 @@
1
+ import { errorText } from '../plans/error-text'
2
+
3
+ /** The answer of one `gh` call: its output, or the reason it failed (exit code, stderr, gh not installed). */
4
+ export type GhAnswer = { ok: true; stdout: string } | { ok: false; error: string }
5
+
6
+ /** Runs `gh` in `cwd`; never rejects, so a caller decides what a failed call means. */
7
+ export const runGh = async (args: string[], cwd: string): Promise<GhAnswer> => {
8
+ const command = `gh ${args.slice(0, 2).join(' ')}`
9
+ try {
10
+ const child = Bun.spawn(['gh', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' })
11
+ const [stdout, stderr, exitCode] = await Promise.all([
12
+ new Response(child.stdout).text(),
13
+ new Response(child.stderr).text(),
14
+ child.exited,
15
+ ])
16
+ if (exitCode === 0) return { ok: true, stdout }
17
+ const reason = stderr.trim().replace(/\s*\n\s*/g, ' ') || `exit ${exitCode}`
18
+ return { ok: false, error: `${command}: ${reason}` }
19
+ } catch (error) {
20
+ return { ok: false, error: `${command}: ${errorText(error)}` }
21
+ }
22
+ }
@@ -0,0 +1,175 @@
1
+ import { terminalSafe } from '../plan-format/terminal-text'
2
+ import { errorText } from '../plans/error-text'
3
+ import { GITHUB_HOST } from '../plans/github'
4
+ import { type GhAnswer, runGh } from './gh'
5
+ import { branchBatches, onBranches, searchableBranches, searchTextOf } from './lane-search'
6
+ import {
7
+ mergeCheckedPullRequests,
8
+ OPEN_PULL_REQUEST_FIELDS,
9
+ parseOpenPullRequests,
10
+ parseWatchedPullRequests,
11
+ WATCHED_PULL_REQUEST_FIELDS,
12
+ type WatchedPullRequest,
13
+ } from './pull-requests'
14
+ import {
15
+ type DeployConfig,
16
+ isDeployTarget,
17
+ parseDeployments,
18
+ parseWorkflowRuns,
19
+ runIdOfLogUrl,
20
+ runListArgs,
21
+ type WorkflowRun,
22
+ } from './workflow-runs'
23
+
24
+ export type GithubAnswer = {
25
+ ok: true
26
+ pullRequests: WatchedPullRequest[]
27
+ runs: WorkflowRun[]
28
+ /** The open pull requests of the signed-in user on a branch no lane has, by number. */
29
+ own: number[]
30
+ }
31
+
32
+ /** One read of GitHub: the pull requests and deploy runs, or why `gh` failed. */
33
+ export type GithubRead = GithubAnswer | { ok: false; error: string }
34
+
35
+ /** A deploy run's environment, or why it could not be read. */
36
+ export type DeployTarget = { target: string } | { reason: string }
37
+
38
+ const PULL_REQUEST_LIMIT = '100'
39
+ const SEARCH_RESULT_LIMIT = '100'
40
+ const DEPLOYMENT_LIMIT = 30
41
+
42
+ export const answered = (github: GithubRead | null): GithubAnswer | null => (github?.ok ? github : null)
43
+
44
+ export const targetText = (deploy: DeployTarget): string =>
45
+ 'target' in deploy ? deploy.target : `unknown (${deploy.reason})`
46
+
47
+ /** The deploy workflow's runs on the base branch; none, without asking `gh`, when the project reports no deploys. */
48
+ const readRuns = async (root: string, deploy: DeployConfig | undefined, baseBranch: string) => {
49
+ const args = runListArgs(deploy, baseBranch)
50
+ return args ? runGh(args, root) : { ok: true as const, stdout: '[]' }
51
+ }
52
+
53
+ /** One search for the open pull requests of a batch of branches, with their checks. */
54
+ const searchOpen = (root: string, branches: readonly string[]) =>
55
+ runGh(
56
+ [
57
+ 'pr',
58
+ 'list',
59
+ '--state',
60
+ 'open',
61
+ '--limit',
62
+ SEARCH_RESULT_LIMIT,
63
+ '--search',
64
+ searchTextOf(branches),
65
+ '--json',
66
+ OPEN_PULL_REQUEST_FIELDS,
67
+ ],
68
+ root,
69
+ )
70
+
71
+ /** The open pull requests of the account `gh` runs as, with their checks. */
72
+ const searchOwn = (root: string) =>
73
+ runGh(
74
+ [
75
+ 'pr',
76
+ 'list',
77
+ '--state',
78
+ 'open',
79
+ '--author',
80
+ '@me',
81
+ '--limit',
82
+ SEARCH_RESULT_LIMIT,
83
+ '--json',
84
+ OPEN_PULL_REQUEST_FIELDS,
85
+ ],
86
+ root,
87
+ )
88
+
89
+ /** The own pull requests on a branch that is no lane's; a lane branch the search could not take counts as a lane's too. */
90
+ const ownWithoutLane = (text: string, laneBranches: readonly string[]) => {
91
+ const lanes = new Set(laneBranches)
92
+ return parseOpenPullRequests(text, GITHUB_HOST).filter(({ branch }) => !lanes.has(branch))
93
+ }
94
+
95
+ /** The open pull requests of the lane branches with their checks: the answers of one search per batch, in parallel. */
96
+ const searchLanes = (root: string, batches: readonly (readonly string[])[]): Promise<GhAnswer[]> =>
97
+ Promise.all(batches.map((batch) => searchOpen(root, batch)))
98
+
99
+ /** The open pull requests the searches found, each answer cut to the branches of its own batch. */
100
+ const lanePullRequestsOf = (texts: readonly string[], batches: readonly (readonly string[])[]) =>
101
+ texts.flatMap((text, index) => onBranches(parseOpenPullRequests(text, GITHUB_HOST), batches[index]))
102
+
103
+ /** The first failure among the answers, else all their outputs in the same order. */
104
+ const outputsOf = (answers: readonly GhAnswer[]): { outputs: string[] } | { error: string } => {
105
+ const failed = answers.find((answer): answer is Extract<GhAnswer, { ok: false }> => !answer.ok)
106
+ return failed ? { error: failed.error } : { outputs: answers.flatMap((answer) => (answer.ok ? [answer.stdout] : [])) }
107
+ }
108
+
109
+ /**
110
+ * The repository's pull requests in every state and, with `deploy`, the deploy runs on the base branch. The checks
111
+ * come from a search for the open pull requests of `laneBranches` (none without a branch, no call either) and from
112
+ * one search for the open pull requests of the signed-in user, whose ones on a branch without a lane are named in
113
+ * `own`: one call for 100 pull requests of every state with their `statusCheckRollup` ran into GitHub's GraphQL
114
+ * timeout (a 504 after ~10 s), and a list of all open pull requests grows with the team's. If several reads fail,
115
+ * the first in the order light list, own search, lane searches, deploy runs is the one reported.
116
+ */
117
+ export const readGithub = async (
118
+ root: string,
119
+ deploy: DeployConfig | undefined,
120
+ baseBranch: string,
121
+ laneBranches: readonly string[],
122
+ ): Promise<GithubRead> => {
123
+ const batches = branchBatches(searchableBranches(laneBranches, baseBranch))
124
+ const [listed, ownAnswer, searches, runs] = await Promise.all([
125
+ runGh(['pr', 'list', '--state', 'all', '--limit', PULL_REQUEST_LIMIT, '--json', WATCHED_PULL_REQUEST_FIELDS], root),
126
+ searchOwn(root),
127
+ searchLanes(root, batches),
128
+ readRuns(root, deploy, baseBranch),
129
+ ])
130
+ const read = outputsOf([listed, ownAnswer, ...searches, runs])
131
+ if ('error' in read) return { ok: false, error: read.error }
132
+ const [listedText, ownText, ...rest] = read.outputs
133
+ const runsText = rest.pop() ?? '[]'
134
+ try {
135
+ const own = ownWithoutLane(ownText, laneBranches)
136
+ const pullRequests = mergeCheckedPullRequests(parseWatchedPullRequests(listedText, GITHUB_HOST), [
137
+ ...own,
138
+ ...lanePullRequestsOf(rest, batches),
139
+ ])
140
+ return { ok: true, pullRequests, runs: parseWorkflowRuns(runsText), own: own.map(({ number }) => number) }
141
+ } catch (error) {
142
+ return { ok: false, error: errorText(error) }
143
+ }
144
+ }
145
+
146
+ /** The target an environment names: one of the configured environments, or the reason it is none. */
147
+ export const targetOfEnvironment = (environment: string, deploy: DeployConfig): DeployTarget =>
148
+ isDeployTarget(environment, deploy.environments)
149
+ ? { target: environment }
150
+ : { reason: `environment ${terminalSafe(environment)}` }
151
+
152
+ /** The environment of the run's own deployment among those of its commit; the reason when there is none. */
153
+ export const deployTargetOf = async (
154
+ root: string,
155
+ deploy: DeployConfig,
156
+ runId: number,
157
+ headSha: string,
158
+ ): Promise<DeployTarget> => {
159
+ const deploymentsPath = `repos/{owner}/{repo}/deployments?sha=${headSha}&per_page=${DEPLOYMENT_LIMIT}`
160
+ const listed = await runGh(['api', deploymentsPath, '--jq', '[.[] | {id, environment}]'], root)
161
+ if (!listed.ok) return { reason: listed.error }
162
+ let deployments: ReturnType<typeof parseDeployments>
163
+ try {
164
+ deployments = parseDeployments(listed.stdout)
165
+ } catch (error) {
166
+ return { reason: errorText(error) }
167
+ }
168
+ for (const { id, environment } of deployments) {
169
+ const statusPath = `repos/{owner}/{repo}/deployments/${id}/statuses?per_page=1`
170
+ const status = await runGh(['api', statusPath, '--jq', '.[0].log_url // ""'], root)
171
+ if (!status.ok) return { reason: status.error }
172
+ if (runIdOfLogUrl(status.stdout.trim()) === runId) return targetOfEnvironment(environment, deploy)
173
+ }
174
+ return { reason: 'no deployment of this run' }
175
+ }
@@ -0,0 +1,11 @@
1
+ import { runHerdr } from '../terminal/herdr'
2
+ import { agentsOf, type HerdrAgent } from './lane-watch'
3
+
4
+ const SCREEN_READ_LINES = '50'
5
+
6
+ /** The agents herdr lists, with the `state_change_seq` the terminal adapter's `LaneAgent` does not carry. */
7
+ export const listAgents = (): HerdrAgent[] => agentsOf(runHerdr(['agent', 'list']))
8
+
9
+ /** The last lines of a lane agent's screen, as herdr's detection reads them; throws when herdr cannot. */
10
+ export const agentScreen = (lane: string): string =>
11
+ runHerdr(['agent', 'read', lane, '--source', 'detection', '--lines', SCREEN_READ_LINES])
@@ -0,0 +1,9 @@
1
+ export const isRecord = (value: unknown): value is Record<string, unknown> =>
2
+ typeof value === 'object' && value !== null && !Array.isArray(value)
3
+
4
+ /** The entries of a JSON array when every one passes `isValid`; throws `<source>: unexpected answer` otherwise. */
5
+ export const parseArray = <T>(json: string, isValid: (entry: unknown) => entry is T, source: string): T[] => {
6
+ const answer: unknown = JSON.parse(json)
7
+ if (!Array.isArray(answer) || !answer.every(isValid)) throw new Error(`${source}: unexpected answer`)
8
+ return answer
9
+ }