@joshuafolkken/kit 1.1435.0 → 1.1453.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 (88) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-park.md +2 -0
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +9 -7
  3. package/.claude/skills/workflow-commands/backlogrun.md +2 -2
  4. package/.claude/skills/workflow-commands/fullrun-steps.md +1 -1
  5. package/.claude/skills/workflow-commands/halfrun.md +1 -1
  6. package/.claude/skills/workflow-commands/kickoff.md +12 -13
  7. package/.claude/skills/workflow-commands/observation-filing.md +1 -1
  8. package/.claude/skills/workflow-commands/prerequisite.md +6 -0
  9. package/.claude/skills/workflow-commands/retrospective.md +5 -5
  10. package/.claude/skills/workflow-commands/split-assessment.md +2 -2
  11. package/README.md +15 -17
  12. package/dist/hooks/batch-guard.js +6 -6
  13. package/dist/hooks/{chunk-3LE6VLSR.js → chunk-3C4XRYG6.js} +18 -5
  14. package/dist/hooks/{chunk-JXQBK323.js → chunk-5C7L6FRC.js} +131 -104
  15. package/dist/hooks/{chunk-EMB55BDF.js → chunk-D3NJXN5M.js} +2 -2
  16. package/dist/hooks/{chunk-7KMN5QB5.js → chunk-EF5GQ6WS.js} +3 -3
  17. package/dist/hooks/{chunk-2G5F2YCD.js → chunk-LBFYEOXJ.js} +3 -3
  18. package/dist/hooks/{chunk-XER6SSIB.js → chunk-MBJYQPEK.js} +16 -6
  19. package/dist/hooks/{chunk-QL6QTPWN.js → chunk-NO4AALP5.js} +3 -3
  20. package/dist/hooks/{chunk-4BHUPFKF.js → chunk-OLMO72ON.js} +1 -1
  21. package/dist/hooks/{chunk-MSYSUSY6.js → chunk-SHYUQOIS.js} +5 -5
  22. package/dist/hooks/{chunk-5SJSSCJG.js → chunk-UT7YC3N2.js} +1 -1
  23. package/dist/hooks/{chunk-MU4LJFIV.js → chunk-W5ZKGTSY.js} +3 -3
  24. package/dist/hooks/{chunk-YZXYOAZ3.js → chunk-Y3GKIKGW.js} +11 -5
  25. package/dist/hooks/{chunk-YW452ZAX.js → chunk-Z4XI7CZS.js} +2 -2
  26. package/dist/hooks/codex-hook-adapter.js +13 -13
  27. package/dist/hooks/duplicate-read-guard.js +7 -7
  28. package/dist/hooks/format-edited.js +3 -3
  29. package/dist/hooks/inputs.json +241 -240
  30. package/dist/hooks/investigation-guard.js +7 -7
  31. package/dist/hooks/pretool-guard.js +12 -12
  32. package/dist/hooks/session-lang.js +3 -3
  33. package/dist/hooks/stop-guard.js +6 -6
  34. package/dist/josh.js +33 -10
  35. package/package.json +1 -1
  36. package/prompts/collaboration-workflow/issue-template.md +10 -8
  37. package/prompts/review.md +8 -4
  38. package/scripts/auto-ok/auto-ok-judge-cli.ts +125 -0
  39. package/scripts/auto-ok/auto-ok-judge-read.ts +95 -0
  40. package/scripts/auto-ok/auto-ok-judge-write.ts +109 -0
  41. package/scripts/auto-ok/auto-ok-judge.ts +67 -0
  42. package/scripts/backlog/backlog-drive-cli.ts +14 -0
  43. package/scripts/backlog/backlog-drive.ts +22 -3
  44. package/scripts/document/document-byte-budget.ts +1 -1
  45. package/scripts/fresh-machine/fresh-machine-patterns.ts +60 -15
  46. package/scripts/gate/project-checks.ts +5 -2
  47. package/scripts/gh/git-gh-issue-rest.ts +1 -0
  48. package/scripts/gh/git-gh-issue.ts +9 -0
  49. package/scripts/init/init-actions.ts +3 -3
  50. package/scripts/init/init-ai-copy.ts +1 -1
  51. package/scripts/init/init-basic.ts +1 -1
  52. package/scripts/init/init-bootstrap.ts +6 -2
  53. package/scripts/init/init-dry-run-report.ts +150 -0
  54. package/scripts/init/init-dry-run.ts +189 -0
  55. package/scripts/init/init-install.ts +8 -1
  56. package/scripts/init/init.ts +57 -19
  57. package/scripts/init/kit-setup-state.ts +11 -17
  58. package/scripts/init/own-formatter.ts +21 -0
  59. package/scripts/init/project-profile.ts +25 -6
  60. package/scripts/init/start-dry-run.ts +70 -0
  61. package/scripts/init/start-plan.ts +8 -3
  62. package/scripts/init/start-setup-pr.ts +13 -24
  63. package/scripts/init/start-steps.ts +16 -1
  64. package/scripts/init/start.ts +38 -18
  65. package/scripts/init/working-tree.ts +116 -0
  66. package/scripts/issue/issue-auto-ok.ts +68 -99
  67. package/scripts/issue/issue-file-cli.ts +17 -17
  68. package/scripts/issue/issue-file.ts +1 -6
  69. package/scripts/issue/issue-labels.ts +6 -2
  70. package/scripts/issue/issue-release-cli.ts +69 -23
  71. package/scripts/issue/issue-release.ts +3 -1
  72. package/scripts/josh/josh-commands-ai.ts +11 -0
  73. package/scripts/josh/josh-commands-issue.ts +3 -3
  74. package/scripts/josh/josh-commands-project.ts +2 -2
  75. package/scripts/josh/process-identity.ts +32 -6
  76. package/scripts/lane/lane-broker-server.ts +69 -0
  77. package/scripts/lane/lane-session-broker.ts +37 -14
  78. package/scripts/observations/observation-ledger-migrate.ts +3 -15
  79. package/scripts/release/release-issue-close.ts +133 -0
  80. package/scripts/release/release-publish.ts +12 -1
  81. package/scripts/repo/repository-labels.ts +11 -1
  82. package/scripts/review/comment-ranges.ts +199 -0
  83. package/scripts/review/review-brief-cli.ts +22 -5
  84. package/scripts/review/review-code-tree.ts +119 -0
  85. package/scripts/review/review-round2-cli.ts +16 -5
  86. package/scripts/review/review-round2.ts +30 -10
  87. package/scripts/review/review-stamps.ts +22 -1
  88. package/scripts/uninstall/distributed-files.ts +1 -1
