jackherd 1.15.2 → 1.17.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 (161) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/INSTALL.md +2 -1
  3. package/README.md +26 -22
  4. package/dist/{chunk-4LUFR7RC.js → chunk-N5FAOQZL.js} +7 -0
  5. package/dist/config.d.ts +33 -1
  6. package/dist/config.js +57 -5
  7. package/dist/plan-format.d.ts +18 -6
  8. package/dist/plan-format.js +8 -1
  9. package/docs/plan-contract.md +1 -0
  10. package/herdr-plugin/herdr-plugin.toml +4 -1
  11. package/herdr-plugin/restore-plans.ts +60 -26
  12. package/herdr-plugin/resume.ts +18 -0
  13. package/install.sh +1 -1
  14. package/kit/hooks/ask-rules.json +11 -0
  15. package/kit/hooks/hooks/approval-gate/checked-marks.ts +59 -0
  16. package/kit/hooks/hooks/approval-gate/index.ts +36 -7
  17. package/kit/hooks/hooks/approval-gate/run.ts +72 -16
  18. package/kit/hooks/hooks/approval-gate/settings.ts +27 -1
  19. package/kit/hooks/hooks/approval-gate/standing-history.ts +90 -0
  20. package/kit/hooks/hooks/approval-gate/standing-sql.ts +58 -0
  21. package/kit/hooks/hooks/approval-gate/transcripts.ts +11 -5
  22. package/kit/hooks/hooks/lane-start-guard/decision.ts +74 -0
  23. package/kit/hooks/hooks/lane-start-guard/index.ts +36 -0
  24. package/kit/hooks/hooks/merge-guard/always-ask.ts +2 -2
  25. package/kit/hooks/hooks/merge-guard/approval.ts +25 -13
  26. package/kit/hooks/hooks/merge-guard/change-id.ts +34 -0
  27. package/kit/hooks/hooks/merge-guard/decision.ts +438 -35
  28. package/kit/hooks/hooks/merge-guard/pane-input.ts +61 -0
  29. package/kit/hooks/hooks/merge-guard/pane-process-info.ts +38 -0
  30. package/kit/hooks/hooks/merge-guard/run.ts +110 -28
  31. package/kit/hooks/hooks/merge-guard/standing-approval.ts +15 -78
  32. package/kit/hooks/hooks/plain-question/form.ts +89 -0
  33. package/kit/hooks/hooks/plain-question/index.ts +44 -4
  34. package/kit/hooks/hooks/plain-question/recommendation.ts +73 -0
  35. package/kit/hooks/hooks/plain-question/run.ts +14 -7
  36. package/kit/hooks/hooks/shared/standing-mark.ts +104 -0
  37. package/kit/hooks/hooks.json +11 -2
  38. package/kit/review/agents/skill-review.md +3 -2
  39. package/kit/review/skills/pre-commit/SKILL.md +1 -1
  40. package/kit/rules/rules/kit/code-style.md +6 -6
  41. package/kit/rules/rules/kit/writing-for-agents.md +3 -0
  42. package/kit/workflow/skills/follow/SKILL.md +18 -11
  43. package/kit/workflow/skills/implement/SKILL.md +1 -1
  44. package/kit/workflow/skills/orchestrate/SKILL.md +124 -300
  45. package/kit/workflow/skills/orchestrate/references/appointments.md +21 -0
  46. package/kit/workflow/skills/orchestrate/references/close-lane.md +8 -7
  47. package/kit/workflow/skills/orchestrate/references/coordinator-handover.md +8 -8
  48. package/kit/workflow/skills/orchestrate/references/events.md +46 -1
  49. package/kit/workflow/skills/orchestrate/references/herd-ask.md +10 -0
  50. package/kit/workflow/skills/orchestrate/references/lane-handover.md +9 -4
  51. package/kit/workflow/skills/orchestrate/references/lane-session.md +5 -4
  52. package/kit/workflow/skills/orchestrate/references/lane-setup.md +2 -1
  53. package/kit/workflow/skills/orchestrate/references/plan-commit.md +9 -2
  54. package/kit/workflow/skills/orchestrate/references/self-merge.md +26 -0
  55. package/kit/workflow/skills/orchestrate/references/stage-approval.md +50 -0
  56. package/kit/workflow/skills/orchestrate/references/standing-approval.md +38 -0
  57. package/kit/workflow/skills/orchestrate/references/start-lane.md +67 -0
  58. package/kit/workflow/skills/orchestrate/references/waiting.md +16 -0
  59. package/kit/workflow/skills/orchestrate/references/watch-without-monitor.md +13 -0
  60. package/kit/workflow/skills/plan/SKILL.md +3 -2
  61. package/package.json +3 -1
  62. package/recipes/README.md +4 -2
  63. package/recipes/google-calendar.md +30 -0
  64. package/recipes/jira-cli.md +1 -1
  65. package/recipes/sources/google-calendar-today.ts +38 -0
  66. package/recipes/sources/google-calendar.ts +368 -0
  67. package/recipes/sources/jira.ts +10 -2
  68. package/recipes/sources/linear.ts +9 -3
  69. package/recipes/sources/sentry.ts +8 -2
  70. package/recipes/sources.md +17 -3
  71. package/schemas/jackherd.schema.json +67 -0
  72. package/schemas/sources.schema.json +9 -0
  73. package/src/approval/checked.ts +60 -0
  74. package/src/approval/command.ts +16 -0
  75. package/src/check/command.ts +51 -6
  76. package/src/check/lint-pointers.ts +79 -0
  77. package/src/check/lint-shaped.ts +196 -0
  78. package/src/check/plan-structure-check.ts +116 -4
  79. package/src/check/report.ts +9 -3
  80. package/src/check/schema.ts +37 -0
  81. package/src/check/settings.ts +4 -0
  82. package/src/check/skill-budget-defaults.ts +81 -23
  83. package/src/check/skill-budgets.ts +58 -10
  84. package/src/cli.ts +2 -0
  85. package/src/config/personal-sources.ts +46 -10
  86. package/src/config/repository-identity.ts +40 -0
  87. package/src/config/schema.ts +53 -1
  88. package/src/context/command.ts +33 -2
  89. package/src/context/options.ts +33 -8
  90. package/src/context/usage-logs.ts +53 -0
  91. package/src/context/usage.ts +110 -0
  92. package/src/lane/base-command.ts +35 -0
  93. package/src/lane/base.ts +43 -0
  94. package/src/lane/close-check.ts +49 -15
  95. package/src/lane/close-command.ts +71 -11
  96. package/src/lane/close-options.ts +29 -8
  97. package/src/lane/close-survey.ts +13 -1
  98. package/src/lane/close.ts +51 -12
  99. package/src/lane/command.ts +21 -3
  100. package/src/lane/send-command.ts +32 -0
  101. package/src/lane/send-herdr.ts +77 -0
  102. package/src/lane/send-options.ts +23 -0
  103. package/src/lane/send.ts +90 -0
  104. package/src/lane/skill-hint.ts +27 -0
  105. package/src/lane/space-command.ts +30 -0
  106. package/src/lane/space.ts +49 -0
  107. package/src/lane/start-lane-hint.ts +18 -0
  108. package/src/plan-contract/base-check-git.ts +33 -0
  109. package/src/plan-contract/base-check.ts +17 -0
  110. package/src/plan-contract/command.ts +25 -4
  111. package/src/plan-format/plan-structure.ts +17 -5
  112. package/src/plans/ask-coordinator.ts +23 -13
  113. package/src/plans/board-rows.ts +9 -4
  114. package/src/plans/click-hint.ts +4 -0
  115. package/src/plans/command.ts +6 -5
  116. package/src/plans/ensure-panes.ts +14 -3
  117. package/src/plans/git.ts +14 -0
  118. package/src/plans/github.ts +19 -10
  119. package/src/plans/layout.ts +191 -25
  120. package/src/plans/live.ts +57 -14
  121. package/src/plans/plan-panes.ts +21 -4
  122. package/src/plans/row-actions.ts +17 -10
  123. package/src/plans/selection.ts +29 -0
  124. package/src/plans/settings.ts +4 -0
  125. package/src/plans/snapshot.ts +8 -3
  126. package/src/plans/source-rows.ts +35 -2
  127. package/src/resume/command.ts +213 -0
  128. package/src/resume/decision.ts +102 -0
  129. package/src/resume/keeper.ts +65 -0
  130. package/src/resume/location.ts +22 -0
  131. package/src/resume/log.ts +27 -0
  132. package/src/resume/ports.ts +102 -0
  133. package/src/resume/record.ts +116 -0
  134. package/src/resume/targets.ts +84 -0
  135. package/src/resume/texts.ts +27 -0
  136. package/src/secrets/command.ts +15 -5
  137. package/src/sync/herdr-plugin.ts +2 -11
  138. package/src/sync/plan-ask-rules.ts +11 -0
  139. package/src/sync/render.ts +3 -1
  140. package/src/vocabulary/index.ts +13 -0
  141. package/src/waiting/command.ts +13 -4
  142. package/src/watch/command.ts +177 -78
  143. package/src/watch/config-reload.ts +119 -0
  144. package/src/watch/lane-model.ts +11 -1
  145. package/src/watch/lane-progress.ts +70 -8
  146. package/src/watch/lane-report.ts +68 -12
  147. package/src/watch/main-checkout.ts +5 -1
  148. package/src/watch/merge-duties.ts +33 -0
  149. package/src/watch/merge-overlap-read.ts +69 -0
  150. package/src/watch/plan-views.ts +16 -4
  151. package/src/watch/reference-paths.ts +46 -5
  152. package/src/watch/report-questions.ts +5 -2
  153. package/src/watch/single-report.ts +3 -2
  154. package/src/watch/source-dry-run-command.ts +22 -4
  155. package/src/watch/source-dry-run.ts +1 -1
  156. package/src/watch/source-reason.ts +58 -0
  157. package/src/watch/source-reload.ts +21 -17
  158. package/src/watch/source-run.ts +24 -7
  159. package/src/watch/sources.ts +9 -2
  160. package/src/watch/texts.ts +15 -4
  161. package/src/watch/waiting-line.ts +4 -3
