@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,6 @@
1
1
  import { spawnSync } from 'node:child_process'
2
2
  import { setTimeout as sleep } from 'node:timers/promises'
3
+ import { run_liveness } from '#scripts/run/run-liveness'
3
4
  import { lane_child_invocation } from './lane-child-invocation'
4
5
  import { lane_handoff } from './lane-handoff'
5
6
 
@@ -22,15 +23,20 @@ const MS_PER_SECOND = 1000
22
23
  const ISSUE_PATTERN = /^[1-9]\d*$/u
23
24
  const PROCESS_FOUND = 0
24
25
 
26
+ // `is_settled` is read once, when the wait starts: a child that ended between two `lane:await` calls
27
+ // never appears in the second one, and only its settled issue tells it apart from one not yet
28
+ // launched (joshuafolkken/kit#3133).
25
29
  interface AwaitState {
26
30
  appeared: boolean
27
31
  disappeared_at: number | undefined
28
32
  first_polled_at: number | undefined
33
+ is_settled?: boolean
29
34
  }
30
35
 
31
36
  // Injectable for testing -- the CLI never overrides these.
32
37
  interface AwaitOptions {
33
38
  is_running?: (issue: string) => boolean
39
+ is_settled?: (issue: string) => Promise<boolean>
34
40
  poll_ms?: number
35
41
  reconfirm_ms?: number
36
42
  never_appeared_timeout_ms?: number
@@ -43,6 +49,7 @@ interface CheckConfig {
43
49
  }
44
50
 
45
51
  interface RunConfig extends CheckConfig {
52
+ is_settled: (issue: string) => Promise<boolean>
46
53
  poll_ms: number
47
54
  }
48
55
 
@@ -55,23 +62,42 @@ function is_process_running_default(issue: string): boolean {
55
62
  return lane_handoff.is_ship_running(issue, process.cwd())
56
63
  }
57
64
 
58
- function make_initial_state(): AwaitState {
59
- return { appeared: false, disappeared_at: undefined, first_polled_at: undefined }
65
+ // The same positive evidence `run:liveness` answers `settled` on: the issue closed, or it was parked.
66
+ async function is_settled_default(issue: string): Promise<boolean> {
67
+ return (await run_liveness.read_child_settled(issue)) === true
60
68
  }
61
69
 
62
- function make_states(issues: ReadonlyArray<string>): Map<string, AwaitState> {
63
- return new Map(issues.map((issue) => [issue, make_initial_state()]))
70
+ function make_initial_state(is_settled: boolean): AwaitState {
71
+ return { appeared: false, disappeared_at: undefined, first_polled_at: undefined, is_settled }
64
72
  }
65
73
 
66
- function resolve_options(options: AwaitOptions): RunConfig {
74
+ async function make_states(
75
+ issues: ReadonlyArray<string>,
76
+ is_settled: (issue: string) => Promise<boolean>,
77
+ ): Promise<Map<string, AwaitState>> {
78
+ const settled = await Promise.all(issues.map(async (issue) => await is_settled(issue)))
79
+
80
+ return new Map(issues.map((issue, index) => [issue, make_initial_state(settled[index] === true)]))
81
+ }
82
+
83
+ function resolve_timing(
84
+ options: AwaitOptions,
85
+ ): Pick<RunConfig, 'never_appeared_timeout_ms' | 'poll_ms' | 'reconfirm_ms'> {
67
86
  return {
68
- is_running: options.is_running ?? is_process_running_default,
69
87
  poll_ms: options.poll_ms ?? DEFAULT_POLL_MS,
70
88
  reconfirm_ms: options.reconfirm_ms ?? RECONFIRM_MS,
71
89
  never_appeared_timeout_ms: options.never_appeared_timeout_ms ?? NEVER_APPEARED_TIMEOUT_MS,
72
90
  }
73
91
  }
74
92
 
93
+ function resolve_options(options: AwaitOptions): RunConfig {
94
+ return {
95
+ is_running: options.is_running ?? is_process_running_default,
96
+ is_settled: options.is_settled ?? is_settled_default,
97
+ ...resolve_timing(options),
98
+ }
99
+ }
100
+
75
101
  function update_liveness(state: AwaitState, is_live: boolean): void {
76
102
  if (!is_live) return
77
103
 
@@ -79,13 +105,16 @@ function update_liveness(state: AwaitState, is_live: boolean): void {
79
105
  state.disappeared_at = undefined
80
106
  }
81
107
 
82
- // Throws if the issue never appeared within the timeout window; otherwise records the first-seen time.
108
+ // Returns the issue when it had already settled before the wait started — it ended and will never
109
+ // appear. Otherwise throws if it never appeared within the timeout window, and records the first-seen time.
83
110
  function check_not_appeared(
84
111
  issue: string,
85
112
  state: AwaitState,
86
113
  now_ms: number,
87
114
  timeout_ms: number,
88
- ): void {
115
+ ): string | undefined {
116
+ if (state.is_settled === true) return issue
117
+
89
118
  state.first_polled_at ??= now_ms
90
119
  const elapsed_ms = now_ms - state.first_polled_at
91
120
 
@@ -94,10 +123,13 @@ function check_not_appeared(
94
123
  `lane child ${issue} never appeared within ${String(timeout_ms / MS_PER_SECOND)}s`,
95
124
  )
96
125
  }
126
+
127
+ return undefined
97
128
  }
98
129
 
99
130
  // Returns the issue that has confirmed-completed this tick, `undefined` if none yet.
100
- // The `appeared` guard prevents a process that never showed up from being declared done.
131
+ // The `appeared` guard prevents a process that never showed up from being declared done, unless its
132
+ // issue had already settled when the wait started.
101
133
  function check_issue(
102
134
  issue: string,
103
135
  state: AwaitState,
@@ -111,9 +143,7 @@ function check_issue(
111
143
  if (is_live) return undefined
112
144
 
113
145
  if (!state.appeared) {
114
- check_not_appeared(issue, state, now_ms, config.never_appeared_timeout_ms)
115
-
116
- return undefined
146
+ return check_not_appeared(issue, state, now_ms, config.never_appeared_timeout_ms)
117
147
  }
118
148
 
119
149
  if (state.disappeared_at === undefined) {
@@ -144,7 +174,7 @@ async function wait_for_any(
144
174
  options: AwaitOptions = {},
145
175
  ): Promise<string> {
146
176
  const config = resolve_options(options)
147
- const states = make_states(issues)
177
+ const states = await make_states(issues, config.is_settled)
148
178
 
149
179
  for (;;) {
150
180
  const completed = check_any(states, Date.now(), config)
@@ -1,6 +1,7 @@
1
1
  import { existsSync, rmSync } from 'node:fs'
2
2
  import { git_command } from '#scripts/git/git-command'
3
3
  import { git_worktree } from '#scripts/git/git-worktree'
4
+ import { error_text } from '#scripts/lib/error-message'
4
5
  import { lane_paths } from './lane-paths'
5
6
  import { lane_reap } from './lane-reap'
6
7
  import { lane_registry, type LaneInfo } from './lane-registry'
@@ -147,7 +148,9 @@ async function try_close(issue: string): Promise<boolean> {
147
148
  const outcome = await close_lane(issue)
148
149
 
149
150
  return outcome.kind !== 'incomplete'
150
- } catch {
151
+ } catch (error) {
152
+ error_text.trace_swallowed('lane_close.try_close', error)
153
+
151
154
  return false
152
155
  }
153
156
  }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import { fileURLToPath } from 'node:url'
3
- import { parseArgs } from 'node:util'
4
3
  import { josh_command } from '#scripts/josh/josh-run'
4
+ import { cli_flags } from '#scripts/lib/cli-flags'
5
5
  import { INSTALL_TIMEOUT_MS } from '#scripts/lib/timeouts'
6
6
  import { execa } from 'execa'
7
7
 
@@ -35,21 +35,15 @@ interface LaunchContext {
35
35
  }
36
36
 
37
37
  function read_context(argv: ReadonlyArray<string>): LaunchContext | undefined {
38
- try {
39
- const parsed = parseArgs({
40
- args: [...argv],
41
- options: OPTIONS,
42
- strict: true,
43
- allowPositionals: true,
44
- })
45
- const [issue, ...rest] = parsed.positionals
46
-
47
- if (issue === undefined || rest.length > 0 || !ISSUE_PATTERN.test(issue)) return undefined
48
-
49
- return { issue, stash: parsed.values.stash }
50
- } catch {
51
- return undefined
52
- }
38
+ const parsed = cli_flags.arguments_of(argv, OPTIONS)
39
+
40
+ if (parsed === undefined) return undefined
41
+
42
+ const [issue, ...rest] = parsed.positionals
43
+
44
+ if (issue === undefined || rest.length > 0 || !ISSUE_PATTERN.test(issue)) return undefined
45
+
46
+ return { issue, stash: parsed.values.stash }
53
47
  }
54
48
 
55
49
  // The pop brought in the `pnpm-lock.yaml` `josh latest` rewrote, so this one lane installs a second
@@ -6,6 +6,7 @@ import { agent_argv } from '#scripts/agent/agent-argv'
6
6
  import { agent_role_profile } from '#scripts/agent/agent-role-profile'
7
7
  import { git_common_directory } from '#scripts/git/git-common-directory'
8
8
  import { stamp_file } from '#scripts/josh/stamp-file'
9
+ import { error_text } from '#scripts/lib/error-message'
9
10
  import { detached_launch } from '#scripts/run/detached-launch'
10
11
  import { run_ship_review } from '#scripts/run/run-ship-review'
11
12
  import { z } from 'zod'
@@ -102,7 +103,9 @@ function write_response(target: string, body: z.infer<typeof response_schema>):
102
103
  async function launched(lane: LaneInfo, round: ReviewRound): Promise<boolean> {
103
104
  try {
104
105
  return await launch_review(lane, round)
105
- } catch {
106
+ } catch (error) {
107
+ error_text.trace_swallowed('openai_review_broker.launched', error)
108
+
106
109
  return false
107
110
  }
108
111
  }
@@ -1,4 +1,4 @@
1
- import { parseArgs, type ParseArgsConfig } from 'node:util'
1
+ import { parseArgs, type ParseArgsConfig, type ParseArgsOptionsConfig } from 'node:util'
2
2
 
3
3
  // Argument reading for the commands under `scripts/`.
4
4
  //
@@ -32,6 +32,35 @@ function parse_or_undefined<T extends ParseArgsConfig>(
32
32
  }
33
33
  }
34
34
 
35
- const cli_flags = { refuse_unknown_flags, parse_or_undefined }
35
+ // The two shapes every command reads `argv` in: flags alone, or flags beside positionals. Both are
36
+ // strict, so an unknown flag is `undefined` rather than a silently ignored typo; the commands had each
37
+ // carried a one-line wrapper spelling these out (joshuafolkken/kit#3072).
38
+ type FlagsOnly<T extends ParseArgsOptionsConfig> = ReturnType<
39
+ typeof parseArgs<{ args: Array<string>; options: T }>
40
+ >
41
+ type WithPositionals<T extends ParseArgsOptionsConfig> = ReturnType<
42
+ typeof parseArgs<{ args: Array<string>; options: T; allowPositionals: true }>
43
+ >
44
+
45
+ function values_of<T extends ParseArgsOptionsConfig>(
46
+ argv: ReadonlyArray<string>,
47
+ options: T,
48
+ ): FlagsOnly<T>['values'] | undefined {
49
+ return parse_or_undefined({ args: [...argv], options })?.values
50
+ }
51
+
52
+ function arguments_of<T extends ParseArgsOptionsConfig>(
53
+ argv: ReadonlyArray<string>,
54
+ options: T,
55
+ ): WithPositionals<T> | undefined {
56
+ return parse_or_undefined({ args: [...argv], options, allowPositionals: true })
57
+ }
58
+
59
+ // A string option's value, or `undefined` where the flag was absent or parsed as anything else.
60
+ function string_of(value: unknown): string | undefined {
61
+ return typeof value === 'string' ? value : undefined
62
+ }
63
+
64
+ const cli_flags = { refuse_unknown_flags, parse_or_undefined, values_of, arguments_of, string_of }
36
65
 
37
66
  export { cli_flags }
@@ -4,6 +4,17 @@ function message_of(error: unknown): string {
4
4
  return error instanceof Error ? error.message : String(error)
5
5
  }
6
6
 
7
- const error_text = { message_of }
7
+ const DEBUG_ENV_KEY = 'JOSH_DEBUG'
8
+
9
+ // A catch that folds a failure into a state verdict — "dirty", "not merged", "not written" — leaves no
10
+ // trace of why by default, so a wrong verdict cannot be followed back to its cause (joshuafolkken/kit#3067).
11
+ // `JOSH_DEBUG` set to any non-blank value writes the swallowed reason to stderr; unset, nothing changes.
12
+ function trace_swallowed(where: string, error: unknown): void {
13
+ if ((process.env[DEBUG_ENV_KEY] ?? '').trim() === '') return
14
+
15
+ process.stderr.write(`josh debug: ${where}: ${message_of(error)}\n`)
16
+ }
17
+
18
+ const error_text = { message_of, trace_swallowed }
8
19
 
9
20
  export { error_text }
@@ -40,10 +40,6 @@ const LS_FILES_FLAGS: ReadonlyArray<string> = [
40
40
  '-z',
41
41
  ]
42
42
  const NUL = '\0'
43
- // `--show-toplevel` prints the absolute repository (or worktree) root from anywhere inside it, so the
44
- // root-relative names `--full-name` returns resolve to the right absolute paths wherever the command
45
- // was invoked.
46
- const SHOW_TOPLEVEL: ReadonlyArray<string> = ['rev-parse', '--show-toplevel']
47
43
 
48
44
  function is_lint_target(relative_path: string): boolean {
49
45
  return LINT_EXTENSIONS.has(path.extname(relative_path))
@@ -53,12 +49,6 @@ function split_names(output: string): ReadonlyArray<string> {
53
49
  return output.split(NUL).filter((name) => name.length > 0)
54
50
  }
55
51
 
56
- // `git_spawn.read` is the one place ordinary git commands are spawned from, so this reuses it rather
57
- // than growing a second spawn site (joshuafolkken/kit#1640).
58
- async function repo_root(): Promise<string> {
59
- return await git_spawn.read([...SHOW_TOPLEVEL])
60
- }
61
-
62
52
  // The names resolve against the repository `root`, never the process cwd: `--full-name` prints them
63
53
  // relative to the root wherever the command runs, so resolving against cwd would double the prefix
64
54
  // and drop every file when the command is invoked from a subdirectory.
@@ -73,7 +63,6 @@ async function lint_target_files(root: string): Promise<ReadonlyArray<string>> {
73
63
  const line_targets = {
74
64
  is_lint_target,
75
65
  lint_target_files,
76
- repo_root,
77
66
  split_names,
78
67
  }
79
68
 
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env tsx
2
2
  import path from 'node:path'
3
3
  import { fileURLToPath } from 'node:url'
4
+ import { git_command } from '#scripts/git/git-command'
4
5
  import { line_budget, type FileBudget, type LineBudget } from './line-budget'
5
6
  import { line_targets } from './line-targets'
6
7
 
@@ -122,10 +123,11 @@ function render(budgets: ReadonlyArray<FileBudget>, project_root: string): strin
122
123
  return [header(budgets), ...rows_for(budgets, project_root)].join('\n')
123
124
  }
124
125
 
125
- // The scan resolves the repository root first — `budgets_for` and the row display both key off it — so
126
- // a run started from a subdirectory reports the same files as one started from the root.
126
+ // The scan resolves the repository root first — `budgets_for` and the row display both key off it, and
127
+ // `git ls-files --full-name` names files relative to it — so a run started from a subdirectory reports
128
+ // the same files as one started from the root.
127
129
  async function run_scan(): Promise<number> {
128
- const root = await line_targets.repo_root()
130
+ const root = await git_command.repository_root()
129
131
  const budgets = await scan_budgets(root)
130
132
 
131
133
  process.stdout.write(`${render(budgets, root)}\n`)
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { git_location_environment } from '#scripts/git/git-location-environment'
4
4
  import { ls_remote_branch_arguments } from '#scripts/git/git-ls-remote'
5
- import { execaSync } from 'execa'
5
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
6
6
 
7
7
  // The git probes propagation needs before it writes anything into a working tree.
8
8
  //
@@ -16,7 +16,6 @@ const GIT_TIMEOUT_MS = 10_000
16
16
  // `rev-parse` and tight for an ssh handshake, and a probe that times out answers "no" — which for
17
17
  // the two below would report a push that never reached origin as a push nobody attempted.
18
18
  const REMOTE_TIMEOUT_MS = 60_000
19
- const SUCCESS_EXIT_CODE = 0
20
19
  const DEFAULT_BRANCH_REF = 'refs/remotes/origin/HEAD'
21
20
  const FALLBACK_DEFAULT_BRANCH = 'main'
22
21
  const ORIGIN_PREFIX = 'refs/remotes/origin/'
@@ -54,15 +53,11 @@ function run_git(
54
53
  args: ReadonlyArray<string>,
55
54
  timeout_ms: number = GIT_TIMEOUT_MS,
56
55
  ): string | undefined {
57
- const result = execaSync('git', args, {
56
+ return git_spawn_sync.read(args, {
58
57
  cwd: repository_path,
59
- reject: false,
60
58
  timeout: timeout_ms,
61
- env: { LC_ALL: 'C', LANGUAGE: 'C', ...git_location_environment.location_free_environment() },
62
- extendEnv: true,
59
+ env: { ...git_spawn_sync.C_LOCALE, ...git_location_environment.location_free_environment() },
63
60
  })
64
-
65
- return result.exitCode === SUCCESS_EXIT_CODE ? result.stdout.trim() : undefined
66
61
  }
67
62
 
68
63
  // The repository's default branch, from the remote's own HEAD. Falls back to `main` when the ref is
@@ -1,3 +1,4 @@
1
+ import { git_command } from '#scripts/git/git-command'
1
2
  import { line_targets } from '#scripts/lines/line-targets'
2
3
  import { import_graph } from './import-graph'
3
4
  import { refactor_lint, type CategoryResult } from './refactor-lint'
@@ -97,7 +98,7 @@ async function scan(root: string): Promise<ScanResult> {
97
98
  }
98
99
 
99
100
  async function run_scan(): Promise<number> {
100
- const root = await line_targets.repo_root()
101
+ const root = await git_command.repository_root()
101
102
  const result = await scan(root)
102
103
 
103
104
  process.stdout.write(`${render_result(result)}\n`)
@@ -99,25 +99,33 @@ function is_github_tarball_line(line: string): boolean {
99
99
  return line.trimStart().startsWith('tarball: https://npm.pkg.github.com/')
100
100
  }
101
101
 
102
- function clean_kit_line(
102
+ // Integrities are keyed by `name@version`, so two scoped packages sharing a version never collide.
103
+ function package_id(key: string): string {
104
+ const name = fix_gh_packages_logic.package_path_from_key(key)
105
+
106
+ return `${name}@${fix_gh_packages_logic.package_version_from_key(key)}`
107
+ }
108
+
109
+ function clean_scoped_line(
103
110
  line: string,
104
- version: string,
111
+ id: string,
105
112
  integrities: ReadonlyMap<string, IntegrityPair>,
106
113
  ): string | undefined {
107
- if (!version) return line
114
+ if (!id) return line
108
115
  if (is_github_tarball_line(line)) return undefined
109
116
  const cleaned = remove_flow_tarball(line)
110
- const pair = integrities.get(version)
117
+ const pair = integrities.get(id)
111
118
  if (pair === undefined) return cleaned
112
119
 
113
120
  return cleaned.split(`integrity: ${pair[0]}`).join(`integrity: ${pair[1]}`)
114
121
  }
115
122
 
116
- function next_kit_version(line: string, version: string): string {
117
- if (!is_package_entry(line)) return version
118
- const prefix = " '@joshuafolkken/kit@"
123
+ function next_scoped_id(line: string, id: string): string {
124
+ if (!is_package_entry(line)) return id
125
+ const prefix = ` '${SCOPE}/`
126
+ if (!line.startsWith(prefix)) return ''
119
127
 
120
- return line.startsWith(prefix) ? (line.slice(prefix.length).split("'", 1)[0] ?? '') : ''
128
+ return package_id(line.slice(" '".length).split("'", 1)[0] ?? '')
121
129
  }
122
130
 
123
131
  function integrity_pair(
@@ -135,12 +143,12 @@ function lock_integrities(
135
143
  ): Map<string, IntegrityPair> {
136
144
  const packages = fix_gh_packages_logic.parse_lockfile_packages(lockfile)
137
145
  const pairs = new Map<string, IntegrityPair>()
138
- const kit_entries = Object.entries(packages).filter(([key]) => key.startsWith(`${SCOPE}/kit@`))
146
+ const scoped_entries = Object.entries(packages).filter(([key]) => key.startsWith(`${SCOPE}/`))
139
147
 
140
- for (const [key, entry] of kit_entries) {
141
- const version = fix_gh_packages_logic.package_version_from_key(key)
142
- const pair = integrity_pair(entry.resolution?.integrity, npm_integrities.get(version))
143
- if (pair !== undefined) pairs.set(version, pair)
148
+ for (const [key, entry] of scoped_entries) {
149
+ const id = package_id(key)
150
+ const pair = integrity_pair(entry.resolution?.integrity, npm_integrities.get(id))
151
+ if (pair !== undefined) pairs.set(id, pair)
144
152
  }
145
153
 
146
154
  return pairs
@@ -150,17 +158,17 @@ function push_if_defined(lines: Array<string>, cleaned: string | undefined): voi
150
158
  if (cleaned !== undefined) lines.push(cleaned)
151
159
  }
152
160
 
153
- function rewrite_kit_lockfile(
161
+ function rewrite_scoped_lockfile(
154
162
  lockfile: string,
155
163
  npm_integrities: ReadonlyMap<string, string>,
156
164
  ): string {
157
- let kit_version = ''
165
+ let id = ''
158
166
  const lines: Array<string> = []
159
167
  const integrities = lock_integrities(lockfile, npm_integrities)
160
168
 
161
169
  for (const line of lockfile.split('\n')) {
162
- kit_version = next_kit_version(line, kit_version)
163
- const cleaned = clean_kit_line(line, kit_version, integrities)
170
+ id = next_scoped_id(line, id)
171
+ const cleaned = clean_scoped_line(line, id, integrities)
164
172
 
165
173
  push_if_defined(lines, cleaned)
166
174
  }
@@ -206,7 +214,8 @@ function previous_registry(project_npmrc: string, user_npmrc: string): string {
206
214
 
207
215
  function plan(project_npmrc: string, user_npmrc: string, lockfile: string): MigrationPlan {
208
216
  const packages = scoped_packages(lockfile)
209
- const blocked = packages.filter((name) => name !== `${SCOPE}/kit`)
217
+ // Every scoped package may migrate; `migrate` blocks any whose locked version is not on public npm.
218
+ const blocked: Array<string> = []
210
219
  if (has_duplicate_mapping(project_npmrc, user_npmrc)) blocked.push('duplicate registry mappings')
211
220
  const unsupported = unsupported_registry(project_npmrc)
212
221
  if (unsupported !== undefined) blocked.push(unsupported)
@@ -220,7 +229,7 @@ const migrate_logic = {
220
229
  plan,
221
230
  github_tarballs,
222
231
  is_github_tarball,
223
- rewrite_kit_lockfile,
232
+ rewrite_scoped_lockfile,
224
233
  scoped_versions,
225
234
  }
226
235
 
@@ -115,8 +115,9 @@ async function verify_public_versions(
115
115
 
116
116
  for (const [index, [name, version]] of versions.entries()) {
117
117
  const integrity = probed[index]
118
- if (integrity === undefined) missing.push(`${name}@${version}`)
119
- else integrities.set(version, integrity)
118
+ const id = `${name}@${version}`
119
+ if (integrity === undefined) missing.push(id)
120
+ else integrities.set(id, integrity)
120
121
  }
121
122
 
122
123
  return { missing, integrities }
@@ -183,7 +184,7 @@ async function verify_resolution(
183
184
  const old_tarballs = migrate_logic.github_tarballs(lockfile)
184
185
  if (old_tarballs.length > 0) throw new Error(`GitHub tarballs remain: ${old_tarballs.join(', ')}`)
185
186
 
186
- if (migrate_logic.rewrite_kit_lockfile(lockfile, integrities) !== lockfile) {
187
+ if (migrate_logic.rewrite_scoped_lockfile(lockfile, integrities) !== lockfile) {
187
188
  throw new Error('lockfile integrity does not match public npm')
188
189
  }
189
190
  }
@@ -197,7 +198,7 @@ async function apply(
197
198
  writeFileSync(context.npmrc_path, context.plan.content)
198
199
  writeFileSync(
199
200
  context.lockfile_path,
200
- migrate_logic.rewrite_kit_lockfile(context.original_lockfile, integrities),
201
+ migrate_logic.rewrite_scoped_lockfile(context.original_lockfile, integrities),
201
202
  )
202
203
  await verify_resolution(context, dependencies, integrities)
203
204
  } catch (error) {
@@ -215,7 +216,7 @@ function is_already_migrated(
215
216
  ): boolean {
216
217
  return (
217
218
  context.plan.content === context.original_npmrc &&
218
- migrate_logic.rewrite_kit_lockfile(context.original_lockfile, integrities) ===
219
+ migrate_logic.rewrite_scoped_lockfile(context.original_lockfile, integrities) ===
219
220
  context.original_lockfile
220
221
  )
221
222
  }
@@ -1,13 +1,24 @@
1
- import { execFileSync } from 'node:child_process'
2
1
  import { fileURLToPath } from 'node:url'
2
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
3
3
  import { github_release } from './github-release'
4
4
  import { github_release_environment } from './github-release-environment'
5
5
 
6
+ // A release published without the tag list would pick its previous tag from nothing, so a failed
7
+ // `git tag` stops the run rather than reading as "no tags".
8
+ function local_tags(): Array<string> {
9
+ const output = git_spawn_sync.read(['tag', '--list'])
10
+ if (output === undefined) throw new Error('git tag --list failed')
11
+
12
+ return output.split('\n')
13
+ }
14
+
6
15
  async function main(): Promise<void> {
7
16
  const { token, tag, settings } = github_release_environment.read_release_input(process.env)
8
- const tags = execFileSync('git', ['tag', '--list'], { encoding: 'utf8' }).trim().split('\n')
9
17
 
10
- console.info(await github_release.publish(fetch, token, tag, { ...settings, tags }))
18
+ console.info(await github_release.publish(fetch, token, tag, { ...settings, tags: local_tags() }))
11
19
  }
12
20
 
13
21
  if (process.argv[1] === fileURLToPath(import.meta.url)) await main()
22
+
23
+ const github_release_cli = { local_tags }
24
+ export { github_release_cli }
@@ -2,6 +2,7 @@ import { git_gh_command } from '#scripts/gh/git-gh-command'
2
2
  import { git_pr_checks } from '#scripts/gh/git-pr-checks'
3
3
  import { git_command } from '#scripts/git/git-command'
4
4
  import { git_remote_branch } from '#scripts/git/git-remote-branch'
5
+ import { repository_lock } from '#scripts/git/repository-lock'
5
6
  import { write_version } from '#scripts/version/bump-version'
6
7
  import { version_targets } from '#scripts/version/version-targets'
7
8
  import type { ReleasePlan } from './release-plan'
@@ -20,6 +21,9 @@ const { PACKAGE_JSON } = version_targets
20
21
  const SUCCESS_EXIT_CODE = 0
21
22
  const FAILURE_EXIT_CODE = 1
22
23
  const IGNORE_FOR_RELEASE_LABEL = 'ignore-for-release'
24
+ const RELEASE_LOCK_PREFIX = 'josh-release-lock-'
25
+ // A release waits on CI for minutes, so a second run refuses rather than queueing behind it.
26
+ const RELEASE_LOCK_MAX_WAIT_MS = 0
23
27
 
24
28
  function branch_name_for(version: string): string {
25
29
  return `release/v${version}`
@@ -87,6 +91,14 @@ async function refuse_existing_branch(branch_name: string): Promise<void> {
87
91
  throw new Error(existing_branch_message(branch_name))
88
92
  }
89
93
 
94
+ // **The leftovers go before the guard asks** (joshuafolkken/kit#3058). A run cut off before its
95
+ // `finally`, or a `worktree add` that failed after creating its branch, leaves a local
96
+ // `release/v<version>` that the guard would otherwise refuse as a release already opened.
97
+ async function clear_the_way(branch_name: string): Promise<void> {
98
+ await release_worktree.clear_leftover()
99
+ await refuse_existing_branch(branch_name)
100
+ }
101
+
90
102
  // **No `checkout -b` and no branch guard here** (joshuafolkken/kit#2411). The work tree is already
91
103
  // sitting on the release branch — `release_worktree.create` cut it that way — and the guard ran
92
104
  // before that creation, because `worktree_add` makes the branch local and `branch_exists` would then
@@ -121,10 +133,10 @@ async function merge_and_tag(plan: ReleasePlan, branch_name: string): Promise<nu
121
133
  // branch guard runs before the tree exists, `process.chdir` points the version write and the git
122
134
  // commands at the tree, and the `finally` returns to the root and removes the tree — whichever way
123
135
  // the wait ended, so a failed tag watch cleans up rather than leaving debris behind.
124
- async function publish(plan: ReleasePlan): Promise<number> {
136
+ async function publish_locked(plan: ReleasePlan): Promise<number> {
125
137
  const branch_name = branch_name_for(plan.next_version)
126
138
 
127
- await refuse_existing_branch(branch_name)
139
+ await clear_the_way(branch_name)
128
140
 
129
141
  const directory = await release_worktree.create(branch_name)
130
142
  const previous_cwd = process.cwd()
@@ -140,6 +152,28 @@ async function publish(plan: ReleasePlan): Promise<number> {
140
152
  }
141
153
  }
142
154
 
155
+ function release_running_message(target: string): string {
156
+ return `Another \`pnpm josh release\` is running in this repository (lock \`${target}\`); wait for it to finish, then run it again.`
157
+ }
158
+
159
+ // **One release at a time per repository, held for the whole run** (joshuafolkken/kit#3058).
160
+ // `clear_leftover` removes an unpushed, commit-less release branch, which is also what a live run
161
+ // looks like while it installs dependencies — so it may only run once no other release is alive. The
162
+ // lock answers that: a run cut off before its `finally` leaves a record whose owner is gone, which
163
+ // `repository_lock` clears on the next claim, while a live owner makes this run refuse at once.
164
+ async function publish(plan: ReleasePlan): Promise<number> {
165
+ const target = repository_lock.lock_path(RELEASE_LOCK_PREFIX)
166
+ const exit_code = await repository_lock.with_lock(
167
+ async () => await publish_locked(plan),
168
+ target,
169
+ RELEASE_LOCK_MAX_WAIT_MS,
170
+ )
171
+
172
+ if (exit_code === undefined) throw new Error(release_running_message(target))
173
+
174
+ return exit_code
175
+ }
176
+
143
177
  const release_publish = {
144
178
  branch_name_for,
145
179
  commit_message,