@joshuafolkken/kit 1.887.0 → 1.929.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 (127) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-progress.md +13 -12
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -1
  3. package/.claude/skills/workflow-commands/fullrun-steps.md +4 -2
  4. package/.claude/skills/workflow-commands/halfrun.md +3 -1
  5. package/.claude/skills/workflow-commands/kickoff.md +5 -3
  6. package/.claude/skills/workflow-commands/observation-filing.md +3 -1
  7. package/.claude/skills/workflow-commands/prerequisite.md +3 -1
  8. package/.claude/skills/workflow-commands/target-repository.md +1 -1
  9. package/.github/pull_request_template.md +12 -0
  10. package/.github/release.yml +3 -0
  11. package/.github/workflows/ci.yml +8 -0
  12. package/.github/workflows/production.yml +10 -1
  13. package/.github/workflows/publish.yml +72 -2
  14. package/CLAUDE.md +2 -0
  15. package/README.md +2 -1
  16. package/dist/CLAUDE.md +2 -0
  17. package/dist/CLAUDE.static.md +8 -0
  18. package/dist/hooks/batch-guard.js +6 -6
  19. package/dist/hooks/{chunk-264QY6KS.js → chunk-2PGRQ62K.js} +25 -10
  20. package/dist/hooks/chunk-3V37IID6.js +82 -0
  21. package/dist/hooks/{chunk-36UE53LS.js → chunk-7XQ6TK3R.js} +2 -2
  22. package/dist/hooks/{chunk-PRDCNGA6.js → chunk-BNFG2IFK.js} +4 -4
  23. package/dist/hooks/{chunk-VGYSA2NT.js → chunk-GQ3EVWHY.js} +22 -3
  24. package/dist/hooks/{chunk-Y5OYCU2F.js → chunk-I3JP3D4A.js} +3 -3
  25. package/dist/hooks/{chunk-6MQCXEEA.js → chunk-LCJZE42I.js} +25 -88
  26. package/dist/hooks/{chunk-ZBK4XRAS.js → chunk-NJZSO7BK.js} +3 -3
  27. package/dist/hooks/{chunk-SSX23SJ2.js → chunk-RKUAD3I6.js} +1 -0
  28. package/dist/hooks/{chunk-FRJKVSK3.js → chunk-ROULPSQC.js} +2 -2
  29. package/dist/hooks/{chunk-FNFOLFFV.js → chunk-SXPTRFPG.js} +2 -2
  30. package/dist/hooks/{chunk-FXAKJKTB.js → chunk-WPALEIRX.js} +1 -1
  31. package/dist/hooks/{chunk-4OXFU3MK.js → chunk-YO43HEPY.js} +634 -156
  32. package/dist/hooks/codex-hook-adapter.js +13 -12
  33. package/dist/hooks/duplicate-read-guard.js +7 -7
  34. package/dist/hooks/format-edited.js +4 -3
  35. package/dist/hooks/investigation-guard.js +7 -7
  36. package/dist/hooks/pretool-guard.js +12 -11
  37. package/dist/hooks/session-lang.js +2 -2
  38. package/dist/hooks/stop-guard.js +5 -5
  39. package/dist/josh.js +156 -20
  40. package/eslint/base.js +4 -1
  41. package/package.json +72 -18
  42. package/playwright.config.ts +9 -17
  43. package/ports/index.d.ts +1 -0
  44. package/ports/index.js +1 -0
  45. package/ports/web-server.d.ts +5 -0
  46. package/ports/web-server.js +73 -0
  47. package/prettier/index.js +3 -8
  48. package/prettier/static.d.ts +3 -0
  49. package/prettier/static.js +10 -0
  50. package/prompts/collaboration-workflow/issue-template.md +11 -4
  51. package/prompts/collaboration-workflow/upstream-interrupt.md +1 -1
  52. package/prompts/collaboration-workflow/wip-cap.md +2 -2
  53. package/prompts/review.md +2 -2
  54. package/prompts/testing-guide.md +1 -0
  55. package/scripts/agent/agent-role-profile.ts +11 -0
  56. package/scripts/build/build-claude-md.ts +6 -1
  57. package/scripts/ci/pr-classification.ts +114 -0
  58. package/scripts/document/document-byte-budget.ts +3 -2
  59. package/scripts/gate/gate-report.ts +2 -0
  60. package/scripts/gate/gate-steps.ts +50 -0
  61. package/scripts/gate/project-checks.ts +145 -0
  62. package/scripts/gate/verification-gate.ts +14 -43
  63. package/scripts/git/git-common-directory.ts +9 -1
  64. package/scripts/git/git-gh-pr-rollup.ts +29 -1
  65. package/scripts/git/git-gh-pr.ts +61 -1
  66. package/scripts/git/git-pr.ts +62 -13
  67. package/scripts/git/issue-labels.ts +9 -0
  68. package/scripts/init/distributed-paths.ts +9 -5
  69. package/scripts/init/hook-command-bootstrap.ts +40 -8
  70. package/scripts/init/hook-command-rewrite.ts +7 -3
  71. package/scripts/init/init-actions.ts +94 -7
  72. package/scripts/init/init-ai-copy.ts +107 -19
  73. package/scripts/init/init-logic-workspace.ts +38 -5
  74. package/scripts/init/init-logic.ts +1 -0
  75. package/scripts/init/init-static.ts +55 -0
  76. package/scripts/init/init.ts +97 -30
  77. package/scripts/init/project-profile-cli.ts +14 -0
  78. package/scripts/init/project-profile.ts +158 -0
  79. package/scripts/init/start.ts +30 -0
  80. package/scripts/issue/issue-bug-label.ts +13 -0
  81. package/scripts/issue/issue-classification.ts +74 -0
  82. package/scripts/issue/issue-lint-cli.ts +10 -3
  83. package/scripts/issue/markdown-section.ts +38 -4
  84. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +27 -1
  85. package/scripts/josh/josh-command-map.ts +3 -0
  86. package/scripts/josh/josh-commands-development.ts +8 -15
  87. package/scripts/josh/josh-commands-project.ts +14 -1
  88. package/scripts/josh/josh-logic.ts +13 -0
  89. package/scripts/josh/run-github-prerequisite.ts +32 -0
  90. package/scripts/lane/lane-await.ts +8 -1
  91. package/scripts/lane/openai-lane-ship.ts +30 -0
  92. package/scripts/lane/openai-lane-supervisor.ts +35 -8
  93. package/scripts/lane/openai-review-broker.ts +260 -0
  94. package/scripts/lint/cspell-cached.ts +21 -0
  95. package/scripts/lint/format.ts +68 -0
  96. package/scripts/lint/lint-parallel.ts +30 -1
  97. package/scripts/observations/observations-flush.ts +1 -1
  98. package/scripts/package/production-dependency-graph.ts +68 -0
  99. package/scripts/package/verify-optional-eslint-install.ts +62 -0
  100. package/scripts/release/github-release-cli.ts +11 -0
  101. package/scripts/release/github-release.ts +220 -0
  102. package/scripts/release/publish-tag-cli.ts +8 -0
  103. package/scripts/release/publish-tag.ts +11 -0
  104. package/scripts/release/release-publish.ts +7 -2
  105. package/scripts/rules/delivered-rules.ts +2 -0
  106. package/scripts/rules/issue-bug-label.ts +85 -0
  107. package/scripts/rules/issue-filing-args.ts +50 -0
  108. package/scripts/run/run-merge-cli.ts +19 -0
  109. package/scripts/run/run-merge.ts +9 -1
  110. package/scripts/run/run-ship-cli.ts +20 -0
  111. package/scripts/run/run-ship-detach.ts +180 -14
  112. package/scripts/run/run-ship-review-steps.ts +45 -25
  113. package/scripts/run/run-step-cli.ts +11 -1
  114. package/scripts/run/run-step.ts +36 -8
  115. package/scripts/run/run-wake-session.ts +3 -1
  116. package/scripts/test/pilot-files.ts +0 -2
  117. package/scripts/test/skip-marker.ts +3 -0
  118. package/scripts/test/test-declared-changed.ts +2 -1
  119. package/scripts/test/test-declared-logic.ts +15 -10
  120. package/scripts/test/test-declared.ts +20 -8
  121. package/scripts/test/test-red-logic.ts +3 -3
  122. package/scripts/test/test-unit-guard.ts +24 -12
  123. package/templates/CLAUDE.static.md +8 -0
  124. package/templates/cursorrules.static +1 -0
  125. package/templates/pnpm-workspace.static.yaml +4 -0
  126. package/templates/prettierignore.static +12 -0
  127. package/templates/workflows/ci.yml +5 -0