@@ -0,0 +1,60 @@
1
+ import { createHash } from 'node:crypto'
2
+ import { terminalSafe } from '../plan-format/terminal-text'
3
+ import { isRecord } from '../shared/is-record'
4
+ import type { ApprovalRequestFile } from '../watch/approvals'
5
+
6
+ /** What `jh approval show|checked` prints and how it ends: 0 shown, 1 wrong arguments, 2 no such open request. */
7
+ export type ApprovalReport = { exitCode: number; stdout: string[]; stderr: string[] }
8
+
9
+ type RequestCall = Pick<ApprovalRequestFile, 'lane' | 'cwd' | 'tool' | 'input'>
10
+
11
+ const VERBS = new Set(['show', 'checked'])
12
+ const USAGE = 'Usage: jh approval show|checked <id> <hash>'
13
+ const ID = /^[0-9a-f]{12}$/
14
+ const HASH = /^[0-9a-f]{16}$/
15
+
16
+ /** JSON with the keys of every object sorted, as the approval gate hook hashes a call. */
17
+ const canonicalJson = (value: unknown): string => {
18
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
19
+ if (isRecord(value))
20
+ return `{${Object.keys(value)
21
+ .sort()
22
+ .map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
23
+ .join(',')}}`
24
+ return JSON.stringify(value) ?? 'null'
25
+ }
26
+
27
+ /** The hash of a call as the approval gate hook writes it (`callHash`, which this package does not import; contract test). */
28
+ export const requestHash = ({ lane, cwd, tool, input }: RequestCall): string =>
29
+ createHash('sha256').update(canonicalJson({ lane, cwd, tool, input })).digest('hex').slice(0, 16)
30
+
31
+ const refused = (exitCode: number, line: string): ApprovalReport => ({ exitCode, stdout: [], stderr: [line] })
32
+
33
+ /** The request as the coordinator reads it before it marks it: every field the lane wrote, made safe to print. */
34
+ const shown = ({ id, hash, lane, tool, cwd, input }: ApprovalRequestFile): string[] => [
35
+ `approval ${id} ${hash}`,
36
+ `lane: ${terminalSafe(lane)}`,
37
+ `tool: ${terminalSafe(tool)}`,
38
+ `cwd: ${terminalSafe(cwd)}`,
39
+ 'input:',
40
+ ...(JSON.stringify(input, null, 2) ?? 'null').split('\n').map(terminalSafe),
41
+ ]
42
+
43
+ /**
44
+ * `jh approval show <id> <hash>` and `jh approval checked <id> <hash>`: print the open request whose id and hash these
45
+ * are, and whose content its hash covers, and end with 0; otherwise say why and end with 2. Neither writes anything.
46
+ * `show` is for reading; a successful `checked` in the coordinator's transcript is the mark the approval gate hook
47
+ * reads as "the coordinator checked exactly this call", so it comes after the check, never before.
48
+ */
49
+ export const approvalReport = (args: readonly string[], requests: readonly ApprovalRequestFile[]): ApprovalReport => {
50
+ const [verb, id, hash, ...rest] = args
51
+ if (!VERBS.has(verb ?? '') || !ID.test(id ?? '') || !HASH.test(hash ?? '') || rest.length > 0)
52
+ return refused(1, USAGE)
53
+ const request = requests.find((candidate) => candidate.id === id && candidate.usedAt === null)
54
+ if (request === undefined) return refused(2, `no open request ${id}`)
55
+ if (request.hash !== hash)
56
+ return refused(2, `request ${id} has hash ${request.hash}, not ${hash}: it changed, look at it again`)
57
+ if (requestHash(request) !== hash)
58
+ return refused(2, `request ${id} does not match its hash: it was changed by hand, ask the user`)
59
+ return { exitCode: 0, stdout: shown(request), stderr: [] }
60
+ }
@@ -0,0 +1,16 @@
1
+ import { mainCheckout } from '../plans/git'
2
+ import { readApprovalRequests } from '../watch/approvals'
3
+ import { approvalReport } from './checked'
4
+
5
+ /** `jh approval show|checked <id> <hash>`: read a held call, and the coordinator's mark that it checked it (`approvalReport`). */
6
+ export const run = (args: string[]): number => {
7
+ try {
8
+ const report = approvalReport(args, readApprovalRequests(mainCheckout(process.cwd())))
9
+ for (const line of report.stdout) console.log(line)
10
+ for (const line of report.stderr) console.error(line)
11
+ return report.exitCode
12
+ } catch (error) {
13
+ console.error(`jh approval: ${error instanceof Error ? error.message : String(error)}`)
14
+ return 1
15
+ }
16
+ }
@@ -3,17 +3,21 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs'
3
3
  import { homedir } from 'node:os'