@@ -0,0 +1,189 @@
1
+ import { createHash } from 'node:crypto'
2
+ import {
3
+ constants,
4
+ cpSync,
5
+ existsSync,
6
+ lstatSync,
7
+ mkdtempSync,
8
+ readFileSync,
9
+ rmSync,
10
+ statSync,
11
+ } from 'node:fs'
12
+ import os from 'node:os'
13
+ import path from 'node:path'
14
+ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
15
+ import { resolve_tsx_runner } from '#scripts/josh/josh-logic'
16
+ import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
17
+ import { execaSync } from 'execa'
18
+ import { init_bootstrap } from './init-bootstrap'
19
+ import { init_dry_run_report, type FileChange } from './init-dry-run-report'
20
+ import { init_install } from './init-install'
21
+ import { PACKAGE_DIR, package_path } from './init-paths'
22
+ import { project_profile, type ProjectProfile } from './project-profile'
23
+ import { start_exec } from './start-exec'
24
+
25
+ // `josh init --dry-run` and `josh start --dry-run` answer "what would the setup change?" without
26
+ // a second copy of the setup's decisions: the real `josh init` runs on a scratch copy of the project,
27
+ // and the plan is the difference it left there. Every create, merge and skip is decided by the code
28
+ // that decides it for real, so the plan cannot drift from the run (#3830).
29
+
30
+ interface DryRunRequest {
31
+ root: string
32
+ profile: ProjectProfile | undefined
33
+ // Whether the setup meets a repository — `josh start` creates one before its setup runs.
34
+ has_git: boolean
35
+ is_install: boolean
36
+ }
37
+
38
+ interface DryRunSplit {
39
+ is_dry_run: boolean
40
+ rest: ReadonlyArray<string>
41
+ }
42
+
43
+ const DRY_RUN_FLAG = '--dry-run'
44
+ const INIT_SCRIPT = 'scripts/init/init.ts'
45
+ const SCRATCH_PREFIX = 'josh-init-dry-run-'
46
+ const DEFAULT_BRANCH = 'main'
47
+ const TITLE = 'josh init --dry-run — nothing was written. The setup would:'
48
+
49
+ function split_dry_run_flag(args: ReadonlyArray<string>): DryRunSplit {
50
+ return {
51
+ is_dry_run: args.includes(DRY_RUN_FLAG),
52
+ rest: args.filter((argument) => argument !== DRY_RUN_FLAG),
53
+ }
54
+ }
55
+
56
+ // A directory is copied unless the project scan skips it: an ignored one, or a link — followed, a
57
+ // link to the project or an ancestor would copy it into itself until the path is too long.
58
+ function is_copied_directory(source: string): boolean {
59
+ return (
60
+ !lstatSync(source).isSymbolicLink() &&
61
+ !project_profile.is_ignored_directory(path.basename(source))
62
+ )
63
+ }
64
+
65
+ // A link to a file is copied as the file it points to, so no write in the scratch copy can follow a
66
+ // link back into the project; a dangling one is left out, as the project scan leaves out what it
67
+ // cannot read. The root itself is always copied: `cpSync` filters it too, so a project in a
68
+ // directory named `build` or `dist` would otherwise be planned as an empty one.
69
+ function is_copied(root: string, source: string): boolean {
70
+ if (source === root) return true
71
+ if (!existsSync(source)) return false
72
+
73
+ return !statSync(source).isDirectory() || is_copied_directory(source)
74
+ }
75
+
76
+ // The scratch repository answers the setup's git questions as the project's does: one exists, and
77
+ // its origin is the same URL, so a GitHub project is planned as one.
78
+ function mirror_git(root: string, scratch: string): void {
79
+ start_exec.git_succeeds(['init', '--quiet', `--initial-branch=${DEFAULT_BRANCH}`], scratch)
80
+ const origin = git_spawn_sync.origin_url(root)
81
+
82
+ if (origin !== undefined) start_exec.git_succeeds(['remote', 'add', 'origin', origin], scratch)
83
+ }
84
+
85
+ function prepare_scratch(root: string, has_git: boolean): string {
86
+ const scratch = mkdtempSync(path.join(os.tmpdir(), SCRATCH_PREFIX))
87
+
88
+ // A copy-on-write clone where the file system supports one, so mirroring a large project costs
89
+ // next to nothing there; a plain copy elsewhere.
90
+ cpSync(root, scratch, {
91
+ recursive: true,
92
+ dereference: true,
93
+ mode: constants.COPYFILE_FICLONE,
94
+ filter: (source) => is_copied(root, source),
95
+ })
96
+ if (has_git) mirror_git(root, scratch)
97
+
98
+ return scratch
99
+ }
100
+
101
+ function digest_of(file: string): string {
102
+ return createHash('sha256').update(readFileSync(file)).digest('hex')
103
+ }
104
+
105
+ // A snapshot holds each file's digest, not its content, so a project with large files is compared
106
+ // without holding them in memory; only the files the setup changed are read in full.
107
+ function snapshot(root: string): Map<string, string> {
108
+ const files = project_profile.list_files(root)
109
+
110
+ return new Map(files.map((file) => [path.relative(root, file), digest_of(file)]))
111
+ }
112
+
113
+ function content_of(
114
+ root: string,
115
+ file: string,
116
+ digests: ReadonlyMap<string, string>,
117
+ ): string | undefined {
118
+ return digests.has(file) ? readFileSync(path.join(root, file), 'utf8') : undefined
119
+ }
120
+
121
+ // The project itself still holds what the scratch copy held before the setup ran.
122
+ function changes_since(
123
+ request: DryRunRequest,
124
+ scratch: string,
125
+ before: ReadonlyMap<string, string>,
126
+ ): Array<FileChange> {
127
+ const after = snapshot(scratch)
128
+
129
+ return init_dry_run_report.changed_files(before, after).map((change) => ({
130
+ path: change.path,
131
+ before: content_of(request.root, change.path, before),
132
+ after: content_of(scratch, change.path, after),
133
+ }))
134
+ }
135
+
136
+ // `--no-install` keeps the install out of the scratch copy, and the hand-off variable keeps this
137
+ // kit doing the setup itself — the scratch copy has no kit of its own to hand it to.
138
+ function run_init(scratch: string, profile: ProjectProfile | undefined): void {
139
+ const runner = resolve_tsx_runner()
140
+ const profile_args = profile === undefined ? [] : ['--profile', profile]
141
+ const args = [package_path(INIT_SCRIPT), init_install.NO_INSTALL_FLAG, ...profile_args]
142
+ const result = execaSync(runner.executable, [...runner.leading_arguments, ...args], {
143
+ cwd: scratch,
144
+ env: { [init_bootstrap.HANDOFF_ENV]: '1' },
145
+ all: true,
146
+ reject: false,
147
+ })
148
+
149
+ if (result.exitCode !== 0) throw new Error(`the simulated josh init failed:\n${result.all}`)
150
+ }
151
+
152
+ function simulate(request: DryRunRequest): Array<FileChange> {
153
+ const scratch = prepare_scratch(request.root, request.has_git)
154
+
155
+ try {
156
+ const before = snapshot(scratch)
157
+
158
+ run_init(scratch, request.profile)
159
+
160
+ return changes_since(request, scratch, before)
161
+ } finally {
162
+ rmSync(scratch, { recursive: true, force: true })
163
+ }
164
+ }
165
+
166
+ // The same two questions the real run asks: whether to install at all, and whether this kit is the
167
+ // project's own or hands the setup to the one it installs.
168
+ function dependency_lines(root: string, is_install: boolean): Array<string> {
169
+ if (!is_install) return [`skip the install and the format (${init_install.NO_INSTALL_FLAG})`]
170
+ const steps = `run ${init_install.INSTALL_STEP.label}, then ${init_install.FORMAT_STEP.label}`
171
+
172
+ if (init_bootstrap.is_project_kit(PACKAGE_DIR, root)) return [steps]
173
+
174
+ return [`install ${KIT_PACKAGE_NAME} into the project and hand the setup to its josh init`, steps]
175
+ }
176
+
177
+ function plan_report(request: DryRunRequest, title: string = TITLE): string {
178
+ const changes = simulate(request)
179
+
180
+ return init_dry_run_report.format_report(
181
+ title,
182
+ changes,
183
+ dependency_lines(request.root, request.is_install),
184
+ )
185
+ }
186
+
187
+ const init_dry_run = { DRY_RUN_FLAG, split_dry_run_flag, plan_report }
188
+ export { init_dry_run }
189
+ export type { DryRunRequest }
@@ -62,6 +62,13 @@ function run_post_init_steps(project_root: string): string | undefined {
62
62
  return undefined
63
63
  }