@@ -0,0 +1,145 @@
1
+ import { existsSync, globSync, readFileSync } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { find_local_bin_upwards } from '#scripts/build/local-bin'
4
+ import { SKIP_MARKER } from '#scripts/test/skip-marker'
5
+
6
+ const PACKAGE_JSON = 'package.json'
7
+ const IGNORED_DIRS = new Set([
8
+ 'node_modules',
9
+ '.git',
10
+ '.venv',
11
+ 'venv',
12
+ 'target',
13
+ 'dist',
14
+ 'build',
15
+ '.svelte-kit',
16
+ ])
17
+ const WEB_FILES = '**/*.{html,css,js,jsx,mjs,cjs}'
18
+ const SCRIPT_FILES = '**/*.{js,jsx,mjs,cjs,ts,tsx,mts,cts}'
19
+ const TYPE_FILES = '**/*.{ts,tsx,mts,cts}'
20
+ const ESLINT_CONFIGS = [
21
+ 'eslint.config.js',
22
+ 'eslint.config.mjs',
23
+ 'eslint.config.cjs',
24
+ 'eslint.config.ts',
25
+ 'eslint.config.mts',
26
+ 'eslint.config.cts',
27
+ ]
28
+ const CSPELL_CONFIGS = [
29
+ 'cspell.config.yaml',
30
+ 'cspell.config.yml',
31
+ 'cspell.config.json',
32
+ 'cspell.config.js',
33
+ 'cspell.config.cjs',
34
+ 'cspell.config.mjs',
35
+ 'cspell.json',
36
+ 'cspell.yaml',
37
+ ]
38
+ const TYPE_CONFIGS = ['tsconfig.json', 'jsconfig.json']
39
+
40
+ function project_root(directory: string): string {
41
+ let current = directory
42
+
43
+ while (!existsSync(path.join(current, PACKAGE_JSON))) {
44
+ const parent = path.dirname(current)
45
+ if (parent === current) return directory
46
+ current = parent
47
+ }
48
+
49
+ return current
50
+ }
51
+
52
+ function is_ignored(entry: string): boolean {
53
+ return entry.split(/[/\\]/u).some((part) => IGNORED_DIRS.has(part))
54
+ }
55
+
56
+ function has_files(directory: string, pattern: string): boolean {
57
+ return globSync(pattern, { cwd: project_root(directory), exclude: is_ignored }).length > 0
58
+ }
59
+
60
+ function has_config(directory: string, names: ReadonlyArray<string>): boolean {
61
+ return names.some((name) => existsSync(path.join(project_root(directory), name)))
62
+ }
63
+
64
+ function has_bin(directory: string, bin_name: string): boolean {
65
+ return find_local_bin_upwards(directory, bin_name) !== undefined
66
+ }
67
+
68
+ function josh_settings(manifest: unknown): unknown {
69
+ if (typeof manifest !== 'object' || manifest === null || !('josh' in manifest)) return undefined
70
+
71
+ return manifest.josh
72
+ }
73
+
74
+ function is_static_manifest(manifest: unknown): boolean {
75
+ const josh = josh_settings(manifest)
76
+ if (typeof josh !== 'object' || josh === null || !('profile' in josh)) return false
77
+
78
+ return josh.profile === 'static'
79
+ }
80
+
81
+ function is_static(directory: string): boolean {
82
+ try {
83
+ const manifest_path = path.join(project_root(directory), PACKAGE_JSON)
84
+ const manifest: unknown = JSON.parse(readFileSync(manifest_path, 'utf8'))
85
+
86
+ return is_static_manifest(manifest)
87
+ } catch {
88
+ return false
89
+ }
90
+ }
91
+
92
+ function skip_notice(check: string, reason: string): string {
93
+ return `josh ${check}: ${reason} ${SKIP_MARKER} ${check}.`
94
+ }
95
+
96
+ function type_check_skip_reason(directory: string): string | undefined {
97
+ if (!is_static(directory)) return undefined
98
+ if (!has_files(directory, TYPE_FILES)) return 'no TypeScript files were found'
99
+ if (!has_config(directory, TYPE_CONFIGS)) return 'no TypeScript configuration was found'
100
+ if (!has_bin(directory, 'tsc')) return 'typescript is not installed'
101
+
102
+ return undefined
103
+ }
104
+
105
+ // Shared by `josh lint` and `josh format`, so a static project without Web files — a Python or Rust
106
+ // project, which `josh init` gives no Prettier — is skipped for the same reason by both
107
+ // (joshuafolkken/kit#2606).
108
+ function prettier_skip_reason(directory: string): string | undefined {
109
+ if (!is_static(directory)) return undefined
110
+ if (!has_files(directory, WEB_FILES)) return 'no HTML, CSS or JavaScript files were found'
111
+ if (!has_bin(directory, 'prettier')) return 'prettier is not installed'
112
+
113
+ return undefined
114
+ }
115
+
116
+ // Shared by `josh lint` and `josh format`, so a static project without ESLint is skipped for the
117
+ // same reason by both (joshuafolkken/kit#2693).
118
+ function eslint_skip_reason(directory: string): string | undefined {
119
+ if (!is_static(directory)) return undefined
120
+ if (!has_files(directory, SCRIPT_FILES)) return 'no JavaScript or TypeScript files were found'
121
+ if (!has_config(directory, ESLINT_CONFIGS)) return 'no ESLint configuration was found'
122
+ if (!has_bin(directory, 'eslint')) return 'eslint is not installed'
123
+
124
+ return undefined
125
+ }
126
+
127
+ const project_checks = {
128
+ CSPELL_CONFIGS,
129
+ ESLINT_CONFIGS,
130
+ SCRIPT_FILES,
131
+ TYPE_CONFIGS,
132
+ TYPE_FILES,
133
+ WEB_FILES,
134
+ eslint_skip_reason,
135
+ has_bin,
136
+ has_config,
137
+ has_files,
138
+ is_static,
139
+ prettier_skip_reason,
140
+ project_root,
141
+ skip_notice,
142
+ type_check_skip_reason,
143
+ }
144
+
145
+ export { project_checks }
@@ -10,12 +10,13 @@ import { review_stamps } from '#scripts/review/review-stamps'
10
10
  import { review_tree } from '#scripts/review/review-tree'