4
4
  import { join, resolve } from 'node:path'
5
5
  import { planRootsIn } from '../../kit/hooks/hooks/shared/boards'
6
+ import { laneOf } from '../../kit/hooks/hooks/shared/lane'
6
7
  import { findProjectRoot, globalConfigPath, locateConfigFile, MANIFEST_FILE, TOOL_DIRECTORY } from '../config/location'
7
8
  import { readRepositoryFacts } from '../config/repository-facts'
9
+ import { installedReferenceHint } from '../lane/skill-hint'
8
10
  import { mainCheckout } from '../plans/git'
9
11
  import { baseRefOf } from '../plans/settings'
12
+ import { REFERENCE } from '../watch/reference-paths'
10
13
  import { type ChangedFile, parsePorcelain, STATUS_COMMAND } from './changes'
11
14
  import { deferralArrowCheck } from './deferral-arrows'
12
15
  import { runCheck, runProcess } from './execute'
13
16
  import { matchesAnyGlob } from './glob'
17
+ import { lintPointerCheck } from './lint-pointers'
14
18
  import { parallelArgument, parallelReportLines } from './parallel-report'
15
19
  import { type CheckPlan, type CheckStack, checkStackOf, type Filesystem, isSlow, planChecks } from './plan'
16
- import { planStructureChecks } from './plan-structure-check'
20
+ import { laneOpenBoxesCheck, planStructureChecks } from './plan-structure-check'
17
21
  import { publicContentCheck } from './public-content-check'
18
22
  import {
19
23
  artifactResult,
@@ -29,6 +33,7 @@ import {
29
33
  } from './report'
30
34
  import { type CheckResult, RUN_MODE, type RunMode, runModeOf } from './result'
31
35
  import { isWorkspaceManifest, kitCopyPaths, WORKSPACE_LIST_FILE, WORKSPACE_MANIFEST } from './roots'
36
+ import { DEFAULT_LINT_POINTERS } from './schema'
32
37
  import { type CheckSettings, CONFIG_PATH, noStackFallbackOf, parseCheckSettings, stackOf } from './settings'
33
38
  import { skillBudgetCheck } from './skill-budgets'
34
39
 
@@ -199,11 +204,50 @@ const planRootsOf = (root: string, worktreeDirectory: string): string[] => {
199
204
  }
200
205
  }
201
206
 