64
64
 
65
- const init_install = { NO_INSTALL_FLAG, split_install_flag, did_step_succeed, run_post_init_steps }
65
+ const init_install = {
66
+ NO_INSTALL_FLAG,
67
+ INSTALL_STEP,
68
+ FORMAT_STEP,
69
+ split_install_flag,
70
+ did_step_succeed,
71
+ run_post_init_steps,
72
+ }
66
73
  export { init_install }
67
74
  export type { InstallStep }
@@ -19,20 +19,27 @@ import { init_actions, PRETTIER_CONFIG_JS } from './init-actions'
19
19
  import { init_ai_copy } from './init-ai-copy'
20
20
  import { init_basic } from './init-basic'
21
21
  import { init_bootstrap } from './init-bootstrap'
22
+ import { init_dry_run } from './init-dry-run'
22
23
  import { init_file_action } from './init-file-action'
23
24
  import { init_install } from './init-install'
24
25
  import { init_logic } from './init-logic'
25
26
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
26
- import { kit_setup_state } from './kit-setup-state'
27
27
  import { package_manager_pin } from './package-manager-pin'
28
28
  import { project_profile, type ProjectProfile, type ProjectShape } from './project-profile'
29
+ import { working_tree } from './working-tree'
29
30
 
30
31
  const PACKAGE_JSON = 'package.json'