11
11
  import { unit_worker_share } from '#scripts/test/unit-worker-share'
12
12
  import { core_budget } from './core-budget'
13
- import { gate_plan, type GateCheck, type GatePlan } from './gate-plan'
13
+ import { gate_plan, type GatePlan } from './gate-plan'
14
14
  import { gate_report, type GateStep, type GateStepResult } from './gate-report'
15
15
  import { gate_skip } from './gate-skip'
16
+ import { build_gate_step, build_gate_steps, UNIT_WORKER_FLAG } from './gate-steps'
16
17
  import { gate_tree, type GateTree } from './gate-tree'
18
+ import { project_checks } from './project-checks'
17
19
  import { scoped_green, type ScopedSources } from './scoped-green'
18
- import { type_check_step } from './type-check-step'
19
20
 
20
21
  // joshuafolkken/kit#914: the completion gate's four checks are independent and share no mutable
21
22
  // state, yet every entry point ran them one after another — paid again on every `epicrun` child,
@@ -33,55 +34,25 @@ import { type_check_step } from './type-check-step'
33
34
  // `process.argv` is [runner, script, ...arguments].
34
35
  const FIRST_ARGUMENT_INDEX = 2
35
36
 
36
- const JOSH = 'josh'
37
- const { GATE_CHECKS, TYPE_CHECK_LABEL, UNIT_LABEL } = gate_plan
37
+ const { GATE_CHECKS } = gate_plan
38
38
 