202
- /** The checks jackherd runs itself beside the configured ones: public content, deferral arrows, skill budgets, plan structure. */
203
- const ownChecksOf = (root: string, paths: readonly string[], settings: CheckSettings, home: string) => [
207
+ /** The lane a checkout is for: the first directory below the main checkout's worktree directory; null outside one. */
208
+ const laneOfCheckout = (root: string, worktreeDirectory: string): string | null => {
209
+ try {
210
+ return laneOf({ cwd: root, main: mainCheckout(root), worktreeDirectory })
211
+ } catch {
212
+ return null
213
+ }
214
+ }
215
+
216
+ /** The open boxes of the lane's phase, left out for a checkout that is no lane's. */
217
+ const openBoxesCheckOf = (root: string, settings: CheckSettings): CheckResult | null => {
218
+ const lane = laneOfCheckout(root, settings.worktreeDirectory)
219
+ return lane === null
220
+ ? null
221
+ : laneOpenBoxesCheck({
222
+ root,
223
+ lane,
224
+ planDirectory: settings.planDirectory,
225
+ board: settings.board,
226
+ language: settings.planLanguage,
227
+ level: settings.check.openBoxes,
228
+ baseOf: contentAt(root, baseRefOf(settings)),
229
+ })
230
+ }
231
+
232
+ /** The checks jackherd runs itself beside the configured ones: public content, deferral arrows, skill budgets, lint pointers, plan structure. */
233
+ const ownChecksOf = (
234
+ root: string,
235
+ paths: readonly string[],
236
+ settings: CheckSettings,
237
+ home: string,
238
+ kitCopies: readonly string[],
239
+ ) => [
240
+ openBoxesCheckOf(root, settings),
204
241
  publicContentCheck(root, paths, settings.check.publicContent, home),
205
242
  deferralArrowCheck(root, paths, settings.planDirectory),
206
243
  skillBudgetCheck(root, settings.check.skillBudgets, settings.skillPrefix),
244
+ lintPointerCheck(
245
+ root,
246
+ paths,
247
+ settings.check.lintPointers ?? DEFAULT_LINT_POINTERS,
248
+ kitCopies,
249
+ contentAt(root, 'HEAD'),
250
+ ),
207
251
  ...planStructureChecks({
208
252
  root,
209
253
  paths,
@@ -228,8 +272,9 @@ const checkChanges = async (
228
272
  ): Promise<CheckResult[] | string> => {
229
273
  const stack = checkStackOf(stackOf(settings.stack))
230
274
  let plan: CheckPlan
275
+ let kitCopies: string[]
231
276
  try {
232
- const kitCopies = kitCopyPaths(readJsonIfPresent(join(root, TOOL_DIRECTORY, MANIFEST_FILE)))
277
+ kitCopies = kitCopyPaths(readJsonIfPresent(join(root, TOOL_DIRECTORY, MANIFEST_FILE)))
233
278
  plan = planChecks(
234
279
  files,
235
280
  {
@@ -253,7 +298,7 @@ const checkChanges = async (
253
298
  testCommandResult(settings.commands.test, settings.stack),
254
299
  missingTestCommandResult(settings.commands.test, settings.stack),
255
300
  namedTestResult(plan.sourcesWithoutNamedTest),
256
- ...ownChecksOf(root, plan.paths, settings, home),
301
+ ...ownChecksOf(root, plan.paths, settings, home, kitCopies),
257
302
  ])
258
303
  }
259
304
 
@@ -282,7 +327,7 @@ export const run = async (args: string[], home: string = homedir()): Promise<num
282
327
  }
283
328
  print(settings.notes)
284
329
  if (files.length === 0) {
285
- console.log('Nothing changed.')
330
+ console.log(`Nothing changed.${installedReferenceHint(root, REFERENCE.events, home)}`)
286
331
  return 0
287
332
  }
288
333
  const results = await checkChanges(files, root, settings, runModeOf(args), home)
@@ -0,0 +1,79 @@
1
+ import { existsSync, readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { matchesAnyGlob } from './glob'
4
+ import {
5
+ changedBlocksOf,
6
+ LINT_MARKER,
7
+ LINT_NONE,
8
+ LINT_POINTER_STATE,
9
+ LINT_POINTERS_NAME,
10
+ type LintFinding,
11
+ lintFindingsOf,
12
+ } from './lint-shaped'
13
+ import { CHECK_STATUS, type CheckResult } from './result'
14
+ import { LINT_POINTERS_LEVEL, type LintPointersConfig, type LintPointersLevel } from './schema'
15
+
16
+ /** The texts a project writes its rules in: rule files and the instruction files. Skills carry commands, the hooks' field. */
17
+ const LINT_POINTER_GLOBS = ['**/rules/**/*.md', '**/CLAUDE.md', '**/AGENTS.md'] as const
18
+
19
+ export const isLintPointerPath = (path: string): boolean => matchesAnyGlob(path, LINT_POINTER_GLOBS)
20
+
21
+ type PathFinding = LintFinding & { path: string }
22
+
23
+ const WAY_THROUGH = `Write the lint rule and name it with \`${LINT_MARKER}<rule id>\`, or write \`${LINT_MARKER}${LINT_NONE} <reason>\` where it stays prose.`
24
+
25
+ const HINTS = {
26
+ [LINT_POINTER_STATE.missing]: '',
27
+ [LINT_POINTER_STATE.noId]: ` (\`${LINT_MARKER}\` names no rule id)`,
28
+ [LINT_POINTER_STATE.noReason]: ` (\`${LINT_MARKER}${LINT_NONE}\` needs a reason)`,
29
+ [LINT_POINTER_STATE.pointed]: '',
30
+ } as const
31
+
32
+ const lineOf = ({ path, line, shape, excerpt, state }: PathFinding): string =>
33
+ `${path}:${line} ${shape}: "${excerpt}"${HINTS[state]}`
34
+
35
+ const detailOf = (findings: readonly PathFinding[]): string =>
36
+ findings.length === 0
37
+ ? 'every lint-shaped rule block points at its lint rule'
38
+ : [...findings.map(lineOf), WAY_THROUGH].join('\n')
39
+
40
+ const statusOf = (findings: readonly PathFinding[], level: LintPointersLevel): CheckResult['status'] => {
41
+ if (findings.length === 0) return CHECK_STATUS.pass
42
+ return level === LINT_POINTERS_LEVEL.fail ? CHECK_STATUS.fail : CHECK_STATUS.warn
43
+ }
44
+
45
+ /**
46
+ * The new or changed blocks of the changed rule files that carry a rule a linter could check (an import path,
47
+ * a literal comparison, a comment form, a banned call) and name no lint rule. Kit copies and `exclude` are not
48
+ * read; `previousOf` answers a path with the file as `HEAD` holds it. Null when no such file changed or the level is off.
49
+ */
50
+ export const lintPointerCheck = (
51
+ root: string,
52
+ paths: readonly string[],
53
+ settings: LintPointersConfig,
54
+ kitCopies: readonly string[],
55
+ previousOf: (path: string) => string | null,
56
+ ): CheckResult | null => {
57
+ const files = paths.filter(
58
+ (path) =>
59
+ isLintPointerPath(path) &&
60
+ !kitCopies.includes(path) &&
61
+ !matchesAnyGlob(path, settings.exclude) &&
62
+ existsSync(join(root, path)),
63
+ )
64
+ if (settings.level === LINT_POINTERS_LEVEL.off || files.length === 0) return null
65
+ const started = Date.now()
66
+ const findings = files.flatMap((path) =>
67
+ lintFindingsOf(changedBlocksOf(readFileSync(join(root, path), 'utf8'), previousOf(path))).map((found) => ({
68
+ ...found,
69
+ path,
70
+ })),
71
+ )
72
+ return {
73
+ name: LINT_POINTERS_NAME,
74
+ command: `${files.length} changed rule ${files.length === 1 ? 'file' : 'files'}, new or changed blocks`,
75
+ status: statusOf(findings, settings.level),
76
+ detail: detailOf(findings),
77
+ durationMs: Date.now() - started,
78
+ }
79
+ }
@@ -0,0 +1,196 @@
1
+ /** The `Lint pointers` result name; the kit texts that name the check are held against it. */
2
+ export const LINT_POINTERS_NAME = 'Lint pointers'
3
+
4
+ /** The inline-code prefix of a pointer: `` `lint:<rule id>` `` names the linter rule that checks the block's rule. */
5
+ export const LINT_MARKER = 'lint:'
6
+
7
+ /** `` `lint:none` `` plus a reason: the rule stays prose on purpose. */
8
+ export const LINT_NONE = 'none'
9
+
10
+ /** The kinds of rule a linter can check. */
11
+ export const LINT_SHAPE = {
12
+ importPath: 'import path',
13
+ literalComparison: 'literal comparison',
14
+ commentForm: 'comment form',
15
+ bannedCall: 'banned call',
16
+ } as const
17
+
18
+ export type LintShape = (typeof LINT_SHAPE)[keyof typeof LINT_SHAPE]
19
+
20
+ /** Whether and how a block points at its lint rule. */
21
+ export const LINT_POINTER_STATE = {
22
+ pointed: 'pointed',
23
+ missing: 'missing',
24
+ noId: 'noId',
25
+ noReason: 'noReason',
26
+ } as const
27
+
28
+ export type LintPointerState = (typeof LINT_POINTER_STATE)[keyof typeof LINT_POINTER_STATE]
29
+
30
+ export type Block = { line: number; text: string }
31
+
32
+ export type LintFinding = { line: number; shape: LintShape; state: LintPointerState; excerpt: string }
33
+
34
+ const EXCERPT_LENGTH = 100
35
+
36
+ const GAP = 40
37
+ const BEFORE_SIGNAL = new RegExp(
38
+ `\\b(?:never|no|not|don't|avoid|instead of|forbidden)\\b([\\p{L}\\p{N}\\s,'’/*()-]{0,${GAP}})\`([^\`\\n]+)\``,
39
+ 'giu',
40
+ )
41
+ const AFTER_SIGNAL = /`([^`\n]+)`[^`\n]{0,25}\b(?:forbidden|banned|refused|never|not allowed|stay out)\b/gi
42
+ const POINTER_SPAN = new RegExp(`\`${LINT_MARKER}([^\`\\n]*)\``, 'g')
43
+ const EXAMPLE_MARK = '❌'
44
+ const IMPORT_WORD = /\b(?:imports?|imported|importing|require)\b/i
45
+ const SCOPED_MODULE = /^(?:@[\w.-]+\/[\w./-]+|~\/[\w./-]+|\.{1,2}\/[\w./-]+)$/
46
+ const PLAIN_MODULE = /^[\w.-]+\/[\w./-]+$/
47
+ const COMPARISON = /(?:===?|!==?)\s*['"`][^'"`]+['"`]/
48
+ const COMMENT_SPAN = /^(?:\/\/|\/\*|<!--|#(?:\s|$)|--(?:\s|$))/
49
+ const CALL_SPAN = /^(?:[\w$]+(?:\.[\w$]+)*|\.[\w$]+)\(/
50
+ const INLINE_CODE = /`([^`\n]+)`/g
51
+ const FENCE = /^\s*```/
52
+ const HEADING = /^#{1,6}\s/
53
+ const LIST_ITEM = /^\s*(?:[-*+]|\d+[.)])\s/
54
+ const TABLE_ROW = /^\s*\|/
55
+ const WORD = /[\p{L}\p{N}]{2,}/u
56
+
57
+ const spansOf = (text: string): string[] => [...text.matchAll(INLINE_CODE)].map(([, span]) => span.trim())
58
+
59
+ const frontmatterEnd = (lines: readonly string[]): number => {
60
+ if (lines[0]?.trim() !== '---') return 0
61
+ const close = lines.findIndex((line, index) => index > 0 && line.trim() === '---')
62
+ return close === -1 ? 0 : close + 1
63
+ }
64
+
65
+ const isSingleLine = (line: string): boolean => HEADING.test(line) || TABLE_ROW.test(line)
66
+
67
+ const startsBlock = (line: string): boolean => isSingleLine(line) || LIST_ITEM.test(line)
68
+
69
+ /**
70
+ * The blocks of a Markdown text: a paragraph, a list item with its continuation lines, a table row or a
71
+ * heading, outside code. A code block belongs to the block before it; the frontmatter falls away. `line` is 1-based.
72
+ */
73
+ export const blocksOf = (markdown: string): Block[] => {
74
+ const lines = markdown.split('\n')
75
+ const blocks: { line: number; lines: string[] }[] = []
76
+ let continuing = false
77
+ let inFence = false
78
+ const open = (index: number, line: string) => {
79
+ blocks.push({ line: index + 1, lines: [line] })
80
+ continuing = !isSingleLine(line)
81
+ }
82
+ for (let index = frontmatterEnd(lines); index < lines.length; index++) {
83
+ const line = lines[index]
84
+ const last = blocks.at(-1)
85
+ if (inFence) {
86
+ last?.lines.push(line)
87
+ inFence = !FENCE.test(line)
88
+ } else if (FENCE.test(line)) {
89
+ if (last) last.lines.push(line)
90
+ else open(index, line)
91
+ inFence = true
92
+ } else if (line.trim() === '') continuing = false
93
+ else if (startsBlock(line) || !continuing) open(index, line)
94
+ else last?.lines.push(line)
95
+ }
96
+ return blocks.map(({ line, lines: blockLines }) => ({ line, text: blockLines.join('\n') }))
97
+ }
98
+
99
+ const splitByFence = (text: string): { prose: string[]; code: string[] } => {
100
+ const split = { prose: [] as string[], code: [] as string[] }
101
+ let inFence = false
102
+ for (const line of text.split('\n')) {
103
+ if (FENCE.test(line)) inFence = !inFence
104
+ else (inFence ? split.code : split.prose).push(line)
105
+ }
106
+ return split
107
+ }
108
+
109
+ const proseLinesOf = (text: string): string[] => splitByFence(text).prose
110
+
111
+ const codeLinesOf = (text: string): string[] => splitByFence(text).code
112
+
113
+ /** An inline-code span a rule forbids, the words between the signal and the span, and whether the signal followed it. */
114
+ type ForbiddenSpan = { span: string; gap: string; trailing: boolean }
115
+
116
+ /**
117
+ * The spans a block marks as forbidden: right after a signal word (`never`, `no`, `not`, `avoid` …, at most
118
+ * {@link GAP} characters of plain words in between), before `forbidden` or `banned`, or on a line with a `❌`.
119
+ * A span far from every signal belongs to a sentence that merely mentions it.
120
+ */
121
+ const forbiddenSpansOf = (text: string): ForbiddenSpan[] => {
122
+ const prose = proseLinesOf(text)
123
+ const marked = prose.filter((line) => line.includes(EXAMPLE_MARK)).join('\n')
124
+ const proseText = prose.join('\n')
125
+ return [
126
+ ...[...proseText.matchAll(BEFORE_SIGNAL)].map(([, gap, span]) => ({ span: span.trim(), gap, trailing: false })),
127
+ ...[...proseText.matchAll(AFTER_SIGNAL)].map(([, span]) => ({ span: span.trim(), gap: '', trailing: true })),
128
+ ...spansOf(marked).map((span) => ({ span, gap: '', trailing: false })),
129
+ ]
130
+ }
131
+
132
+ const isModule = ({ span, gap, trailing }: ForbiddenSpan, text: string): boolean =>
133
+ (SCOPED_MODULE.test(span) && (trailing || IMPORT_WORD.test(text))) ||
134
+ (PLAIN_MODULE.test(span) && IMPORT_WORD.test(gap))
135
+
136
+ const markedCodeComparison = (text: string): boolean =>
137
+ codeLinesOf(text).some((line) => line.includes(EXAMPLE_MARK) && COMPARISON.test(line))
138
+
139
+ const hasShape = {
140
+ [LINT_SHAPE.importPath]: (text: string, forbidden: readonly ForbiddenSpan[]) =>
141
+ forbidden.some((entry) => isModule(entry, text)),
142
+ [LINT_SHAPE.literalComparison]: (text: string, forbidden: readonly ForbiddenSpan[]) =>
143
+ forbidden.some(({ span }) => COMPARISON.test(span)) || markedCodeComparison(text),
144
+ [LINT_SHAPE.commentForm]: (_text: string, forbidden: readonly ForbiddenSpan[]) =>
145
+ forbidden.some(({ span }) => COMMENT_SPAN.test(span)),
146
+ [LINT_SHAPE.bannedCall]: (_text: string, forbidden: readonly ForbiddenSpan[]) =>
147
+ forbidden.some(({ span }) => CALL_SPAN.test(span)),
148
+ } as const
149
+
150
+ /**
151
+ * The kinds of lint-checkable rule a block carries: an inline-code span of that kind that a signal word
152
+ * forbids, or, for a literal comparison, a code line marked `❌`.
153
+ */
154
+ export const lintShapesOf = (text: string): LintShape[] => {
155
+ const forbidden = forbiddenSpansOf(text)
156
+ return Object.values(LINT_SHAPE).filter((shape) => hasShape[shape](text, forbidden))
157
+ }
158
+
159
+ const isNoneMarker = (id: string): boolean => id === LINT_NONE || id.startsWith(`${LINT_NONE} `)
160
+
161
+ const pointerStateOfMatch = (match: RegExpMatchArray, text: string): LintPointerState => {
162
+ const id = match[1].trim()
163
+ if (id === '') return LINT_POINTER_STATE.noId
164
+ if (!isNoneMarker(id)) return LINT_POINTER_STATE.pointed
165
+ const reason = `${id.slice(LINT_NONE.length)} ${text.slice((match.index ?? 0) + match[0].length)}`
166
+ return WORD.test(reason) ? LINT_POINTER_STATE.pointed : LINT_POINTER_STATE.noReason
167
+ }
168
+
169
+ /** Whether a block names its lint rule: `` `lint:<id>` ``, or `` `lint:none` `` with a reason in the block. */
170
+ export const lintPointerStateOf = (text: string): LintPointerState => {
171
+ const marked = [...text.matchAll(POINTER_SPAN)]
172
+ if (marked.length === 0) return LINT_POINTER_STATE.missing
173
+ const states = marked.map((match) => pointerStateOfMatch(match, text))
174
+ return states.includes(LINT_POINTER_STATE.pointed) ? LINT_POINTER_STATE.pointed : states[0]
175
+ }
176
+
177
+ const excerptOf = (text: string): string => {
178
+ const first = text.split('\n')[0].trim()
179
+ return first.length > EXCERPT_LENGTH ? `${first.slice(0, EXCERPT_LENGTH - 1)}…` : first
180
+ }
181
+
182
+ /** The blocks that carry a lint-checkable rule and do not point at its lint rule. */
183
+ export const lintFindingsOf = (blocks: readonly Block[]): LintFinding[] =>
184
+ blocks.flatMap(({ line, text }) => {
185
+ const [shape] = lintShapesOf(text)
186
+ const state = lintPointerStateOf(text)
187
+ return shape && state !== LINT_POINTER_STATE.pointed ? [{ line, shape, state, excerpt: excerptOf(text) }] : []
188
+ })
189
+
190
+ const normalized = (text: string): string => text.replace(/\s+/g, ' ').trim()
191
+
192
+ /** The blocks of `current` whose text `previous` (the file as `HEAD` holds it; null for a new file) lacks. */
193
+ export const changedBlocksOf = (current: string, previous: string | null): Block[] => {
194
+ const known = new Set(previous === null ? [] : blocksOf(previous).map(({ text }) => normalized(text)))
195
+ return blocksOf(current).filter(({ text }) => !known.has(normalized(text)))
196
+ }
@@ -1,36 +1,47 @@
1
1
  import { existsSync, readFileSync } from 'node:fs'
2
2
  import { dirname, join, posix } from 'node:path'
3
3
  import { boardPlansOf, isInsideRepository } from '../../kit/hooks/hooks/shared/board-lanes'
4
- import { planCopiesIn } from '../../kit/hooks/hooks/shared/boards'
4
+ import { boardsIn, planCopiesIn } from '../../kit/hooks/hooks/shared/boards'
5
+ import { readTextOrNull } from '../../kit/hooks/hooks/shared/files'
5
6
  import { codesOfPlans } from '../../kit/hooks/hooks/shared/plan-codes'
6
7
  import {
7
8
  boardRowsWithoutPhase,
8
9
  boardRowsWithProse,
10
+ currentPhase,
9
11
  hasLanesTable,
10
12
  type LongPhaseTitle,
13
+ laneProgress,
11
14
  longPhaseTitles,
12
- type MergedPhaseWithOpenBoxes,
13
15
  mergedPhasesWithOpenBoxes,
14
16
  newPhasesWithoutReviewGate,
17
+ openBoxesOfPhase,
15
18
  PHASE_CELL_LIMIT,
16
19
  PHASE_TITLE_LIMIT,
20
+ type PhaseWithOpenBoxes,
17
21
  type PhaseWithoutGate,
18
22
  type PhaseWithoutRow,
19
23
  PLAN_VOCABULARY,
20
24
  type PlanLanguage,
25
+ type PlanPhase,
21
26
  PROSE_REASON,
27
+ parsePlanPhases,
28
+ parseSprintLanes,
22
29
  phaseIdsOfPlan,
23
30
  phasesWithoutDependencyRow,
24
31
  type RowWithoutPhase,
25
32
  type RowWithProse,
33
+ resolvePlanPath,
26
34
  SHORT_NAME_LIMIT,
27
35
  SHORT_NAME_PROBLEM,
28
36
  type ShortNameFinding,
37
+ type SprintLane,
29
38
  shortNameFindings,
30
39
  } from '../plan-format'
31
40
  import { boardRowsWithBareCodes, type RowWithBareCode } from '../plan-format/bare-codes'
41
+ import { mergePlanPhases } from '../plans/phase-source'
32
42
  import { changedPlansOf } from './deferral-arrows'
33
43
  import { CHECK_STATUS, type CheckResult } from './result'
44
+ import { OPEN_BOXES_LEVEL, type OpenBoxesLevel } from './schema'
34
45
 
35
46
  /** The content of a file at a ref; null where the ref lacks the file. */
36
47
  export type ContentAt = (path: string) => string | null
@@ -72,6 +83,22 @@ type Report<T> = {
72
83
  clean: string
73
84
  }
74
85
 
86
+ /** The lane a checkout is for and where its boards live, as the check reads them from the checkout. */
87
+ export type LaneBoxesInput = {
88
+ root: string
89
+ lane: string
90
+ planDirectory: string
91
+ board: { pattern: string; file: string }
92
+ language: PlanLanguage
93
+ /** A plan as the base has it (the pull request's parent in the watcher); null where it lacks the file. */
94
+ baseOf: ContentAt
95
+ }
96
+
97
+ type BuiltPhase = { planPath: string; phaseId: string; partial: boolean }
98
+
99
+ /** The phase a lane builds with the plan it is in and its open boxes; `open` is null for a phase without any. */
100
+ export type LaneBuild = { path: string; open: PhaseWithOpenBoxes | null }
101
+
75
102
  const changedCount = (count: number, noun: string): string => `${count} changed ${count === 1 ? noun : `${noun}s`}`
76
103
 
77
104
  const findingsResult = <T>(
@@ -132,7 +159,7 @@ const bareCodeReport: Report<RowWithBareCode> = {
132
159
  clean: 'no changed waiting row names a plan code bare',
133
160
  }
134
161
 
135
- const mergedReport: Report<MergedPhaseWithOpenBoxes> = {
162
+ const mergedReport: Report<PhaseWithOpenBoxes> = {
136
163
  name: 'Merged phases with open boxes',
137
164
  noun: 'plan',
138
165
  rule: 'a merged phase ticks or moves on its open boxes',
@@ -205,7 +232,7 @@ const bareCodesOfBoard = (roots: readonly string[], file: Changed, language: Pla
205
232
  codesOfPlans(planTextsOf(roots, file, language), language),
206
233
  ).map(withPath(file.path))
207
234
 
208
- const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<MergedPhaseWithOpenBoxes>[] =>
235
+ const mergedOf = ({ path, current, base }: Changed, language: PlanLanguage): Found<PhaseWithOpenBoxes>[] =>
209
236
  base ? mergedPhasesWithOpenBoxes(current, base, language).map(withPath(path)) : []
210
237
 
211
238
  const unlistedOf = ({ path, current }: Changed, language: PlanLanguage): Found<PhaseWithoutRow>[] | null =>
@@ -227,6 +254,91 @@ const changedContents = ({ root, previousOf, baseOf, language }: PlanCheckInput,
227
254
  return { path, current, previous, previousLines, base: baseOf(path), board: hasLanesTable(current, language) }
228
255
  })
229
256
 
257
+ const openBoxesText = (lane: string, partialWord: string, path: string, found: PhaseWithOpenBoxes): string =>
258
+ `${path}:${found.line} Phase ${found.id} ${found.title} is built by lane ${lane} but has open boxes at ${found.openLines.join(', ')}; tick them, end them with → P<n> or mark the row ${partialWord}`
259
+
260
+ /** The open lane `input.lane` of the newest board that lists it, with the directory its plan links are relative to. */
261
+ const laneOnBoards = (input: LaneBoxesInput): { lane: SprintLane; directory: string } | null => {
262
+ const boards = boardsIn(input.root, input).sort((left, right) => right.directory.localeCompare(left.directory))
263
+ for (const { directory, text } of boards) {
264
+ const lane = parseSprintLanes(text, input.language).find(({ name, closed }) => name === input.lane && !closed)
265
+ if (lane) return { lane, directory }
266
+ }
267
+ return null
268
+ }
269
+
270
+ /** The lane has work of its own in the checkout, so a phase it ticked further than the base is marked touched, as the herd reads it. */
271
+ const CHECKOUT_WORK = { ahead: 1, dirty: 0 }
272
+
273
+ /**
274
+ * The phases of the plan at `path` in the checkout; with the plan on the base too, those the checkout ticked further
275
+ * are marked touched, which is how the herd tells the phase a lane builds from the first open phase of its row.
276
+ */
277
+ const phasesInCheckout = (input: LaneBoxesInput, path: string): PlanPhase[] => {
278
+ const text = readTextOrNull(join(input.root, path))
279
+ const base = input.baseOf(path)
280
+ const own = text === null ? [] : parsePlanPhases(text, input.language)
281
+ return base === null ? own : mergePlanPhases(parsePlanPhases(base, input.language), own, [], CHECKOUT_WORK)
282
+ }
283
+
284
+ /** The phase the lane's newest board row names, the plan it is in and whether the row marks it as built in parts. */
285
+ const phaseBuiltBy = (input: LaneBoxesInput): BuiltPhase | null => {
286
+ const found = laneOnBoards(input)
287
+ if (!found) return null
288
+ const pathOf = (href: string): string => resolvePlanPath(found.directory, href)
289
+ const phasesByHref = Object.fromEntries(
290
+ found.lane.steps.flatMap(({ planHref }) =>
291
+ planHref === null ? [] : [[planHref, phasesInCheckout(input, pathOf(planHref))] as const],
292
+ ),
293
+ )
294
+ const progress = laneProgress(found.lane, phasesByHref)
295
+ const phase = currentPhase(progress)
296
+ const step = progress.current === null ? null : progress.steps[progress.current.stepIndex].step
297
+ if (!phase || !step?.planHref) return null
298
+ // with the base known, a phase this checkout did not tick further waits in the row; it is not what the lane builds
299
+ if (phase.work === undefined && input.baseOf(pathOf(step.planHref)) !== null) return null
300
+ const partial = step.label.includes(PLAN_VOCABULARY[input.language].partialPhase)
301
+ return { planPath: pathOf(step.planHref), phaseId: phase.id, partial }
302
+ }
303
+
304
+ /**
305
+ * The phase the lane's board row names and its open boxes, read from the checkout whether or not a commit changes the
306
+ * plan. Null for a lane the checkout's board does not list, for a row marked with the `partialPhase` word (`(Teil)`),
307
+ * which builds part of the phase, and for a phase ticked to the end, which is no longer the lane's current one.
308
+ */
309
+ export const laneBuild = (input: LaneBoxesInput): LaneBuild | null => {
310
+ const built = phaseBuiltBy(input)
311
+ const plan = built && !built.partial ? readTextOrNull(join(input.root, built.planPath)) : null
312
+ return built && plan !== null
313
+ ? { path: built.planPath, open: openBoxesOfPhase(plan, input.language, built.phaseId) }
314
+ : null
315
+ }
316
+
317
+ /** What the level makes of a finding: `fail` fails, `warn` warns; `off` never reaches here. */
318
+ const statusOf = (level: OpenBoxesLevel): typeof CHECK_STATUS.fail | typeof CHECK_STATUS.warn =>
319
+ level === OPEN_BOXES_LEVEL.warn ? CHECK_STATUS.warn : CHECK_STATUS.fail
320
+
321
+ /**
322
+ * The open boxes of the phase the lane builds (`laneBuild`) as a check: `level` decides whether they fail, warn or are
323
+ * not looked at (`off` returns null).
324
+ */
325
+ export const laneOpenBoxesCheck = (input: LaneBoxesInput & { level: OpenBoxesLevel }): CheckResult | null => {
326
+ if (input.level === OPEN_BOXES_LEVEL.off) return null
327
+ const started = Date.now()
328
+ const build = laneBuild(input)
329
+ if (!build) return null
330
+ const { open } = build
331
+ return {
332
+ name: 'Open boxes of the lane phase',
333
+ command: `lane ${input.lane}, the phase its board row names has no open box`,
334
+ status: open === null ? CHECK_STATUS.pass : statusOf(input.level),
335
+ detail: open
336
+ ? openBoxesText(input.lane, PLAN_VOCABULARY[input.language].partialPhase, build.path, open)
337
+ : 'the phase the lane builds has no open box left',
338
+ durationMs: Date.now() - started,
339
+ }
340
+ }
341
+
230
342
  /**
231
343
  * The structure of the changed plans and boards:
232
344
  * - a phase the change added (against `HEAD`) with tasks and without a review gate fails;
@@ -143,12 +143,18 @@ export const formatPlanHeader = (
143
143
  '',
144
144
  ]
145
145
 
146
+ const ADVISORY_LINE = /^(hint|note):/
147
+
148
+ /** A passing check shows only its advisory lines (`hint:`, `note:`); any other status shows its whole detail. */
149
+ const shownDetail = ({ status, detail }: CheckResult): string[] => {
150
+ const lines = detail ? detail.split('\n') : []
151
+ return status === CHECK_STATUS.pass ? lines.filter((line) => ADVISORY_LINE.test(line)) : lines
152
+ }
153
+
146
154
  export const formatResults = (results: readonly CheckResult[]): string[] =>
147
155
  results.flatMap((result) => [
148
156
  `${STATUS_ICON[result.status]} ${result.name} (${result.command}, ${result.durationMs}ms)`,
149
- ...(result.status !== CHECK_STATUS.pass && result.detail
150
- ? result.detail.split('\n').map((line) => ` ${line}`)
151
- : []),
157
+ ...shownDetail(result).map((line) => ` ${line}`),
152
158
  ])
153
159
 
154
160
  const warnings = (count: number): string => (count > 0 ? `${count} ${count === 1 ? 'warning' : 'warnings'}, ` : '')