@joshuafolkken/kit 1.1094.0 → 1.1128.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 (149) hide show
  1. package/.claude/skills/workflow-commands/SKILL.md +1 -1
  2. package/.claude/skills/workflow-commands/background-commands.md +36 -85
  3. package/.claude/skills/workflow-commands/backlogrun-progress.md +29 -32
  4. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -2
  5. package/.claude/skills/workflow-commands/fullrun.md +5 -4
  6. package/.claude/skills/workflow-commands/halfrun.md +5 -4
  7. package/.claude/skills/workflow-commands/latest-gate.md +7 -15
  8. package/.claude/skills/workflow-commands/pre-gate-cut.md +4 -8
  9. package/.claude/skills/workflow-commands/split-assessment.md +4 -18
  10. package/.github/actions/setup-node/action.yml +33 -0
  11. package/.github/actions/setup-pnpm/action.yml +4 -4
  12. package/.github/workflows/auto-tag.yml +2 -3
  13. package/.github/workflows/ci.yml +7 -8
  14. package/.github/workflows/dependabot-auto-merge.yml +1 -0
  15. package/.github/workflows/pr-classification.yml +1 -3
  16. package/.github/workflows/production.yml +1 -0
  17. package/.github/workflows/publish.yml +10 -9
  18. package/.github/workflows/sonar-qube.yml +17 -13
  19. package/.pnpmfile.mjs +29 -0
  20. package/AGENTS.md +4 -11
  21. package/CLAUDE.md +24 -17
  22. package/GEMINI.md +4 -13
  23. package/README.md +8 -3
  24. package/SECURITY.md +1 -0
  25. package/cspell/index.yaml +4 -0
  26. package/dist/CLAUDE.basic.md +6 -6
  27. package/dist/CLAUDE.md +24 -17
  28. package/dist/CLAUDE.static.md +6 -6
  29. package/dist/config-merge/index.js +33 -9
  30. package/dist/hooks/batch-guard.js +4 -4
  31. package/dist/hooks/{chunk-NKOG3XAV.js → chunk-4JZDP5X5.js} +2 -2
  32. package/dist/hooks/{chunk-3C4YCCEF.js → chunk-5SD2YM7Z.js} +4 -4
  33. package/dist/hooks/{chunk-OIZUHPJI.js → chunk-77J7IEQM.js} +1 -1
  34. package/dist/hooks/{chunk-KHWHKJOM.js → chunk-7HVB7SZG.js} +3 -3
  35. package/dist/hooks/{chunk-UD24EAIL.js → chunk-AIOIOMPX.js} +1875 -104
  36. package/dist/hooks/{chunk-M2XDCUKD.js → chunk-M7QKEKWO.js} +88 -1783
  37. package/dist/hooks/{chunk-E54KGFEQ.js → chunk-MKVGBQPA.js} +1 -1
  38. package/dist/hooks/{chunk-7ZPFAVR7.js → chunk-NXYMNPUQ.js} +3 -3
  39. package/dist/hooks/{chunk-O4LL4HTI.js → chunk-QVLT52OV.js} +2 -2
  40. package/dist/hooks/{chunk-IL27YTPH.js → chunk-XYUIQOW3.js} +2 -2
  41. package/dist/hooks/codex-hook-adapter.js +12 -47
  42. package/dist/hooks/duplicate-read-guard.js +5 -5
  43. package/dist/hooks/format-edited.js +2 -2
  44. package/dist/hooks/inputs.json +180 -179
  45. package/dist/hooks/investigation-guard.js +5 -5
  46. package/dist/hooks/pretool-guard.js +9 -9
  47. package/dist/hooks/stop-guard.js +17 -12
  48. package/dist/josh.js +79 -30
  49. package/dist/version/index.js +36 -20
  50. package/eslint/rules/unicorn.js +3 -2
  51. package/package.json +18 -2
  52. package/pnpm-workspace.yaml +5 -7
  53. package/prompts/collaboration-workflow/glossary.md +17 -0
  54. package/prompts/collaboration-workflow/issue-template.md +1 -1
  55. package/prompts/collaboration-workflow/operating-rules.md +1 -1
  56. package/prompts/collaboration-workflow/principles.md +9 -0
  57. package/prompts/collaboration-workflow.md +1 -0
  58. package/scripts/backlog/backlog-arrival.ts +30 -10
  59. package/scripts/backlog/backlog-budget-cli.ts +15 -16
  60. package/scripts/backlog/backlog-offer-cli.ts +6 -14
  61. package/scripts/backlog/backlog-plan.ts +29 -18
  62. package/scripts/backlog/backlog-ready.ts +20 -7
  63. package/scripts/config-merge/parse-jsonc.ts +33 -3
  64. package/scripts/config-merge/patch-json-key.ts +13 -6
  65. package/scripts/cost-runtime/cost-cli.ts +3 -7
  66. package/scripts/doctor/doctor-consumer.ts +3 -5
  67. package/scripts/doctor/doctor-io.ts +9 -13
  68. package/scripts/document/backlogrun-parent-read-set.ts +12 -1
  69. package/scripts/document/document-byte-budget.ts +1 -0
  70. package/scripts/document/entry-read-budget.ts +13 -7
  71. package/scripts/document/entry-read-set.ts +32 -8
  72. package/scripts/document/lane-child-read-set.ts +7 -0
  73. package/scripts/document/read-set-point-of-use.ts +8 -3
  74. package/scripts/document/read-set-trim.ts +3 -1
  75. package/scripts/epic/epic-audit.ts +3 -18
  76. package/scripts/gh/git-gh-issue-list.ts +12 -11
  77. package/scripts/gh/git-gh-issue-write.ts +4 -1
  78. package/scripts/gh/git-gh-pr-auto-merge.ts +4 -1
  79. package/scripts/git/git-command.ts +6 -1
  80. package/scripts/git/git-spawn-sync.ts +65 -0
  81. package/scripts/git/git-spawn.ts +5 -4
  82. package/scripts/git/stash/stash-pop-args.ts +1 -6
  83. package/scripts/init/init-file-action.ts +54 -0
  84. package/scripts/init/init-logic-migrate.ts +35 -17
  85. package/scripts/init/init-logic-secretlint.ts +1 -1
  86. package/scripts/init/init-logic.ts +6 -22
  87. package/scripts/init/init.ts +11 -68
  88. package/scripts/init/kit-written-paths.ts +2 -0
  89. package/scripts/init/project-profile.ts +3 -6
  90. package/scripts/init/start-steps.ts +2 -1
  91. package/scripts/issue/defect-rate-cli.ts +61 -20
  92. package/scripts/issue/issue-backlinks.ts +7 -14
  93. package/scripts/issue/issue-cite.ts +12 -6
  94. package/scripts/issue/issue-file.ts +5 -12
  95. package/scripts/issue/markdown-section.ts +21 -6
  96. package/scripts/issue/measure-rerun-cli.ts +73 -14
  97. package/scripts/josh/josh-commands-project.ts +1 -1
  98. package/scripts/josh/josh-commands-workflow.ts +1 -1
  99. package/scripts/josh/run-github-prerequisite.ts +3 -6
  100. package/scripts/lane/lane-await.ts +43 -13
  101. package/scripts/lane/lane-close.ts +4 -1
  102. package/scripts/lane/lane-launch-cli.ts +10 -16
  103. package/scripts/lane/openai-review-broker.ts +4 -1
  104. package/scripts/lib/cli-flags.ts +31 -2
  105. package/scripts/lib/error-message.ts +12 -1
  106. package/scripts/lines/line-targets.ts +0 -11
  107. package/scripts/lines/lines-command.ts +5 -3
  108. package/scripts/propagate/propagate-git.ts +3 -8
  109. package/scripts/refactor/refactor-scan.ts +2 -1
  110. package/scripts/registry-migration/migrate-logic.ts +28 -19
  111. package/scripts/registry-migration/migrate.ts +6 -5
  112. package/scripts/release/github-release-cli.ts +14 -3
  113. package/scripts/release/release-publish.ts +36 -2
  114. package/scripts/release/release-worktree.ts +97 -2
  115. package/scripts/review/review-checkout.ts +3 -2
  116. package/scripts/review/review-record-cli.ts +9 -16
  117. package/scripts/rules/early-heartbeat.ts +4 -3
  118. package/scripts/rules/lane-park.ts +9 -0
  119. package/scripts/rules/stop-rules.ts +7 -4
  120. package/scripts/run/run-carry-args.ts +8 -12
  121. package/scripts/run/run-cut-args.ts +1 -6
  122. package/scripts/run/run-ending-cli.ts +1 -5
  123. package/scripts/run/run-entry-cli.ts +40 -9
  124. package/scripts/run/run-entry-stop.ts +104 -0
  125. package/scripts/run/run-event-stream-emit.ts +7 -2
  126. package/scripts/run/run-hold.ts +4 -1
  127. package/scripts/run/run-liveness-cli.ts +1 -5
  128. package/scripts/run/run-merge-cli.ts +2 -10
  129. package/scripts/run/run-parent-cut-hook.ts +4 -1
  130. package/scripts/run/run-prep-cli.ts +4 -1
  131. package/scripts/run/run-progress-args.ts +20 -4
  132. package/scripts/run/run-progress-cli.ts +31 -29
  133. package/scripts/run/run-progress-clock.ts +4 -1
  134. package/scripts/run/run-ship-cli.ts +8 -16
  135. package/scripts/run/run-ship-probe.ts +4 -1
  136. package/scripts/run/run-status-cli.ts +2 -15
  137. package/scripts/run/run-tidy-lanes.ts +4 -1
  138. package/scripts/run/run-wake-cli.ts +2 -10
  139. package/scripts/run/run-watcher-guard.ts +9 -5
  140. package/scripts/safe-chain/pack-hook.ts +92 -0
  141. package/scripts/sync/sync-ai-files.ts +4 -0
  142. package/scripts/test/test-declared-changed.ts +13 -16
  143. package/scripts/test/test-declared.ts +3 -7
  144. package/scripts/version/version-commands.ts +11 -1
  145. package/templates/CLAUDE.basic.md +6 -6
  146. package/templates/workflows/ci.yml +16 -82
  147. package/templates/workflows/dependabot-auto-merge.yml +1 -0
  148. package/templates/workflows/github-release.yml +1 -3
  149. package/tsconfig.sonar.json +4 -22
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env tsx
2
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
5
  import { resolve_local_bin } from '#scripts/build/local-bin'