31
32
  const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
32
33
  const LEFTHOOK_BIN = 'lefthook'
33
34
  const ARGUMENT_START_INDEX = 2
34
35
  const INSTALL_HINT = '→ run `pnpm install` to install what package.json lists'
35
- const INIT_USAGE = 'josh init [--profile basic|full] [--no-install]'
36
+ const INIT_USAGE = 'josh init [--profile basic|full] [--no-install] [--dry-run]'
37
+
38
+ interface InitRequest {
39
+ is_dry_run: boolean
40
+ is_install: boolean
41
+ requested: ProjectProfile | undefined
42
+ }
36
43
 
37
44
  const engine_pin_schema = z.looseObject({ name: z.string(), version: z.string() })
38
45
 
@@ -265,14 +272,21 @@ function set_up_project(requested: ProjectProfile | undefined, is_install: boole
265
272
  console.info('\n✅ Done.\n')
266
273
  }
267
274
 
268
- // True when this process set the project up — false when it refused, or handed the run to the
269
- // project's own kit, which then speaks for itself.
270
- function main(args: ReadonlyArray<string> = []): boolean {
271
- if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return false
272
- const { is_install, rest } = init_install.split_install_flag(args)
273
- // Validated before the hand-off, so a malformed invocation is refused before kit is installed.
274
- const requested = project_profile.requested_profile(rest, INIT_USAGE)
275
+ // A dry run sets nothing up, so it answers false and points nowhere onward.
276
+ function print_dry_run({ requested, is_install }: InitRequest): false {
277
+ const has_git = project_profile.has_git(PROJECT_ROOT)
275
278
 
279
+ console.info(
280
+ init_dry_run.plan_report({ root: PROJECT_ROOT, profile: requested, has_git, is_install }),
281
+ )
282
+
283
+ return false
284
+ }
285
+
286
+ function did_set_up_here(
287
+ { requested, is_install }: InitRequest,
288
+ args: ReadonlyArray<string>,
289
+ ): boolean {
276
290
  const is_set_up_here = !is_install || !did_hand_off(args)
277
291
 
278
292
  if (is_set_up_here) set_up_project(requested, is_install)
@@ -280,19 +294,43 @@ function main(args: ReadonlyArray<string> = []): boolean {
280
294
  return is_set_up_here
281
295
  }
282
296
 
283
- // Only the command a person typed points onward to `josh start`: `josh start` calls `main` itself and
284
- // is already the next step.
285
- // A setup that failed after writing, such as a failed install, sets the exit code and points nowhere.
286
- function print_start_hint(): void {
287
- if (process.exitCode !== undefined && process.exitCode !== 0) return
288
- const hint = kit_setup_state.start_hint(PROJECT_ROOT)
297
+ // The flags are peeled off first, so what remains is exactly the `--profile` pair.
298
+ // Validated before the hand-off, so a malformed invocation is refused before kit is installed.
299
+ function parse_init_args(args: ReadonlyArray<string>): InitRequest {
300
+ const { is_dry_run, rest: run_args } = init_dry_run.split_dry_run_flag(args)
301
+ const { is_install, rest } = init_install.split_install_flag(run_args)
302
+
303
+ return { is_dry_run, is_install, requested: project_profile.requested_profile(rest, INIT_USAGE) }
304
+ }
305
+
306
+ // True when this process set the project up — false when it refused, or handed the run to the
307
+ // project's own kit, which then speaks for itself.
308
+ function main(args: ReadonlyArray<string> = []): boolean {
309
+ if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return false
310
+ const request = parse_init_args(args)
289
311
 
290
- if (hint !== undefined) console.info(`${hint}\n`)
312
+ return request.is_dry_run ? print_dry_run(request) : did_set_up_here(request, args)
313
+ }
314
+
315
+ // Only the command a person typed is refused, before anything is written or installed. `josh start`
316
+ // calls `main` itself and decides about its own tree (#3835); a handed-off run follows the check
317
+ // its caller already passed, on a tree the kit install it just made has changed.
318
+ function did_refuse_dirty_tree(): boolean {
319
+ const refusal = init_bootstrap.is_handed_off()
320
+ ? undefined
321
+ : working_tree.dirty_tree_refusal(PROJECT_ROOT, 'josh init')
322
+
323
+ if (refusal === undefined) return false
324
+ console.error(`\n${refusal}\n`)
325
+ process.exitCode = 1
326
+
327
+ return true
291
328
  }
292
329
 
293
330
  function run_cli(args: ReadonlyArray<string>): void {
294
331
  try {
295
- if (main(args)) print_start_hint()
332
+ if (did_refuse_dirty_tree()) return
333
+ main(args)
296
334
  } catch (error) {
297
335
  console.error(`\n✖ ${error_text.message_of(error)}\n`)
298
336
  process.exitCode = 1
@@ -312,5 +350,5 @@ const init = {
312
350
  }
313
351
 
314
352
  // `init` is the entry point; `main` is exported for its own unit test, the way `sync.ts` exports the
315
- // `main()` its self-run guard lives in.
316
- export { init, main }
353
+ // `main()` its self-run guard lives in, and `run_cli` for the refusal only the typed command makes.
354
+ export { init, main, run_cli }
@@ -1,30 +1,24 @@
1
+ import { json_value } from '#scripts/lib/json-value'
1
2
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
2
3
  import { start_exec } from './start-exec'
4
+ import { workflow_mark } from './workflow-mark'
3
5
 
4
- const START_HINT =
5
- 'To use the GitHub Issue workflow, run pnpm exec josh start next: it puts this setup on GitHub, through a pull request when main already has commits.'
6
-
7
- // Whether the commit checked out records kit in its manifest — the sign that kit's setup has already
8
- // reached the branch, so `josh start` has no setup pull request to open.
6
+ // Whether the commit checked out holds `josh start`'s setup — its manifest lists kit and records the
7
+ // GitHub workflow mark — the sign that the setup has already reached the branch, so `josh start` has
8
+ // no setup pull request to open. Kit alone is not enough: a committed `josh init` setup lacks the mark
9
+ // and, on `basic`, `.claude/settings.json`, which `josh start`'s setup then writes and only a setup
10
+ // pull request carries to `main` (#3835).
9
11
  function is_kit_committed(root: string): boolean {
10
12
  // `./` resolves the path from `root` rather than from the repository's top level.
11
13
  const manifest = start_exec.git_read(['show', 'HEAD:./package.json'], root)
12
14
 
13
- return manifest?.includes(`"${KIT_PACKAGE_NAME}"`) ?? false
14
- }
15
-
16
- function is_git_work_tree(root: string): boolean {
17
- return start_exec.git_succeeds(['rev-parse', '--is-inside-work-tree'], root)
18
- }
15
+ if (manifest?.includes(`"${KIT_PACKAGE_NAME}"`) !== true) return false
19
16
 
20
- // The line `josh init` ends with in a Git repository whose checkout does not hold kit yet: the setup is
21
- // still uncommitted, and `josh start` is what carries it to GitHub.
22
- function start_hint(root: string): string | undefined {
23
- if (!is_git_work_tree(root) || is_kit_committed(root)) return undefined
17
+ const parsed = json_value.parse_or_undefined(manifest)
24
18
 
25
- return START_HINT
19
+ return workflow_mark.is_recorded(json_value.is_record(parsed) ? parsed : undefined)
26
20
  }
27
21
 
28
- const kit_setup_state = { is_kit_committed, start_hint }
22
+ const kit_setup_state = { is_kit_committed }
29
23
 
30
24
  export { kit_setup_state }
@@ -0,0 +1,21 @@
1
+ import { existsSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ // The configuration files of a formatter other than Prettier. A `basic` project holding one already
5
+ // formats its own files, so `josh init` gives it no Prettier and `josh format` / `josh gate` skip it
6
+ // (joshuafolkken/kit#3832).
7
+ const OWN_FORMATTER_CONFIGS = [
8
+ 'biome.json',
9
+ 'biome.jsonc',
10
+ 'dprint.json',
11
+ 'dprint.jsonc',
12
+ '.dprint.json',
13
+ '.dprint.jsonc',
14
+ ]
15
+
16
+ function find_config(root: string): string | undefined {
17
+ return OWN_FORMATTER_CONFIGS.find((name) => existsSync(path.join(root, name)))
18
+ }
19
+
20
+ const own_formatter = { find_config }
21
+ export { own_formatter }
@@ -5,6 +5,7 @@ import { git_spawn_sync } from '#scripts/git/git-spawn-sync'
5
5
  import { json_value } from '#scripts/lib/json-value'
6
6
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
7
7
  import { kit_written_paths } from './kit-written-paths'
8
+ import { own_formatter } from './own-formatter'
8
9
  import { workflow_mark } from './workflow-mark'
9
10
 
10
11
  type ProjectProfile = 'basic' | 'full'
@@ -15,7 +16,8 @@ interface ProfileResult {
15
16
  }
16
17
 
17
18
  interface ProjectShape extends ProfileResult {
18
- has_web: boolean
19
+ // Web files, and no formatter of the project's own (`own-formatter.ts`).
20
+ should_use_prettier: boolean
19
21
  has_typescript: boolean
20
22
  has_git: boolean
21
23
  has_github: boolean
@@ -127,8 +129,12 @@ function resolve_profile(root: string, requested?: ProjectProfile): ProfileResul
127
129
  return inferred_profile(manifest)
128
130
  }
129
131
 
132
+ function is_ignored_directory(name: string): boolean {
133
+ return IGNORED_DIRECTORIES.has(name)
134
+ }
135
+
130
136
  function should_visit(entry: Dirent): boolean {
131
- return entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)
137
+ return entry.isDirectory() && !is_ignored_directory(entry.name)
132
138
  }
133
139
 
134
140
  function scan_directory(directory: string, pending: Array<string>): Array<string> {
@@ -152,7 +158,9 @@ function is_project_file(root: string, file: string): boolean {
152
158
  return !kit_written_paths.is_kit_written(relative_path)
153
159
  }
154
160
 
155
- function file_extensions(root: string): Array<string> {
161
+ // Every file under `root` outside the directories no setup reads — the one walk the project scan and
162
+ // the `josh init --dry-run` snapshot share.
163
+ function list_files(root: string): Array<string> {
156
164
  const pending = [root]
157
165
  const files: Array<string> = []
158
166
 
@@ -160,15 +168,24 @@ function file_extensions(root: string): Array<string> {
160
168
  files.push(...scan_directory(directory, pending))
161
169
  }
162
170
 
163
- return files.filter((file) => is_project_file(root, file)).map((file) => path.extname(file))
171
+ return files
172
+ }
173
+
174
+ function file_extensions(root: string): Array<string> {
175
+ return list_files(root)
176
+ .filter((file) => is_project_file(root, file))
177
+ .map((file) => path.extname(file))
164
178
  }
165
179
 
166
- function scan_files(root: string): { has_web: boolean; has_typescript: boolean } {
180
+ // A project that formats with its own tool gets no Prettier even with Web files: two formatters
181
+ // would rewrite each other's output (joshuafolkken/kit#3832).
182
+ function scan_files(root: string): { should_use_prettier: boolean; has_typescript: boolean } {
167
183
  const extensions = file_extensions(root)
168
184
  const has_web = extensions.some((extension) => WEB_EXTENSIONS.has(extension))
185
+ const should_use_prettier = has_web && own_formatter.find_config(root) === undefined
169
186
  const has_typescript = extensions.some((extension) => TYPESCRIPT_EXTENSIONS.has(extension))
170
187
 
171
- return { has_web, has_typescript }
188
+ return { should_use_prettier, has_typescript }
172
189
  }
173
190
 
174
191
  function has_github_remote(root: string): boolean {
@@ -205,6 +222,8 @@ const project_profile = {
205
222
  resolve_profile,
206
223
  has_git,
207
224
  inspect_project,
225
+ is_ignored_directory,
226
+ list_files,
208
227
  requested_profile,
209
228
  with_legacy_profile_names,
210
229
  }
@@ -0,0 +1,70 @@
1
+ import path from 'node:path'
2
+ import { repository_labels } from '#scripts/repo/repository-labels'
3
+ import { init_dry_run } from './init-dry-run'
4
+ import type { ProjectProfile } from './project-profile'
5
+ import type { StartStep } from './start-plan'
6
+ import type { Choices } from './start-prompt'
7
+ import { start_setup_pr } from './start-setup-pr'
8
+ import { workflow_mark } from './workflow-mark'
9
+
10
+ // What `josh start --dry-run` adds to the plan `josh start` prints: what each GitHub step would
11
+ // create, and what its setup step would write. Nothing here creates, pushes or writes (#3830).
12
+
13
+ const TITLE = 'josh start --dry-run — nothing was written. Its setup step would:'
14
+ const NONE = '(none)'
15
+ const UNREAD_LABELS =
16
+ 'add the workflow labels it lacks — the repository’s labels could not be read to name them'
17
+
18
+ // The labels are read from the repository when it already exists — the same listing the real step
19
+ // compares against — and a repository this run would create has none.
20
+ function label_line(steps: ReadonlyArray<StartStep>): string {
21
+ const repo = steps.includes('repository') ? undefined : '{owner}/{repo}'
22
+ const labels = repository_labels.planned_labels(repo)
23
+
24
+ if (labels === undefined) return UNREAD_LABELS
25
+
26
+ return `add the labels it lacks: ${labels.length === 0 ? NONE : labels.join(', ')}`
27
+ }
28
+
29
+ function github_lines({ steps, root, options }: Choices): Array<string> {
30
+ const lines: Readonly<Partial<Record<StartStep, string | undefined>>> = {
31
+ repository: `create the ${options.visibility} repository ${path.basename(root)} as origin, and push main`,
32
+ labels: steps.includes('labels') ? label_line(steps) : undefined,
33
+ setup_pr: `open the Issue "${start_setup_pr.SETUP_TITLE}" (or reuse the open one), push a branch named after it, and open a pull request into main`,
34
+ }
35
+
36
+ return steps.map((step) => lines[step]).filter((line) => line !== undefined)
37
+ }
38
+
39
+ function github_report(choices: Choices): string {
40
+ const lines = github_lines(choices).map((line) => ` ${line}`)
41
+
42
+ return ['GitHub:', ...(lines.length === 0 ? [` ${NONE}`] : lines)].join('\n')
43
+ }
44
+
45
+ // A caller's setup command writes files kit cannot name, so it is named rather than simulated. Kit's
46
+ // own setup runs on a scratch copy, told it sets up the GitHub workflow as the real step tells it,
47
+ // with the repository the run would have created by then.
48
+ function setup_report({ steps, root, options, shape }: Choices): string | undefined {
49
+ if (!steps.includes('initialize')) return undefined
50
+ const profile: ProjectProfile = options.profile ?? shape.profile
51
+
52
+ if (options.init_command !== undefined) {
53
+ return `The setup step would run ${options.init_command} --profile ${profile}, which is not simulated.`
54
+ }
55
+
56
+ workflow_mark.request()
57
+ const has_git = shape.has_git || steps.includes('git_init')
58
+
59
+ return init_dry_run.plan_report({ root, profile, has_git, is_install: true }, TITLE)
60
+ }
61
+
62
+ function report(choices: Choices): string {
63
+ const setup = setup_report(choices)
64
+ const sections = [github_report(choices), ...(setup === undefined ? [] : [setup])]
65
+
66
+ return `\n${sections.join('\n\n')}`
67
+ }
68
+
69
+ const start_dry_run = { report }
70
+ export { start_dry_run }
@@ -7,6 +7,7 @@ interface StartOptions {
7
7
  profile: ProjectProfile | undefined
8
8
  is_yes: boolean
9
9
  is_github: boolean
10
+ is_dry_run: boolean
10
11
  visibility: Visibility
11
12
  init_command: string | undefined
12
13
  push_preflight_command: string | undefined
@@ -120,8 +121,9 @@ function has_commit(steps: ReadonlyArray<StartStep>): boolean {
120
121
  return steps.some((step) => COMMIT_STEPS.has(step))
121
122
  }
122
123
 
124
+ // A dry run asks nothing and changes nothing, so it needs neither a terminal nor `--yes`.
123
125
  function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interaction> {
124
- if (options.is_yes) return { value: 'unattended', refusal: undefined }
126
+ if (options.is_yes || options.is_dry_run) return { value: 'unattended', refusal: undefined }
125
127
  if (is_tty) return { value: 'interactive', refusal: undefined }
126
128
 
127
129
  return {
@@ -133,13 +135,16 @@ function interaction_of(options: StartOptions, is_tty: boolean): Verdict<Interac
133
135
 
134
136
  // `--yes` accepts the detected defaults, never the creation of a public-facing resource: an
135
137
  // unattended run creates a repository, pushes, or opens the setup Issue and pull request only when
136
- // `--github` asks for it by name.
138
+ // `--github` asks for it by name. A dry run writes to GitHub no more than it writes anywhere else, so
139
+ // it lists the GitHub steps without that consent.
137
140
  function github_consent(
138
141
  steps: ReadonlyArray<StartStep>,
139
142
  options: StartOptions,
140
143
  interaction: Interaction,
141
144
  ): Verdict<Consent> {
142
- if (!has_push(steps) || options.is_github) return { value: 'granted', refusal: undefined }
145
+ const is_consented = options.is_github || options.is_dry_run
146
+
147
+ if (is_consented || !has_push(steps)) return { value: 'granted', refusal: undefined }
143
148
  if (interaction === 'interactive') return { value: 'ask', refusal: undefined }
144
149
 
145
150
  return {
@@ -9,28 +9,19 @@ import { IGNORE_FOR_RELEASE_LABEL } from '#scripts/issue/issue-labels'
9
9
  import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
10
10
  import { kit_written_paths } from './kit-written-paths'
11
11
  import { start_exec } from './start-exec'
12
+ import { working_tree } from './working-tree'
12
13
 
13
14
  const SETUP_TITLE = `Set up ${KIT_PACKAGE_NAME}`
14
15
  const SETUP_BODY = [
15
16
  `Adds the files \`josh start\` wrote while setting up ${KIT_PACKAGE_NAME}, so main carries the`,
16
17
  'rules, settings and workflows every later Issue is run with. Only the files the setup wrote are in',
17
- 'the pull request; anything else in the working tree is left as it was.',
18
+ 'the pull request: `josh start` runs only on a working tree with nothing uncommitted.',
18
19
  ].join('\n')
19
20
  const NAME_ONLY = '--name-only'
20
21
  const NOTHING_TO_COMMIT = 'The kit setup has no changes to commit, so no pull request was opened.'
21
22
  const OPEN_SETUP_ISSUES_QUERY = `?state=open&labels=${IGNORE_FOR_RELEASE_LABEL}&per_page=100`
22
23
  const FIRST_SETUP_ISSUE_FILTER = `[.[] | select(.title == "${SETUP_TITLE}") | .number][0] // empty`
23
24
 
24
- // Listings are read NUL-separated (`-z`), so a path is never quoted — a name a caller's initialize
25
- // command wrote need not be ASCII the way kit's own are (#2872).
26
- function paths_of(output: string | undefined = ''): Array<string> {
27
- return output.split('\0').filter((listed) => listed.length > 0)
28
- }
29
-
30
- function listing(args: ReadonlyArray<string>, root: string): Array<string> {
31
- return paths_of(start_exec.git_read([...args, '-z'], root))
32
- }
33
-
34
25
  function setup_issue(issue_number: string): IssueInfo {
35
26
  return git_issue.parse(`${SETUP_TITLE} ${issue_cite.plain(issue_number)}`)
36
27
  }
@@ -49,19 +40,10 @@ function is_setup_branch(branch: string | undefined): boolean {
49
40
  return setup_branch_number(branch) !== undefined
50
41
  }
51
42
 
52
- // Untracked files the ignore rules let through, plus tracked files the setup changed — two plain
53
- // listings, so nothing has to be parsed out of `git status`.
54
- function changed_paths(root: string): Array<string> {
55
- const untracked = listing(['ls-files', '--others', '--exclude-standard'], root)
56
- const modified = listing(['diff', NAME_ONLY, 'HEAD'], root)
57
-
58
- return [...untracked, ...modified]
59
- }
60
-
61
43
  // On the setup branch a failed commit left, that run's `git add` already staged its files, so they
62
44
  // read as changed before this run's command ran — the staged ones count as setup output (#2872).
63
45
  function carried_paths(root: string, current: string | undefined): Array<string> {
64
- return is_setup_branch(current) ? listing(['diff', NAME_ONLY, '--cached'], root) : []
46
+ return is_setup_branch(current) ? working_tree.listing(['diff', NAME_ONLY, '--cached'], root) : []
65
47
  }
66
48
 
67
49
  // The Issue carries the release classification the pull request is opened with, so the PR step finds
@@ -124,13 +106,15 @@ function commit_kit_paths(paths: ReadonlyArray<string>, issue: IssueInfo, root:
124
106
 
125
107
  // A caller's initialize command writes files kit cannot name, so with a `baseline` — what was already
126
108
  // changed before that command ran — everything changed since joins kit's own files, while a change
127
- // the user had made before the run still stays out (#2872).
109
+ // the user had made before the run still stays out (#2872). `josh start` refuses a dirty tree on
110
+ // `main` (#3835), so the baseline there is empty; it still keeps the two apart on the setup branch a
111
+ // failed commit left, where the re-run starts with that run's output uncommitted.
128
112
  function setup_paths(
129
113
  root: string,
130
114
  baseline: ReadonlyArray<string> | undefined,
131
115
  current: string | undefined,
132
116
  ): Array<string> {
133
- const changed = changed_paths(root)
117
+ const changed = working_tree.changed_paths(root)
134
118
  const kit_paths = kit_written_paths.select(changed)
135
119
 
136
120
  if (baseline === undefined) return kit_paths
@@ -161,6 +145,11 @@ async function open(root: string, baseline?: ReadonlyArray<string>): Promise<voi
161
145
  await git_pr.create_with_issue_info(issue)
162
146
  }
163
147
 
164
- const start_setup_pr = { open, changed_paths, is_setup_branch }
148
+ const start_setup_pr = {
149
+ SETUP_TITLE,
150
+ open,
151
+ changed_paths: working_tree.changed_paths,
152
+ is_setup_branch,
153
+ }
165
154
 
166
155
  export { start_setup_pr }