39
39
  const GATE_TARGETS: ReadonlyArray<string> = GATE_CHECKS.map((check) => check.target)
40
40
 
41
- // vitest's own flag, appended to the sub-command rather than set in `vitest.config.ts`: the config
42
- // is one project's, and the number this carries is a property of the machine the gate is running
43
- // on (joshuafolkken/kit#1258). `josh test:unit` forwards what it is given straight to
44
- // `vitest run`, so nothing between here and vitest has to know about it.
45
- const UNIT_WORKER_FLAG = '--maxWorkers'
46
-
47
- function unit_worker_args(check: GateCheck, plan: GatePlan): ReadonlyArray<string> {
48
- if (check.label !== UNIT_LABEL || plan.unit_worker_cap === undefined) return []
49
-
50
- return [`${UNIT_WORKER_FLAG}=${String(plan.unit_worker_cap)}`]
51
- }
52
-
53
- // Only the type check is resolved per project (joshuafolkken/kit#934) — a SvelteKit project
54
- // type-checks through its own toolkit, not through `tsc --noEmit`. Resolving inside the step keeps
55
- // the probe concurrent with the other three checks rather than delaying every one of them.
56
- async function build_gate_step(
57
- check: GateCheck,
58
- start_directory: string,
59
- plan: GatePlan = gate_plan.resolve_gate_plan(),
60
- ): Promise<GateStep> {
61
- if (check.label !== TYPE_CHECK_LABEL) {
41
+ async function run_gate_step(step: GateStep): Promise<GateStepResult> {
42
+ if (step.skip_reason !== undefined) {
62
43
  return {
63
- label: check.label,
64
- command_args: [JOSH, check.target, ...unit_worker_args(check, plan)],
44
+ label: step.label,
45
+ command: step.command_args.join(' '),
46
+ output: project_checks.skip_notice('check', step.skip_reason),
47
+ exit_code: 0,
48
+ elapsed_ms: 0,
65
49
  }
66
50
  }
67
51
 
68
- return {
69
- label: check.label,
70
- command_args: await type_check_step.resolve_type_check_args(start_directory),
71
- }
72
- }
73
-
74
- async function build_gate_steps(
75
- start_directory: string,
76
- plan: GatePlan = gate_plan.resolve_gate_plan(),
77
- ): Promise<ReadonlyArray<GateStep>> {
78
- return await Promise.all(
79
- plan.checks.map(async (check) => await build_gate_step(check, start_directory, plan)),
52
+ const result = await buffered_process.run_buffered_process(
53
+ step.command_args,
54
+ step.cwd === undefined ? {} : { cwd: step.cwd },
80
55
  )
81
- }
82
-
83
- async function run_gate_step(step: GateStep): Promise<GateStepResult> {
84
- const result = await buffered_process.run_buffered_process(step.command_args)
85
56
 
86
57
  return { label: step.label, command: step.command_args.join(' '), ...result }
87
58
  }
@@ -33,6 +33,14 @@ function resolve(cwd: string): string | undefined {
33
33
  }
34
34
  }
35
35
 
36
- const git_common_directory = { resolve, select_linked }
36
+ function repository(cwd: string): string | undefined {
37
+ try {
38
+ return read_directories(cwd)[COMMON_GIT_DIRECTORY]
39
+ } catch {
40
+ return undefined
41
+ }
42
+ }
43
+
44
+ const git_common_directory = { repository, resolve, select_linked }
37
45
 
38
46
  export { git_common_directory }
@@ -29,6 +29,7 @@ const check_runs_page_schema = z.looseObject({
29
29
  // answer an empty rollup — which `git-pr-followup.ts` reports as "this branch has no checks".
30
30
  check_runs: z.array(rollup_element_schema),
31
31
  })
32
+ const check_run_app_schema = z.object({ id: z.number() })
32
33
 
33
34
  const status_page_schema = z.looseObject({
34
35
  statuses: z.array(rollup_element_schema),
@@ -50,10 +51,37 @@ function read_pages<T>(raw_json: string, schema: z.ZodType<T>, message: string):
50
51
  return pages
51
52
  }
52
53
 
54
+ function check_run_group_key(run: RollupElement): string | undefined {
55
+ const { name, app: raw_app } = run
56
+ if (typeof name !== 'string') return undefined
57
+ const app = check_run_app_schema.safeParse(raw_app)
58
+
59
+ return JSON.stringify([name, app.success ? app.data.id : undefined])
60
+ }
61
+
62
+ function remember_newest_run(run: RollupElement, latest_ids: Map<string, number>): void {
63
+ const key = check_run_group_key(run)
64
+ const { id } = run
65
+ if (key === undefined || typeof id !== 'number') return
66
+ const previous_id = latest_ids.get(key)
67
+ if (previous_id === undefined || id > previous_id) latest_ids.set(key, id)
68
+ }
69
+
70
+ function is_newest_run(run: RollupElement, latest_ids: Map<string, number>): boolean {
71
+ const key = check_run_group_key(run)
72
+ const { id } = run
73
+ if (key === undefined || typeof id !== 'number') return true
74
+
75
+ return id === latest_ids.get(key)
76
+ }
77
+
53
78
  function to_check_run_items(check_runs_json: string): Array<RollupElement> {
54
79
  const pages = read_pages(check_runs_json, check_runs_page_schema, NOT_A_CHECK_RUNS_LISTING)
80
+ const runs = pages.flatMap((page) => page.check_runs)
81
+ const latest_ids = new Map<string, number>()
82
+ for (const run of runs) remember_newest_run(run, latest_ids)
55
83
 
56
- return pages.flatMap((page) => page.check_runs)
84
+ return runs.filter((run) => is_newest_run(run, latest_ids))
57
85
  }
58
86
 
59
87
  function to_status_context_items(status_json: string): Array<RollupElement> {
@@ -1,4 +1,6 @@
1
+ import { pr_classification, type ReleaseClassification } from '#scripts/ci/pr-classification'
1
2
  import { poll } from '#scripts/lib/poll'
3
+ import { z } from 'zod'
2
4
  import { git_command } from './git-command'
3
5
  import { git_gh_api_path } from './git-gh-api-path'
4
6
  import { git_gh_exec } from './git-gh-exec'
@@ -46,6 +48,44 @@ const MERGE_UNCONFIRMED_MESSAGE =
46
48
  'gh api could not read the pull request back after the merge request failed'
47
49
  const MERGE_READ_BACK_ATTEMPTS = 3
48
50
  const MERGE_READ_BACK_INTERVAL_MS = 1000
51
+ const PR_NUMBER_PATTERN = /\/pull\/([1-9]\d*)$/u
52
+ const LABELS_SEGMENT = '/labels'
53
+ const PR_LABELS = z.array(z.object({ name: z.string() }))
54
+
55
+ async function add_pr_classification(number: string, label: ReleaseClassification): Promise<void> {
56
+ await git_gh_exec.exec_gh_api({
57
+ path: `${git_gh_api_path.issue_api_path(number)}${LABELS_SEGMENT}`,
58
+ body: JSON.stringify({ labels: [label] }),
59
+ })
60
+ }
61
+
62
+ async function pr_get_classification(
63
+ branch_name: string,
64
+ ): Promise<ReleaseClassification | undefined> {
65
+ const number = await require_pr_number(branch_name)
66
+ const response = await git_gh_exec.exec_gh_api({
67
+ path: `${git_gh_api_path.issue_api_path(String(number))}${LABELS_SEGMENT}`,
68
+ })
69
+ const names = PR_LABELS.parse(JSON.parse(response)).map((label) => label.name.toLowerCase())
70
+ const selected = names.filter((name) => pr_classification.is_classification(name))
71
+ if (selected.length === 0) return undefined
72
+
73
+ const error = pr_classification.classification_error(names, 'human')
74
+ if (error !== undefined) throw new Error(error)
75
+
76
+ return selected[0]
77
+ }
78
+
79
+ async function pr_ensure_classification(
80
+ branch_name: string,
81
+ label: ReleaseClassification,
82
+ ): Promise<void> {
83
+ if ((await pr_get_classification(branch_name)) !== undefined) return
84
+
85
+ const number = await require_pr_number(branch_name)
86
+
87
+ await add_pr_classification(String(number), label)
88
+ }
49
89
 
50
90
  // **`head` is required and `gh pr create` never asked for it.** The CLI inferred it from the current
51
91
  // branch; REST does not, and a request without it is a 422. The branch is read from git rather than
@@ -64,7 +104,7 @@ const MERGE_READ_BACK_INTERVAL_MS = 1000
64
104
  // `to_gh_error` appends stdout (joshuafolkken/kit#1029); the `already exists` match itself is
65
105
  // unchanged, because both wordings contain it and the 422 carries no machine-readable code for the
66
106
  // case (`"code":"custom"`).
67
- async function pr_create(title: string, body: string): Promise<string> {
107
+ async function post_pull_request(title: string, body: string): Promise<string> {
68
108
  const base = await git_command.get_default_branch()
69
109
  const head = await git_command.branch()
70
110
 
@@ -81,6 +121,24 @@ async function pr_create(title: string, body: string): Promise<string> {
81
121
  }
82
122
  }
83
123
 
124
+ async function pr_create(
125
+ title: string,
126
+ body: string,
127
+ label: ReleaseClassification,
128
+ ): Promise<string> {
129
+ const classification_error = pr_classification.classification_error([label], 'human')
130
+ if (classification_error !== undefined) throw new Error(classification_error)
131
+
132
+ const url = await post_pull_request(title, body)
133
+ const number = PR_NUMBER_PATTERN.exec(url)?.[1]
134
+
135
+ if (number === undefined) throw new Error(`Could not identify the created pull request: ${url}`)
136
+
137
+ await add_pr_classification(number, label)
138
+
139
+ return url
140
+ }
141
+
84
142
  // **`gh pr checkout` does use GraphQL** — one `POST /graphql` to resolve the pull request, measured
85
143
  // with `GH_DEBUG=api` on the throwaway pull request (joshuafolkken/kit#1029). Everything after that
86
144
  // is git, so only the resolution had to move: `pr_head_reference` is the REST read that already
@@ -210,6 +268,8 @@ const git_gh_pr = {
210
268
  ...git_gh_pr_read,
211
269
  ...git_gh_pr_snapshot,
212
270
  pr_create,
271
+ pr_get_classification,
272
+ pr_ensure_classification,
213
273
  pr_checkout,
214
274
  pr_comment,
215
275
  pr_merge,
@@ -1,3 +1,4 @@
1
+ import { pr_classification, type ReleaseClassification } from '#scripts/ci/pr-classification'
1
2
  import { animation_helpers, type AnimationOptions } from './animation-helpers'
2
3
  import { git_gh_command } from './git-gh-command'
3
4
  import type { IssueInfo } from './git-issue'
@@ -12,7 +13,11 @@ import { pr_info_schema } from './schemas'
12
13
  // in the same instant the pull request was created it can answer nothing, and `pr_get_url` folds
13
14
  // "not there yet" and "the read failed" into the same `undefined`. The five-second sleep used to
14
15
  // cover that gap incidentally.
15
- async function create_pr(title: string, body: string): Promise<string | undefined> {
16
+ async function create_pr(
17
+ title: string,
18
+ body: string,
19
+ label: ReleaseClassification,
20
+ ): Promise<string | undefined> {
16
21
  const config: AnimationOptions<string> = {
17
22
  icon_selector: () => '✅',
18
23
  error_message: 'Failed to create PR',
@@ -22,7 +27,7 @@ async function create_pr(title: string, body: string): Promise<string | undefine
22
27
  try {
23
28
  return await animation_helpers.execute_with_animation(
24
29
  'Creating pull request...',
25
- async () => await git_gh_command.pr_create(title, body),
30
+ async () => await git_gh_command.pr_create(title, body, label),
26
31
  config,
27
32
  )
28
33
  } catch (error) {
@@ -72,8 +77,13 @@ async function report_open_pr(branch_name: string, created_url?: string): Promis
72
77
 
73
78
  const PR_STATE_MERGED = 'MERGED'
74
79
 
75
- async function create_and_report(title: string, body: string, branch_name: string): Promise<void> {
76
- const created_url = await create_pr(title, body)
80
+ async function create_and_report(
81
+ title: string,
82
+ body: string,
83
+ branch_name: string,
84
+ label: ReleaseClassification,
85
+ ): Promise<void> {
86
+ const created_url = await create_pr(title, body, label)
77
87
 
78
88
  await report_open_pr(branch_name, created_url)
79
89
  }
@@ -113,7 +123,9 @@ async function report_existing_pr(
113
123
  body: string,
114
124
  branch_name: string,
115
125
  should_replace_body: boolean,
126
+ label: ReleaseClassification,
116
127
  ): Promise<void> {
128
+ await git_gh_command.pr_ensure_classification(branch_name, label)
117
129
  if (should_replace_body) await git_gh_command.pr_update_body(branch_name, body)
118
130
 
119
131
  await report_open_pr(branch_name)
@@ -123,35 +135,36 @@ async function handle_existing_pr(
123
135
  title: string,
124
136
  body: string,
125
137
  branch_name: string,
126
- should_replace_body: boolean,
138
+ options: { label: ReleaseClassification; should_replace_body: boolean },
127
139
  ): Promise<void> {
128
140
  const pr_state_result = await get_pr_state_safe(branch_name)
129
141
 
130
142
  if (is_pr_state_merged(pr_state_result)) {
131
143
  git_pr_messages.display_merged_pr_message()
132
- await create_and_report(title, body, branch_name)
144
+ await create_and_report(title, body, branch_name, options.label)
133
145
 
134
146
  return
135
147
  }
136
148
 
137
- await report_existing_pr(body, branch_name, should_replace_body)
149
+ await report_existing_pr(body, branch_name, options.should_replace_body, options.label)
138
150
  }
139
151
 
140
152
  async function create(
141
153
  title: string,
142
154
  body: string,
143
155
  branch_name: string,
144
- should_replace_body = false,
156
+ options: { label: ReleaseClassification; should_replace_body?: boolean },
145
157
  ): Promise<void> {
158
+ const { label, should_replace_body = false } = options
146
159
  const has_pr = await git_gh_command.pr_exists(branch_name)
147
160
 
148
161
  if (!has_pr) {
149
- await create_and_report(title, body, branch_name)
162
+ await create_and_report(title, body, branch_name, label)
150
163
 
151
164
  return
152
165
  }
153
166
 
154
- await handle_existing_pr(title, body, branch_name, should_replace_body)
167
+ await handle_existing_pr(title, body, branch_name, { label, should_replace_body })
155
168
  }
156
169
 
157
170
  function build_title(issue_info: IssueInfo): string {
@@ -166,11 +179,47 @@ function build_body(issue_info: IssueInfo, extra_body?: string): string {
166
179
  return `${closes}\n\n${extra_body}`
167
180
  }
168
181
 
182
+ async function existing_pr_label(branch_name: string): Promise<ReleaseClassification | undefined> {
183
+ if (!(await git_gh_command.pr_exists(branch_name))) return undefined
184
+ if (is_pr_state_merged(await get_pr_state_safe(branch_name))) return undefined
185
+
186
+ return await git_gh_command.pr_get_classification(branch_name)
187
+ }
188
+
189
+ async function create_for_issue(
190
+ issue_info: IssueInfo,
191
+ extra_body: string | undefined,
192
+ label: ReleaseClassification,
193
+ ): Promise<void> {
194
+ await create(
195
+ build_title(issue_info),
196
+ build_body(issue_info, extra_body),
197
+ issue_info.branch_name,
198
+ {
199
+ label,
200
+ should_replace_body: extra_body !== undefined,
201
+ },
202
+ )
203
+ }
204
+
169
205
  async function create_with_issue_info(issue_info: IssueInfo, extra_body?: string): Promise<void> {
170
- const title = build_title(issue_info)
171
- const body = build_body(issue_info, extra_body)
206
+ const existing_label = await existing_pr_label(issue_info.branch_name)
207
+
208
+ if (existing_label !== undefined) {
209
+ await create_for_issue(issue_info, extra_body, existing_label)
210
+
211
+ return
212
+ }
213
+
214
+ const issue_json = await git_gh_command.issue_view_json(issue_info.number, 'labels,body')
215
+
216
+ if (issue_json === undefined) {
217
+ throw new Error(`Could not read issue #${issue_info.number} for release classification`)
218
+ }
219
+
220
+ const label = pr_classification.select_issue_classification(issue_json)
172
221
 
173
- await create(title, body, issue_info.branch_name, extra_body !== undefined)
222
+ await create_for_issue(issue_info, extra_body, label)
174
223
  }
175
224
 
176
225
  const git_pr = {
@@ -2,6 +2,9 @@
2
2
  // labeler and the next-issues display all key on these exact strings, and a drifted copy would
3
3
  // fail silently — an epic filtered on the wrong name is simply never closed or never excluded.
4
4
  const EPIC_LABEL = 'epic'
5
+ const BUG_LABEL = 'bug'
6
+ const ENHANCEMENT_LABEL = 'enhancement'
7
+ const BREAKING_CHANGE_LABEL = 'breaking-change'
5
8
  const IN_PROGRESS_LABEL = 'in-progress'
6
9
  // Parks a child that cannot advance without a person deciding something. `epic:next` is what reads
7
10
  // it: a parked child is why a run reports "nothing left that time will fix" rather than waiting
@@ -237,6 +240,9 @@ const REVIEW_ROUND2_SKIPPED_LABEL = 'review-round2-skipped'
237
240
 
238
241
  const ALL_LABELS: ReadonlySet<string> = new Set([
239
242
  EPIC_LABEL,
243
+ BUG_LABEL,
244
+ ENHANCEMENT_LABEL,
245
+ BREAKING_CHANGE_LABEL,
240
246
  IN_PROGRESS_LABEL,
241
247
  NEEDS_DECISION_LABEL,
242
248
  AUTO_OK_LABEL,
@@ -256,12 +262,15 @@ export {
256
262
  ALL_LABELS,
257
263
  ALREADY_DONE_LABEL,
258
264
  AUTO_OK_LABEL,
265
+ BUG_LABEL,
266
+ BREAKING_CHANGE_LABEL,
259
267
  depth_label_of,
260
268
  DEPTH_0_LABEL,
261
269
  DEPTH_1_LABEL,
262
270
  DEPTH_2_LABEL,
263
271
  DEPTH_LABEL_ORDER,
264
272
  DEPTH_LABELS,
273
+ ENHANCEMENT_LABEL,
265
274
  EPIC_LABEL,
266
275
  FILING_ROUTE_LABELS,
267
276
  has_any_label,
@@ -20,8 +20,10 @@ const GITHUB_REPO_TREE_BASE = 'https://github.com/joshuafolkken/kit/tree/main/'
20
20
  // The consumer imports kit's published, already path-transformed rules. A tracked bootstrap note
21
21
  // remains readable before installation, while the rule body stays in the package (kit#1878).
22
22
  const CLAUDE_MD_IMPORT_LINE = '@node_modules/@joshuafolkken/kit/dist/CLAUDE.md'
23
- const CLAUDE_MD_BOOTSTRAP =
23
+ const OLD_CLAUDE_MD_BOOTSTRAP =
24
24
  '> Fresh checkout: if kit is not installed, run `pnpm install` first, then reread this file before doing any other work.'
25
+ const CLAUDE_MD_BOOTSTRAP =
26
+ '> Fresh checkout: before `pnpm install`, run `pnpm config get "@joshuafolkken:registry"` from the project root to check the effective registry, including user-level settings. If it points to GitHub Packages, [set up authentication](https://github.com/joshuafolkken/kit/blob/main/docs/authentication.md#1-get-a-token-from-the-gh-cli) first. Then run `pnpm install` and reread this file before doing any other work.'
25
27
 
26
28
  // A span containing `*` is excluded: it is a **glob**, not a reference to a file a consumer can
27
29
  // open. A distributed document that writes a directory set as `prompts/**` means "anything beneath
@@ -84,13 +86,15 @@ function transform_distributed_paths(content: string): string {
84
86
  function ensure_claude_md_import(existing: string | undefined): string {
85
87
  if (existing === undefined) return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n`
86
88
 
87
- if (existing.includes(CLAUDE_MD_IMPORT_LINE)) {
88
- if (existing.includes(CLAUDE_MD_BOOTSTRAP)) return existing
89
+ const updated = existing.replace(OLD_CLAUDE_MD_BOOTSTRAP, () => CLAUDE_MD_BOOTSTRAP)
90
+
91
+ if (updated.includes(CLAUDE_MD_IMPORT_LINE)) {
92
+ if (updated.includes(CLAUDE_MD_BOOTSTRAP)) return updated
89
93
 
90
- return `${CLAUDE_MD_BOOTSTRAP}\n\n${existing}`
94
+ return `${CLAUDE_MD_BOOTSTRAP}\n\n${updated}`
91
95
  }
92
96
 
93
- return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n\n${existing}`
97
+ return `${CLAUDE_MD_BOOTSTRAP}\n\n${CLAUDE_MD_IMPORT_LINE}\n\n${updated}`
94
98
  }
95
99
 
96
100
  const distributed_paths = {
@@ -10,6 +10,11 @@ interface HookResult {
10
10
  stderr: string
11
11
  }
12
12
 
13
+ interface CheckoutOptions {
14
+ git_overrides?: Record<string, string>
15
+ is_external_git?: boolean
16
+ }
17
+
13
18
  function create_partial_install(temporary_root: string): void {
14
19
  const package_root = path.join(temporary_root, 'node_modules/@joshuafolkken/kit')
15
20
  const hook_path = path.join(package_root, 'dist/hooks/pretool-guard.js')
@@ -19,25 +24,52 @@ function create_partial_install(temporary_root: string): void {
19
24
  writeFileSync(hook_path, "process.stdout.write('guard ran')")
20
25
  }
21
26
 
22
- function run_in_temporary_checkout(command: string, is_partial_install = false): HookResult {
23
- const temporary_root = mkdtempSync(path.join(tmpdir(), 'kit-hook-bootstrap-'))
27
+ function execution_directory(temporary_root: string, is_nested: boolean): string {
28
+ if (!is_nested) return temporary_root
29
+
30
+ const nested_root = path.join(temporary_root, 'src')
31
+
32
+ mkdirSync(nested_root)
33
+
34
+ return nested_root
35
+ }
36
+
37
+ function checkout_environment(temporary_root: string, options: CheckoutOptions): NodeJS.ProcessEnv {
38
+ const metadata_root = path.join(temporary_root, 'metadata')
24
39
  const git_environment = {
25
40
  ...process.env,
26
41
  ...git_location_environment.location_free_environment(),
27
42
  }
43
+ const git_arguments = options.is_external_git
44
+ ? ['init', '--bare', '-q', metadata_root]
45
+ : ['init', '-q']
46
+
47
+ spawnSync('git', git_arguments, { cwd: temporary_root, env: git_environment })
48
+
49
+ if (options.is_external_git) {
50
+ return { ...git_environment, GIT_DIR: metadata_root, GIT_WORK_TREE: temporary_root }
51
+ }
52
+
53
+ return { ...git_environment, ...options.git_overrides }
54
+ }
55
+
56
+ function run_in_temporary_checkout(
57
+ command: string,
58
+ is_partial_install = false,
59
+ is_nested = false,
60
+ options: CheckoutOptions = {},
61
+ ): HookResult {
62
+ const temporary_root = mkdtempSync(path.join(tmpdir(), 'kit-hook-bootstrap-'))
28
63
 
29
64
  try {
30
- spawnSync('git', ['init', '-q'], {
31
- cwd: temporary_root,
32
- env: git_environment,
33
- })
65
+ const hook_environment = checkout_environment(temporary_root, options)
34
66
 
35
67
  if (is_partial_install) create_partial_install(temporary_root)
36
68
 
37
69
  const { status, stdout, stderr } = spawnSync('sh', ['-c', command], {
38
- cwd: temporary_root,
70
+ cwd: execution_directory(temporary_root, is_nested),
39
71
  encoding: 'utf8',
40
- env: git_environment,
72
+ env: hook_environment,
41
73
  })
42
74
 
43
75
  return { status, stdout, stderr }