@@ -7,6 +7,7 @@ import { doctor_io } from '#scripts/doctor/doctor-io'
7
7
  import { error_text } from '#scripts/lib/error-message'
8
8
  import { package_version_schema, with_package_manager_schema } from '#scripts/lib/schemas'
9
9
  import { auto_merge_setting } from '#scripts/repo/auto-merge-setting'
10
+ import { pack_hook } from '#scripts/safe-chain/pack-hook'
10
11
  import { project_config } from '#scripts/safe-chain/project-config'
11
12
  import { security_updates } from '#scripts/security/security-updates'
12
13
  import { did_refuse_self_run } from '#scripts/self-sync-guard/self-sync-refusal'
@@ -14,10 +15,11 @@ import { sync } from '#scripts/sync/sync'
14
15
  import { package_manager_version } from '#scripts/version/package-manager-version'
15
16
  import { execaSync } from 'execa'
16
17
  import { z } from 'zod'
17
- import { init_actions, PRETTIER_CONFIG_JS, type FileAction } from './init-actions'
18
+ import { init_actions, PRETTIER_CONFIG_JS } from './init-actions'
18
19
  import { init_ai_copy } from './init-ai-copy'
19
20
  import { init_basic } from './init-basic'
20
21
  import { init_bootstrap } from './init-bootstrap'
22
+ import { init_file_action } from './init-file-action'
21
23
  import { init_install } from './init-install'
22
24
  import { init_logic } from './init-logic'
23
25
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
@@ -27,69 +29,10 @@ import { project_profile, type ProjectProfile, type ProjectShape } from './proje
27
29
  const PACKAGE_JSON = 'package.json'
28
30
  const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
29
31
  const LEFTHOOK_BIN = 'lefthook'
30
- const SAMPLE_INDENT_WIDTH = 4
31
32
  const ARGUMENT_START_INDEX = 2
32
- const SAMPLE_INDENT = ' '.repeat(SAMPLE_INDENT_WIDTH)
33
33
  const INSTALL_HINT = '→ run `pnpm install` to install what package.json lists'
34
34
  const INIT_USAGE = 'josh init [--profile basic|full] [--no-install]'
35
35
 
36
- function write_new_file(action: FileAction, destination_path: string): void {
37
- mkdirSync(path.dirname(destination_path), { recursive: true })
38
- writeFileSync(destination_path, action.create())
39
- console.info(` ✔ created ${action.dest}`)
40
- }
41
-
42
- function show_sample(action: FileAction): void {
43
- console.info(` ⚠ exists ${action.dest} — add manually:`)
44
- console.info('')
45
- console.info(action.create().replaceAll(/^/gmu, () => SAMPLE_INDENT))
46
- }
47
-
48
- // A file that already holds the sample needs nothing added, so only one that differs gets it shown.
49
- function report_unchanged(action: FileAction, existing: string): void {
50
- const is_sample_missing = existing !== action.create()
51
-
52
- if (is_sample_missing && action.should_show_sample_when_unchanged === true) show_sample(action)
53
- else console.info(` ✔ unchanged ${action.dest}`)
54
- }
55
-
56
- function merge_existing_file(
57
- merge_function: (existing: string) => string,
58
- destination_path: string,
59
- action: FileAction,
60
- ): void {
61
- const existing = readFileSync(destination_path, 'utf8')
62
- const merged = merge_function(existing)
63
-
64
- if (merged === existing) {
65
- report_unchanged(action, existing)
66
-
67
- return
68
- }
69
-
70
- writeFileSync(destination_path, merged)
71
- console.info(` ✔ updated ${action.dest}`)
72
- }
73
-
74
- function execute_file_action(action: FileAction): void {
75
- const destination_path = path.join(PROJECT_ROOT, action.dest)
76
- const is_existing = existsSync(destination_path)
77
-
78
- if (!is_existing) {
79
- write_new_file(action, destination_path)
80
-
81
- return
82
- }
83
-
84
- if (action.merge === undefined) {
85
- show_sample(action)
86
-
87
- return
88
- }
89
-
90
- merge_existing_file(action.merge, destination_path, action)
91
- }
92
-
93
36
  const engine_pin_schema = z.looseObject({ name: z.string(), version: z.string() })
94
37
 
95
38
  const published_development_engines_schema = z.looseObject({
@@ -124,10 +67,10 @@ function get_kit_self_dependency(): Record<string, string> {
124
67
  }
125
68
 
126
69
  // Each config `init` generates runs a CLI or imports types the consumer has to resolve itself:
127
- // `prettier.config.js` → prettier, `cspell.config.yaml` → cspell, `playwright.config.ts` →
128
- // @types/node (with @playwright/test among the peers), `lefthook.yml` → lefthook. Without them the
129
- // first `josh gate` after `pnpm install` fails (joshuafolkken/kit#2710).
130
- const TOOL_DEVELOPMENT_DEPENDENCIES = ['prettier', 'cspell', '@types/node']
70
+ // `cspell.config.yaml` → cspell, `playwright.config.ts` → @types/node, `lefthook.yml` → lefthook.
71
+ // Prettier, its preset plugins and @playwright/test come in as peers. Without them the first
72
+ // `josh gate` after `pnpm install` fails (joshuafolkken/kit#2710).
73
+ const TOOL_DEVELOPMENT_DEPENDENCIES = ['cspell', '@types/node']
131
74
  const LEFTHOOK_DEVELOPMENT_DEPENDENCY = 'lefthook'
132
75
 
133
76
  function tool_dependency_names(has_git: boolean): ReadonlyArray<string> {
@@ -164,9 +107,8 @@ function apply_dependency_merges(content: string, has_git = true): string {
164
107
  ? suggested
165
108
  : Object.fromEntries(Object.entries(suggested).filter(([key]) => key !== 'prepare')),
166
109
  )
167
- const with_prettier = init_logic.merge_prettier_plugin_development_deps(merged)
168
110
  const with_toolchain = init_logic.merge_development_dependencies(
169
- with_prettier,
111
+ merged,
170
112
  get_toolchain_development_dependencies(has_git),
171
113
  )
172
114
  const with_secretlint = init_logic.merge_secretlint_development_deps(with_toolchain)
@@ -262,7 +204,7 @@ function run_config_file_actions(shape: ProjectShape): void {
262
204
  }
263
205
 
264
206
  for (const action of init_actions.build_file_actions(shape)) {
265
- execute_file_action(action)
207
+ init_file_action.execute_file_action(action, PROJECT_ROOT)
266
208
  }
267
209
  }
268
210
 
@@ -305,6 +247,7 @@ function initialize_project(shape: ProjectShape): void {
305
247
  merge_project_package_json(shape)
306
248
 
307
249
  run_ai_file_actions(shape)
250
+ pack_hook.sync_pack_hook(PROJECT_ROOT, PACKAGE_DIR)
308
251
  if (shape.profile === 'full') project_config.sync_project_config(PROJECT_ROOT)
309
252
  }
310
253
 
@@ -1,3 +1,4 @@
1
+ import { PACK_HOOK_FILE } from '#scripts/safe-chain/pack-hook'
1
2
  import { PROJECT_CONFIG_FILE } from '#scripts/safe-chain/project-config'
2
3
  import { managed_config_scope } from '#scripts/sync/managed-config-scope'
3
4
  import { BASIC_PRETTIER_CONFIG } from './init-actions'
@@ -13,6 +14,7 @@ const SETUP_ONLY_PATHS: ReadonlySet<string> = new Set([
13
14
  CLAUDE_MD_FILENAME,
14
15
  BASIC_PRETTIER_CONFIG,
15
16
  PROJECT_CONFIG_FILE,
17
+ PACK_HOOK_FILE,
16
18
  MANIFEST_FILE,
17
19
  LOCKFILE,
18
20
  ])
@@ -1,9 +1,9 @@
1
1
  import { existsSync, readdirSync, readFileSync, type Dirent } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { repo_origin } from '#scripts/discovery/repo-origin'
4
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
4
5
  import { json_value } from '#scripts/lib/json-value'
5
6
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
6
- import { execaSync } from 'execa'
7
7
 
8
8
  type ProjectProfile = 'basic' | 'full'
9
9
 
@@ -159,12 +159,9 @@ function scan_files(root: string): { has_web: boolean; has_typescript: boolean }
159
159
  }
160
160
 
161
161
  function has_github_remote(root: string): boolean {
162
- const result = execaSync('git', ['config', '--get', 'remote.origin.url'], {
163
- cwd: root,
164
- reject: false,
165
- })
162
+ const url = git_spawn_sync.origin_url(root)
166
163
 
167
- return result.exitCode === 0 && repo_origin.parse_origin_url(result.stdout) !== undefined
164
+ return url !== undefined && repo_origin.parse_origin_url(url) !== undefined
168
165
  }
169
166
 
170
167
  function inspect_project(root: string, requested?: ProjectProfile): ProjectShape {
@@ -1,4 +1,5 @@
1
1
  import path from 'node:path'
2
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
2
3
  import { error_text } from '#scripts/lib/error-message'
3
4
  import { repository_labels } from '#scripts/repo/repository-labels'
4
5
  import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
@@ -44,7 +45,7 @@ function read_git_state(root: string, shape: ProjectShape): GitState {
44
45
  }
45
46
  }
46
47
 
47
- const has_origin = succeeds('git', ['remote', 'get-url', 'origin'], root)
48
+ const has_origin = git_spawn_sync.origin_url(root) !== undefined
48
49
  const branch = read_output('git', ['symbolic-ref', '--short', 'HEAD'], root)
49
50
  const has_commits = succeeds('git', ['rev-parse', '--verify', '--quiet', 'HEAD'], root)
50
51
  const has_kit_committed = kit_setup_state.is_kit_committed(root)
@@ -1,16 +1,24 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
4
3
  import { git_gh_command } from '#scripts/gh/git-gh-command'
5
4
  import { git_gh_exec } from '#scripts/gh/git-gh-exec'
6
- import { parse_json_array_or_undefined } from '#scripts/git/parse-json-array'
5
+ import { parse_json } from '#scripts/git/parse-json-array'
7
6
  import { issue_label_schema } from '#scripts/git/schemas'
7
+ import { bounded_pool } from '#scripts/lib/bounded-pool'
8
+ import { cli_flags } from '#scripts/lib/cli-flags'
8
9
  import { z } from 'zod'
9
10
  import { defect_rate, type DefectRate, type RateIssue } from './defect-rate'
10
11
 
11
12
  const SUCCESS_EXIT_CODE = 0
12
13
  const FAILURE_EXIT_CODE = 1
13
14
  const PER_PAGE = '100'
15
+ const FIRST_PAGE = 1
16
+ const SECOND_PAGE = 2
17
+ // The search API serves at most this many results, so a window past it holds no further page.
18
+ const SEARCH_RESULT_CEILING = 1000
19
+ // The search API allows 30 requests a minute; both searches together stay at the ~10 requests the
20
+ // sequential walk made, so this bounds only how many are in flight at once.
21
+ const PAGE_CONCURRENCY = 4
14
22
  const USAGE = 'Usage: pnpm josh defect:rate [--days <n>]'
15
23
  const UNKNOWN_REPO_MESSAGE =
16
24
  'Could not read this repository from `git remote`, so no issue can be searched — check `gh auth status` and that this is a checkout with an `origin` remote.'
@@ -33,7 +41,7 @@ const search_item_schema = z.object({
33
41
  labels: z.array(issue_label_schema),
34
42
  })
35
43
 
36
- // One page of `search/issues`. `--paginate --slurp` hands back every page as one array.
44
+ // One page of `search/issues`.
37
45
  const search_page_schema = z.object({
38
46
  total_count: z.number(),
39
47
  incomplete_results: z.boolean(),
@@ -48,22 +56,60 @@ interface SearchResult {
48
56
  }
49
57
 
50
58
  function read_days(argv: ReadonlyArray<string>): number | undefined {
51
- try {
52
- const { values } = parseArgs({ args: [...argv], options: OPTIONS, strict: true })
53
- const raw = values.days ?? String(defect_rate.DEFAULT_WINDOW_DAYS)
59
+ const values = cli_flags.values_of(argv, OPTIONS)
54
60
 
55
- if (!DAYS_PATTERN.test(raw)) return undefined
61
+ if (values === undefined) return undefined
56
62
 
57
- const days = Number(raw)
63
+ const raw = values.days ?? String(defect_rate.DEFAULT_WINDOW_DAYS)
58
64
 
59
- return days <= MAX_WINDOW_DAYS ? days : undefined
60
- } catch {
61
- return undefined
62
- }
65
+ if (!DAYS_PATTERN.test(raw)) return undefined
66
+
67
+ const days = Number(raw)
68
+
69
+ return days <= MAX_WINDOW_DAYS ? days : undefined
70
+ }
71
+
72
+ function search_path(query: string, page: number = FIRST_PAGE): string {
73
+ const parameters = new URLSearchParams({ q: query, per_page: PER_PAGE })
74
+
75
+ if (page !== FIRST_PAGE) parameters.set('page', String(page))
76
+
77
+ return `search/issues?${parameters.toString()}`
63
78
  }
64
79
 
65
- function search_path(query: string): string {
66
- return `search/issues?${new URLSearchParams({ q: query, per_page: PER_PAGE }).toString()}`
80
+ // How many pages the search holds, read off the first page's own count. Bounded by the API's
81
+ // ceiling, past which GitHub serves no page at all — so this asks for exactly the pages `gh api
82
+ // --paginate` used to walk to.
83
+ function page_count(first: SearchPage): number {
84
+ return Math.ceil(Math.min(first.total_count, SEARCH_RESULT_CEILING) / Number(PER_PAGE))
85
+ }
86
+
87
+ async function read_page(query: string, page: number): Promise<SearchPage | undefined> {
88
+ const raw = await git_gh_exec.exec_gh_api({ path: search_path(query, page) })
89
+
90
+ return parse_json.parse_json_object_safe(raw, search_page_schema)
91
+ }
92
+
93
+ // Every page of one search. joshuafolkken/kit#3103: `--paginate` follows each page's `next` link, so
94
+ // a two-week window of ~5 pages cost ~1.5 s a page, one after another, on every `backlog:plan`. The
95
+ // first page names the total, so the rest are fetched together; any page that does not read fails
96
+ // the whole search, as a failed `--paginate` did.
97
+ async function read_pages(query: string): Promise<ReadonlyArray<SearchPage> | undefined> {
98
+ const first = await read_page(query, FIRST_PAGE)
99
+
100
+ if (first === undefined) return undefined
101
+
102
+ const rest_numbers = Array.from(
103
+ { length: Math.max(page_count(first) - FIRST_PAGE, 0) },
104
+ (_value, index) => index + SECOND_PAGE,
105
+ )
106
+ const rest = await bounded_pool.bounded_map(
107
+ rest_numbers,
108
+ PAGE_CONCURRENCY,
109
+ async (page) => await read_page(query, page),
110
+ )
111
+
112
+ return rest.every((page) => page !== undefined) ? [first, ...rest] : undefined
67
113
  }
68
114
 
69
115
  function to_result(pages: ReadonlyArray<SearchPage>): SearchResult {
@@ -83,12 +129,7 @@ function to_result(pages: ReadonlyArray<SearchPage>): SearchResult {
83
129
 
84
130
  async function search(query: string): Promise<SearchResult | undefined> {
85
131
  try {
86
- const raw = await git_gh_exec.exec_gh_api({
87
- path: search_path(query),
88
- should_paginate: true,
89
- should_slurp: true,
90
- })
91
- const pages = parse_json_array_or_undefined(raw, search_page_schema)
132
+ const pages = await read_pages(query)
92
133
 
93
134
  return pages === undefined ? undefined : to_result(pages)
94
135
  } catch {
@@ -4,12 +4,13 @@
4
4
  // undetected. This classifies an origin issue's filed backlinks into one of four fixed words
5
5
  // (joshuafolkken/kit#2123). The headings are the single source's, pinned by the document test.
6
6
 
7
+ import { markdown_section } from './markdown-section'
8
+
7
9
  const ORIGIN_HEADING = '## Origin'
8
10
  const UPSTREAM_ISSUES_HEADING = '## Upstream issues'
9
11
  const UPSTREAM_CANDIDATE_HEADING = '## Upstream candidate'
10
12
  const CHECKBOX_PATTERN = /^\s*-\s*\[[ xX]\]/u
11
13
  const WRONG_UPSTREAM_HEADING_PATTERN = /^##\s+Upstream\b/iu
12
- const SUBHEADING_PATTERN = /^##\s/u
13
14
  const BARE_REFERENCE_PATTERN = /^#\d+$/u
14
15
  const DIGITS_PATTERN = /^\d+$/u
15
16
  const REFERENCE_MARK = '#'
@@ -27,31 +28,23 @@ interface UpstreamEntry {
27
28
  body: string
28
29
  }
29
30
 
30
- function trimmed_lines(body: string): ReadonlyArray<string> {
31
- return body.split('\n').map((line) => line.trimEnd())
32
- }
33
-
31
+ // A heading inside a code fence is example text, so neither lookup counts one (joshuafolkken/kit#3066).
34
32
  function has_heading(body: string, heading: string): boolean {
35
- return trimmed_lines(body).some((line) => line.trim() === heading)
33
+ return markdown_section.has_line(body, heading)
36
34
  }
37
35
 
38
36
  // The lines under `heading`, up to the next `##` heading; `undefined` when the heading is absent.
39
37
  function section_lines(body: string, heading: string): ReadonlyArray<string> | undefined {
40
- const lines = trimmed_lines(body)
41
- const start = lines.findIndex((line) => line.trim() === heading)
42
- if (start === -1) return undefined
43
-
44
- const rest = lines.slice(start + 1)
45
- const end = rest.findIndex((line) => SUBHEADING_PATTERN.test(line.trim()))
38
+ if (!has_heading(body, heading)) return undefined
46
39
 
47
- return end === -1 ? rest : rest.slice(0, end)
40
+ return markdown_section.section_lines(body, heading)
48
41
  }
49
42
 
50
43
  // A heading that looks like the upstream one but is not exactly it (`## Upstream`, `## Upstreams`,
51
44
  // wrong case) — the grep the template promises would miss it, so it is a wrong-heading rather than a
52
45
  // missing one. `## Upstream candidate` is a legitimate separate heading and is excluded.
53
46
  function has_wrong_upstream_heading(body: string): boolean {
54
- return trimmed_lines(body).some((line) => {
47
+ return markdown_section.unfenced_lines(body).some((line) => {
55
48
  const heading = line.trim()
56
49
  if (heading === UPSTREAM_ISSUES_HEADING || heading === UPSTREAM_CANDIDATE_HEADING) return false
57
50
 
@@ -46,14 +46,19 @@ function citation_line(slug: string, number: string, summary: string): string {
46
46
  // and the plain `#N` only when the repository could not be read at all.
47
47
  type IssueCiter = (number: string) => string
48
48
 
49
+ // One issue, named from what the caller holds — the single decision every listing's citation goes
50
+ // through (joshuafolkken/kit#3099), so a renderer holding one title never re-derives the fallback.
51
+ function reference(slug: string | undefined, number: string, title: string | undefined): string {
52
+ if (slug === undefined) return `#${number}`
53
+
54
+ return title === undefined
55
+ ? `[#${number}](${issue_url(slug, number)})`
56
+ : citation_line(slug, number, title)
57
+ }
58
+
49
59
  function citer(slug: string | undefined, titles: ReadonlyMap<string, string>): IssueCiter {
50
60
  return function cite(number: string): string {
51
- if (slug === undefined) return `#${number}`
52
- const title = titles.get(number)
53
-
54
- return title === undefined
55
- ? `[#${number}](${issue_url(slug, number)})`
56
- : citation_line(slug, number, title)
61
+ return reference(slug, number, titles.get(number))
57
62
  }
58
63
  }
59
64
 
@@ -111,6 +116,7 @@ const issue_cite = {
111
116
  missing_line,
112
117
  no_repo_line,
113
118
  parse_target,
119
+ reference,
114
120
  unreadable_line,
115
121
  }
116
122
 
@@ -1,6 +1,7 @@
1
- import { parseArgs } from 'node:util'
1
+ import type { parseArgs } from 'node:util'
2
2
  import { epic_issue } from '#scripts/epic/epic-issue'
3
3
  import { github_issue_url } from '#scripts/gh/github-issue-url'
4
+ import { cli_flags } from '#scripts/lib/cli-flags'
4
5
  import { issue_backlinks } from './issue-backlinks'
5
6
  import { issue_classification } from './issue-classification'
6
7
  import { DEPTH_LABEL_ORDER, FILING_ROUTE_LABELS, has_label_name } from './issue-labels'
@@ -103,19 +104,11 @@ function arguments_of(values: ParsedValues, title: string | undefined): FileArgu
103
104
  return { title, body_file, ...labelled, labels: values.label ?? [], repo: values.repo }
104
105
  }
105
106
 
106
- // An unknown flag makes `parseArgs` throw; caught so the answer is the usage line, not a stack trace.
107
+ // An unknown flag is `undefined` rather than a throw, so the answer is the usage line, not a stack trace.
107
108
  function parse(argv: ReadonlyArray<string>): FileArguments | undefined {
108
- try {
109
- const { values, positionals } = parseArgs({
110
- args: [...argv],
111
- options: OPTIONS,
112
- allowPositionals: true,
113
- })
109
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
114
110
 
115
- return arguments_of(values, positionals[0])
116
- } catch {
117
- return undefined
118
- }
111
+ return parsed === undefined ? undefined : arguments_of(parsed.values, parsed.positionals[0])
119
112
  }
120
113
 
121
114
  // A classification label is the body's to declare, so an extra `--label` naming one the body does not
@@ -52,13 +52,16 @@ function has_line(body: string, text: string): boolean {
52
52
  return has_unfenced_line(body, text)
53
53
  }
54
54
 
55
- // The lines between `heading` and the next `## ` heading, trimmed of the heading line itself; an empty
56
- // array when the heading is absent. A heading matched inside a sentence is not the section heading, so
57
- // only a line that is exactly the heading opens the section.
58
- function section_lines(body: string, heading: string): ReadonlyArray<string> {
55
+ // The lines between the first unfenced line `is_target` accepts and the next unfenced `## ` heading,
56
+ // trimmed of the heading line itself; an empty array when no line is accepted. A `## ` line inside a
57
+ // code fence is example text, so it neither opens nor closes a section (joshuafolkken/kit#3066).
58
+ function section_lines_matching(
59
+ body: string,
60
+ is_target: (line: string) => boolean,
61
+ ): ReadonlyArray<string> {
59
62
  const lines = body.split('\n')
60
63
  const visible = unfenced_lines(body)
61
- const start = visible.findIndex((line) => is_the_heading(line, heading))
64
+ const start = visible.findIndex((line) => is_target(line))
62
65
 
63
66
  if (start === -1) return []
64
67
 
@@ -68,6 +71,18 @@ function section_lines(body: string, heading: string): ReadonlyArray<string> {
68
71
  return end === -1 ? rest : rest.slice(0, end)
69
72
  }
70
73
 
71
- const markdown_section = { section_lines, has_line, has_unfenced_line }
74
+ // A heading matched inside a sentence is not the section heading, so only a line that is exactly the
75
+ // heading opens the section.
76
+ function section_lines(body: string, heading: string): ReadonlyArray<string> {
77
+ return section_lines_matching(body, (line) => is_the_heading(line, heading))
78
+ }
79
+
80
+ const markdown_section = {
81
+ section_lines,
82
+ section_lines_matching,
83
+ unfenced_lines,
84
+ has_line,
85
+ has_unfenced_line,
86
+ }
72
87
 
73
88
  export { markdown_section }
@@ -1,26 +1,52 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { execSync } from 'node:child_process'
3
- import { readFile } from 'node:fs/promises'
4
3
  import { fileURLToPath } from 'node:url'
4
+ import { git_gh_issue_read } from '#scripts/gh/git-gh-issue-read'
5
+ import { parse_json_object_safe } from '#scripts/git/parse-json-array'
5
6
  import { error_text } from '#scripts/lib/error-message'
6
7
  import { observation_ledger_home } from '#scripts/observations/observation-ledger-home'
8
+ import { z } from 'zod'
7
9
  import { baseline_measure, type Baseline } from './baseline-measure'
8
10
 
9
- // `josh measure:rerun <path>` — read a behavior-change Issue body after merge, re-run each baseline
11
+ // `josh measure:rerun <N>` — read a behavior-change Issue body after merge, re-run each baseline
10
12
  // command, and print the before/after pair (joshuafolkken/kit#2212). A value that did not move means
11
13
  // the premise the rule rested on is refuted, so a line is appended to the observation ledger — reusing
12
14
  // that append-only, same-key mechanism rather than a second one.
15
+ //
16
+ // **The baseline is shell, and the body is written by whoever filed the Issue** (joshuafolkken/kit#3064).
17
+ // A baseline needs pipes and loops (`git log … | wc -l`, `for d in $(ls …)`), so an allow-list of
18
+ // commands cannot make it safe; what can is who wrote it. The input is therefore the Issue number, not
19
+ // a file, so the author's `author_association` arrives with the body from the same REST read, and only
20
+ // a body written by someone with write access to the repository is run.
13
21
 
14
22
  const FAILURE_EXIT_CODE = 1
15
- const USAGE = 'Usage: josh measure:rerun <path-to-issue-body>'
23
+ const ARGV_OFFSET = 2
24
+ const ISSUE_NUMBER_PATTERN = /^[1-9]\d*$/u
25
+ const ISSUE_FIELDS = 'body,author_association'
26
+ const TRUSTED_ASSOCIATIONS: ReadonlySet<string> = new Set(['OWNER', 'MEMBER', 'COLLABORATOR'])
27
+ const TRUSTED_LIST = [...TRUSTED_ASSOCIATIONS].join(' / ')
28
+ const UNKNOWN_ASSOCIATION = 'unknown'
29
+ const USAGE = 'Usage: josh measure:rerun <issue-number>'
16
30
  const NO_BASELINE = 'no `command → value` baseline found in ## ベースライン — nothing to re-run'
17
31
  const DATE_END = 10
18
-
19
- // The command's stdout, trimmed to one value. A failing command yields its message so the pair still
20
- // prints rather than aborting the whole re-measurement.
32
+ // One baseline command's budget — the same minute `josh-harness.ts` gives one command it spawns. A
33
+ // baseline is a count or a digest that answers in seconds, so a command still running after a minute
34
+ // is stuck, and without a bound it would hold the whole re-measurement open with nothing printed.
35
+ const MEASURE_TIMEOUT_MS = 60_000
36
+ const issue_schema = z.object({
37
+ body: z.string().nullish(),
38
+ author_association: z.string().nullish(),
39
+ })
40
+
41
+ type IssueFields = z.infer<typeof issue_schema>
42
+ type BodyRead = { kind: 'trusted'; body: string } | { kind: 'refused'; reason: string }
43
+
44
+ // The command's stdout, trimmed to one value. A failing or timed-out command yields its message so the
45
+ // pair still prints and the remaining baselines still run, rather than aborting the whole
46
+ // re-measurement.
21
47
  function measure(command: string): string {
22
48
  try {
23
- return execSync(command, { encoding: 'utf8' }).trim()
49
+ return execSync(command, { encoding: 'utf8', timeout: MEASURE_TIMEOUT_MS }).trim()
24
50
  } catch (error) {
25
51
  return `(command failed: ${error_text.message_of(error)})`
26
52
  }
@@ -56,8 +82,35 @@ function today(now: Date): string {
56
82
  return now.toISOString().slice(0, DATE_END)
57
83
  }
58
84
 
59
- async function rerun(body_path: string, now: Date): Promise<number> {
60
- const baselines = baseline_measure.parse_baselines(await readFile(body_path, 'utf8'))
85
+ async function read_issue(issue_number: string): Promise<IssueFields | undefined> {
86
+ const json = await git_gh_issue_read.issue_view_json(issue_number, ISSUE_FIELDS)
87
+
88
+ return json === undefined ? undefined : parse_json_object_safe(json, issue_schema)
89
+ }
90
+
91
+ // The body, only when its author can write to the repository. A read that fails is refused as well:
92
+ // an unknown author is not a trusted one.
93
+ async function read_trusted_body(issue_number: string): Promise<BodyRead> {
94
+ const issue = await read_issue(issue_number)
95
+
96
+ if (issue === undefined) {
97
+ return { kind: 'refused', reason: `could not read issue #${issue_number}` }
98
+ }
99
+
100
+ const association = issue.author_association ?? UNKNOWN_ASSOCIATION
101
+
102
+ if (!TRUSTED_ASSOCIATIONS.has(association)) {
103
+ return {
104
+ kind: 'refused',
105
+ reason: `refusing to run the baseline of #${issue_number}: its author is ${association}, not ${TRUSTED_LIST}`,
106
+ }
107
+ }
108
+
109
+ return { kind: 'trusted', body: issue.body ?? '' }
110
+ }
111
+
112
+ async function rerun(body: string, now: Date): Promise<number> {
113
+ const baselines = baseline_measure.parse_baselines(body)
61
114
 
62
115
  if (baselines.length === 0) {
63
116
  console.error(NO_BASELINE)
@@ -76,14 +129,22 @@ async function rerun(body_path: string, now: Date): Promise<number> {
76
129
  return 0
77
130
  }
78
131
 
79
- async function run(body_path: string | undefined, now: Date): Promise<number> {
80
- if (body_path === undefined) {
132
+ async function run(issue_number: string | undefined, now: Date): Promise<number> {
133
+ if (issue_number === undefined || !ISSUE_NUMBER_PATTERN.test(issue_number)) {
81
134
  console.error(USAGE)
82
135
 
83
136
  return FAILURE_EXIT_CODE
84
137
  }
85
138
 
86
- return await rerun(body_path, now)
139
+ const read = await read_trusted_body(issue_number)
140
+
141
+ if (read.kind === 'refused') {
142
+ console.error(read.reason)
143
+
144
+ return FAILURE_EXIT_CODE
145
+ }
146
+
147
+ return await rerun(read.body, now)
87
148
  }
88
149
 
89
150
  async function main(argv: ReadonlyArray<string>): Promise<void> {
@@ -92,8 +153,6 @@ async function main(argv: ReadonlyArray<string>): Promise<void> {
92
153
 
93
154
  const measure_rerun_cli = { run }
94
155
 
95
- const ARGV_OFFSET = 2
96
-
97
156
  if (process.argv[1] === fileURLToPath(import.meta.url)) await main(process.argv.slice(ARGV_OFFSET))
98
157
 
99
158
  export { measure_rerun_cli }
@@ -40,7 +40,7 @@ const PROJECT_COMMANDS: Record<string, CommandEntry> = {
40
40
  },
41
41
  'registry:migrate': {
42
42
  script: 'scripts/registry-migration/migrate.ts',
43
- description: 'Migrate a kit-only project from GitHub Packages to public npm',
43
+ description: 'Migrate a project from GitHub Packages to public npm',
44
44
  category: 'Project',
45
45
  reference: ['', 'developer', ['files', 'network', 'processes']],
46
46
  },
@@ -89,7 +89,7 @@ const WORKFLOW_COMMANDS: Record<string, CommandEntry> = {
89
89
  script: 'scripts/issue/measure-rerun-cli.ts',
90
90
  description: 'Re-run a merged issue’s baseline command and print the before/after pair',
91
91
  category: 'Workflow',
92
- reference: ['<path>', 'automation', ['processes', 'files']],
92
+ reference: ['<issue-number>', 'automation', ['network', 'processes', 'files']],
93
93
  },
94
94
  // A script rather than an `sh -c` chain, because it has a precondition to enforce: run inside a
95
95
  // linked work tree it would hijack the default branch from every other one (joshuafolkken/kit#1535).
@@ -1,7 +1,7 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { repo_origin } from '#scripts/discovery/repo-origin'
4
- import { execaSync } from 'execa'
4
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
5
5
 
6
6
  const GITHUB_RUN_COMMANDS = new Set([
7
7
  'run:entry',
@@ -19,11 +19,8 @@ function explanation(root: string, command: string): string | undefined {
19
19
  return `${command} requires Git and a GitHub origin. Run git init and add a GitHub origin first.`
20
20
  }
21
21
 
22
- const result = execaSync('git', ['config', '--get', 'remote.origin.url'], {
23
- cwd: root,
24
- reject: false,
25
- })
26
- if (result.exitCode === 0 && repo_origin.parse_origin_url(result.stdout)) return undefined
22
+ const url = git_spawn_sync.origin_url(root)
23
+ if (url !== undefined && repo_origin.parse_origin_url(url)) return undefined
27
24
 
28
25
  return `${command} requires a GitHub origin. Add an origin pointing to GitHub first.`
29
26
  }