@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
@@ -12,16 +12,20 @@ import { did_refuse_self_run } from '#scripts/self-sync-guard/self-sync-refusal'
12
12
  import { sync } from '#scripts/sync/sync'
13
13
  import { package_manager_version } from '#scripts/version/package-manager-version'
14
14
  import { execaSync } from 'execa'
15
+ import { z } from 'zod'
15
16
  import { init_actions, PRETTIER_CONFIG_JS, type FileAction } from './init-actions'
16
17
  import { init_ai_copy } from './init-ai-copy'
17
18
  import { init_logic } from './init-logic'
18
19
  import { PACKAGE_DIR, PROJECT_ROOT } from './init-paths'
20
+ import { init_static } from './init-static'
19
21
  import { plugin_install_hint_module } from './plugin-install-hint'
22
+ import { project_profile, type ProjectShape } from './project-profile'
20
23
 
21
24
  const PACKAGE_JSON = 'package.json'
22
25
  const KIT_PACKAGE_NAME = '@joshuafolkken/kit'
23
26
  const LEFTHOOK_BIN = 'lefthook'
24
27
  const SAMPLE_INDENT_WIDTH = 4
28
+ const ARGUMENT_START_INDEX = 2
25
29
  const SAMPLE_INDENT = ' '.repeat(SAMPLE_INDENT_WIDTH)
26
30
 
27
31
  function write_new_file(action: FileAction, destination_path: string): void {
@@ -93,22 +97,48 @@ function get_kit_self_dependency(): Record<string, string> {
93
97
  return { [KIT_PACKAGE_NAME]: version }
94
98
  }
95
99
 
96
- function apply_dependency_merges(content: string): string {
100
+ function get_eslint_development_dependencies(): Record<string, string> {
101
+ const manifest = z
102
+ .object({
103
+ peerDependencies: z.record(z.string(), z.string()),
104
+ devDependencies: z.record(z.string(), z.string()),
105
+ })
106
+ .parse(init_actions.read_package_json(PACKAGE_JSON))
107
+ const entries = Object.keys(manifest.peerDependencies)
108
+ .filter((name) => name !== '@playwright/test')
109
+ .map((name): [string, string] => {
110
+ const version = manifest.devDependencies[name]
111
+ if (version === undefined) throw new Error(`Missing development version for ${name}`)
112
+
113
+ return [name, version]
114
+ })
115
+
116
+ return Object.fromEntries(entries)
117
+ }
118
+
119
+ function apply_dependency_merges(content: string, has_git = true): string {
97
120
  const migrated = init_logic.strip_managed_postinstall(content)
121
+ const suggested = init_logic.get_suggested_scripts_for_content(migrated)
98
122
  const merged = init_logic.merge_package_scripts(
99
123
  migrated,
100
- init_logic.get_suggested_scripts_for_content(migrated),
124
+ has_git
125
+ ? suggested
126
+ : Object.fromEntries(Object.entries(suggested).filter(([key]) => key !== 'prepare')),
101
127
  )
102
128
  const with_prettier = init_logic.merge_prettier_plugin_development_deps(merged)
103
- const with_secretlint = init_logic.merge_secretlint_development_deps(with_prettier)
129
+ const with_eslint = init_logic.merge_development_dependencies(
130
+ with_prettier,
131
+ get_eslint_development_dependencies(),
132
+ )
133
+ const with_secretlint = init_logic.merge_secretlint_development_deps(with_eslint)
104
134
 
105
135
  return init_logic.merge_development_dependencies(with_secretlint, get_kit_self_dependency())
106
136
  }
107
137
 
108
- function apply_package_json_merges(content: string): string {
109
- const with_kit = apply_dependency_merges(content)
110
- const upgraded = init_logic.upgrade_prepare_lefthook_warning(with_kit)
111
- const with_lifecycle = init_logic.merge_prepare_lifecycle_cmd(upgraded)
138
+ function apply_package_json_merges(content: string, has_git = true): string {
139
+ const with_kit = apply_dependency_merges(content, has_git)
140
+ const upgraded = has_git ? init_logic.upgrade_prepare_lefthook_warning(with_kit) : with_kit
141
+ const with_lifecycle = has_git ? init_logic.merge_prepare_lifecycle_cmd(upgraded) : upgraded
112
142
  const kit_pm = get_kit_package_manager()
113
143
  const with_pm =
114
144
  kit_pm === undefined ? with_lifecycle : init_logic.merge_package_manager(with_lifecycle, kit_pm)
@@ -121,21 +151,46 @@ function apply_package_json_merges(content: string): string {
121
151
  return package_manager_version.align_development_engines_version(sorted)
122
152
  }
123
153
 
124
- function merge_project_package_json(): void {
125
- const package_json_path = path.join(PROJECT_ROOT, PACKAGE_JSON)
126
- if (!existsSync(package_json_path)) return
154
+ function get_static_versions(): { kit: string; prettier: string } {
155
+ const manifest = z
156
+ .object({
157
+ version: z.string(),
158
+ devDependencies: z.record(z.string(), z.string()),
159
+ })
160
+ .parse(init_actions.read_package_json(PACKAGE_JSON))
161
+ const { prettier } = manifest.devDependencies
162
+ if (prettier === undefined) throw new Error('Missing prettier development version')
163
+
164
+ return { kit: manifest.version, prettier }
165
+ }
127
166
 
128
- const existing = readFileSync(package_json_path, 'utf8')
129
- const merged = apply_package_json_merges(existing)
167
+ function merged_manifest(existing: string, shape: ProjectShape): string {
168
+ if (shape.profile === 'static') {
169
+ return init_static.merge_static_manifest(existing, shape, get_static_versions())
170
+ }
130
171
 
131
- if (merged === existing) {
172
+ return init_static.with_recorded_profile(
173
+ apply_package_json_merges(existing, shape.has_git),
174
+ shape.profile,
175
+ )
176
+ }
177
+
178
+ function merge_project_package_json(shape: ProjectShape): void {
179
+ const package_json_path = path.join(PROJECT_ROOT, PACKAGE_JSON)
180
+ const is_existing = existsSync(package_json_path)
181
+ const existing = is_existing
182
+ ? readFileSync(package_json_path, 'utf8')
183
+ : init_static.initial_manifest()
184
+ const merged = merged_manifest(existing, shape)
185
+
186
+ if (is_existing && merged === existing) {
132
187
  console.info(' ✔ unchanged package.json')
133
188
 
134
189
  return
135
190
  }
136
191
 
137
192
  writeFileSync(package_json_path, merged)
138
- console.info(' ✔ updated package.json')
193
+ console.info(` ✔ ${is_existing ? 'updated' : 'created'} package.json`)
139
194
  }
140
195
 
141
196
  function install_lefthook(): void {
@@ -148,14 +203,17 @@ function install_lefthook(): void {
148
203
  }
149
204
  }
150
205
 
151
- function run_config_file_actions(): void {
206
+ function run_config_file_actions(shape: ProjectShape): void {
152
207
  console.info('Config files:')
153
208
 
154
- if (sync.migrate_prettierrc(path.join(PROJECT_ROOT, PRETTIER_CONFIG_JS))) {
209
+ if (
210
+ shape.profile === 'node' &&
211
+ sync.migrate_prettierrc(path.join(PROJECT_ROOT, PRETTIER_CONFIG_JS))
212
+ ) {
155
213
  console.info(' ✔ migrated .prettierrc → prettier.config.js')
156
214
  }
157
215
 
158
- for (const action of init_actions.build_file_actions()) {
216
+ for (const action of init_actions.build_file_actions(shape)) {
159
217
  execute_file_action(action)
160
218
  }
161
219
  }
@@ -179,36 +237,45 @@ function report_repository_settings(name_with_owner: string | undefined): void {
179
237
  // rather than through `sync`'s own `main()`, so the guard there never ran for it — and on top of the
180
238
  // 14 files #868 reproduced, `init` also rewrites `package.json` scripts and devDependencies
181
239
  // (joshuafolkken/kit#879). Checked before the first write, for the reason the sync guard is.
182
- function run_ai_file_actions(): void {
240
+ function run_ai_file_actions(shape: ProjectShape): void {
183
241
  console.info('\nAI files:')
184
242
  // `init` writes the same npm-disabling `.github/dependabot.yml` that `sync` distributes, so a
185
243
  // freshly scaffolded repository is exposed from its first commit — and a new private repository
186
244
  // is exactly where the setting is off by default. The name is the one the Sonar config already
187
245
  // resolved, so `gh repo view` runs once; the position is the pre-existing one.
188
- const name_with_owner = init_ai_copy.run_ai_copies()
246
+ const name_with_owner = init_ai_copy.run_ai_copies(shape)
189
247
 
190
- install_lefthook()
248
+ if (shape.has_git && shape.profile === 'node') install_lefthook()
191
249
 
192
- report_repository_settings(name_with_owner)
250
+ if (shape.has_github) report_repository_settings(name_with_owner)
193
251
 
194
- plugin_install_hint_module.report_plugin_install_hint()
252
+ if (shape.profile === 'node') plugin_install_hint_module.report_plugin_install_hint()
195
253
  }
196
254
 
197
- function main(): void {
198
- if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
199
-
255
+ function initialize_project(shape: ProjectShape): void {
200
256
  console.info('\n🚀 Initializing @joshuafolkken/kit\n')
201
- run_config_file_actions()
257
+ console.info(`profile: ${shape.profile} (${shape.reason})`)
258
+ run_config_file_actions(shape)
202
259
 
203
260
  console.info('\nPackage scripts:')
204
- merge_project_package_json()
261
+ merge_project_package_json(shape)
205
262
 
206
- run_ai_file_actions()
207
- project_config.sync_project_config(PROJECT_ROOT)
263
+ run_ai_file_actions(shape)
264
+ if (shape.profile === 'node') project_config.sync_project_config(PROJECT_ROOT)
208
265
  console.info('\n✅ Done.\n')
209
266
  }
210
267
 
211
- if (process.argv[1] === fileURLToPath(import.meta.url)) main()
268
+ function main(args: ReadonlyArray<string> = []): void {
269
+ if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
270
+ const requested = project_profile.requested_profile(args)
271
+ const shape = project_profile.inspect_project(PROJECT_ROOT, requested)
272
+
273
+ initialize_project(shape)
274
+ }
275
+
276
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
277
+ main(process.argv.slice(ARGUMENT_START_INDEX))
278
+ }
212
279
 
213
280
  const init = {
214
281
  copy_ai_file: init_ai_copy.copy_ai_file,
@@ -0,0 +1,14 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { project_profile } from './project-profile'
4
+
5
+ const ARGUMENT_START_INDEX = 2
6
+
7
+ function main(): void {
8
+ const requested = project_profile.requested_profile(process.argv.slice(ARGUMENT_START_INDEX))
9
+ const result = project_profile.resolve_profile(process.cwd(), requested)
10
+
11
+ console.info(`profile: ${result.profile} (${result.reason})`)
12
+ }
13
+
14
+ if (process.argv[1] === fileURLToPath(import.meta.url)) main()
@@ -0,0 +1,158 @@
1
+ import { existsSync, readdirSync, readFileSync, type Dirent } from 'node:fs'
2
+ import path from 'node:path'
3
+ import { repo_origin } from '#scripts/discovery/repo-origin'
4
+ import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
5
+ import { execaSync } from 'execa'
6
+
7
+ type ProjectProfile = 'static' | 'node'
8
+
9
+ interface ProfileResult {
10
+ profile: ProjectProfile
11
+ reason: string
12
+ }
13
+
14
+ interface ProjectShape extends ProfileResult {
15
+ has_web: boolean
16
+ has_typescript: boolean
17
+ has_git: boolean
18
+ has_github: boolean
19
+ }
20
+
21
+ const IGNORED_DIRECTORIES = new Set(['.git', 'node_modules', 'dist', 'build', '.svelte-kit'])
22
+ const WEB_EXTENSIONS = new Set(['.html', '.css', '.js', '.jsx', '.mjs', '.cjs'])
23
+ const TYPESCRIPT_EXTENSIONS = new Set(['.ts', '.tsx'])
24
+ const PROFILE_ARG_LENGTH = 2
25
+
26
+ function parse_profile(value: unknown): ProjectProfile | undefined {
27
+ if (value === 'static' || value === 'node') return value
28
+
29
+ return undefined
30
+ }
31
+
32
+ function requested_profile(args: ReadonlyArray<string>): ProjectProfile | undefined {
33
+ if (args.length === 0) return undefined
34
+
35
+ if (args.length !== PROFILE_ARG_LENGTH || args[0] !== '--profile') {
36
+ throw new Error('Usage: josh init [--profile static|node]')
37
+ }
38
+
39
+ const profile = parse_profile(args[1])
40
+ if (profile !== undefined) return profile
41
+ throw new Error('Profile must be static or node')
42
+ }
43
+
44
+ function read_manifest(root: string): Record<string, unknown> | undefined {
45
+ const filename = path.join(root, 'package.json')
46
+ if (!existsSync(filename)) return undefined
47
+ const parsed: unknown = JSON.parse(readFileSync(filename, 'utf8'))
48
+
49
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
50
+ throw new Error('package.json must contain an object')
51
+ }
52
+
53
+ return parsed as Record<string, unknown>
54
+ }
55
+
56
+ function is_record(value: unknown): value is Record<string, unknown> {
57
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
58
+ }
59
+
60
+ function recorded_profile(manifest: Record<string, unknown>): ProjectProfile | undefined {
61
+ const { josh } = manifest
62
+
63
+ return is_record(josh) ? parse_profile(josh['profile']) : undefined
64
+ }
65
+
66
+ function has_build_script(value: unknown): boolean {
67
+ return is_record(value) && ('build' in value || 'dev' in value)
68
+ }
69
+
70
+ // kit itself is not evidence of a Node toolchain: `pnpm add -D @joshuafolkken/kit` before
71
+ // `josh init` is the documented order, and it must still leave an `index.html` site static
72
+ // (joshuafolkken/kit#2693).
73
+ function has_project_dependencies(value: unknown): boolean {
74
+ if (!is_record(value)) return false
75
+
76
+ return Object.keys(value).some((name) => name !== KIT_PACKAGE_NAME)
77
+ }
78
+
79
+ function inferred_profile(manifest: Record<string, unknown>): ProfileResult {
80
+ if (
81
+ has_project_dependencies(manifest['dependencies']) ||
82
+ has_project_dependencies(manifest['devDependencies'])
83
+ ) {
84
+ return { profile: 'node', reason: 'package dependencies' }
85
+ }
86
+
87
+ if (has_build_script(manifest['scripts'])) {
88
+ return { profile: 'node', reason: 'build or dev script' }
89
+ }
90
+
91
+ return { profile: 'static', reason: 'metadata-only package.json' }
92
+ }
93
+
94
+ function resolve_profile(root: string, requested?: ProjectProfile): ProfileResult {
95
+ if (requested !== undefined) return { profile: requested, reason: 'explicit --profile' }
96
+ const manifest = read_manifest(root)
97
+ if (manifest === undefined) return { profile: 'static', reason: 'no package.json' }
98
+ const profile = recorded_profile(manifest)
99
+ if (profile !== undefined) return { profile, reason: 'package.json josh.profile' }
100
+
101
+ return inferred_profile(manifest)
102
+ }
103
+
104
+ function should_visit(entry: Dirent): boolean {
105
+ return entry.isDirectory() && !IGNORED_DIRECTORIES.has(entry.name)
106
+ }
107
+
108
+ function scan_directory(directory: string, pending: Array<string>): Array<string> {
109
+ const entries = readdirSync(directory, { withFileTypes: true })
110
+
111
+ pending.push(
112
+ ...entries
113
+ .filter((entry) => should_visit(entry))
114
+ .map((entry) => path.join(directory, entry.name)),
115
+ )
116
+
117
+ return entries.filter((entry) => entry.isFile()).map((entry) => path.extname(entry.name))
118
+ }
119
+
120
+ function file_extensions(root: string): Array<string> {
121
+ const pending = [root]
122
+ const extensions: Array<string> = []
123
+
124
+ for (const directory of pending) {
125
+ extensions.push(...scan_directory(directory, pending))
126
+ }
127
+
128
+ return extensions
129
+ }
130
+
131
+ function scan_files(root: string): { has_web: boolean; has_typescript: boolean } {
132
+ const extensions = file_extensions(root)
133
+ const has_web = extensions.some((extension) => WEB_EXTENSIONS.has(extension))
134
+ const has_typescript = extensions.some((extension) => TYPESCRIPT_EXTENSIONS.has(extension))
135
+
136
+ return { has_web, has_typescript }
137
+ }
138
+
139
+ function has_github_remote(root: string): boolean {
140
+ const result = execaSync('git', ['config', '--get', 'remote.origin.url'], {
141
+ cwd: root,
142
+ reject: false,
143
+ })
144
+
145
+ return result.exitCode === 0 && repo_origin.parse_origin_url(result.stdout) !== undefined
146
+ }
147
+
148
+ function inspect_project(root: string, requested?: ProjectProfile): ProjectShape {
149
+ const profile = resolve_profile(root, requested)
150
+ const files = scan_files(root)
151
+ const has_git = existsSync(path.join(root, '.git'))
152
+
153
+ return { ...profile, ...files, has_git, has_github: has_git && has_github_remote(root) }
154
+ }
155
+
156
+ const project_profile = { resolve_profile, inspect_project, requested_profile }
157
+ export { project_profile }
158
+ export type { ProjectProfile, ProjectShape }
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { main as init_main } from './init'
4
+ import { project_profile, type ProjectShape } from './project-profile'
5
+
6
+ const ARGUMENT_START_INDEX = 2
7
+
8
+ function prerequisite(shape: ProjectShape): string | undefined {
9
+ if (!shape.has_git) return 'josh start requires Git. Run git init, or use josh init without Git.'
10
+ if (!shape.has_github) return 'josh start requires a GitHub origin. Add one, or use josh init.'
11
+
12
+ return undefined
13
+ }
14
+
15
+ function main(args: ReadonlyArray<string> = []): void {
16
+ // --profile uses the same override as josh init.
17
+ const requested = project_profile.requested_profile(args)
18
+ const shape = project_profile.inspect_project(process.cwd(), requested)
19
+ const message = prerequisite(shape)
20
+ if (message !== undefined) throw new Error(message)
21
+ init_main(args)
22
+ console.info('GitHub workflow ready. Use kickoff in your assistant to plan an Issue.')
23
+ }
24
+
25
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
26
+ main(process.argv.slice(ARGUMENT_START_INDEX))
27
+ }
28
+
29
+ const start = { prerequisite }
30
+ export { start }
@@ -0,0 +1,13 @@
1
+ import { markdown_section } from './markdown-section'
2
+
3
+ const BUG_DECLARATION_LINE = '- 種別: 不具合'
4
+
5
+ function is_bug_fix(body: string): boolean {
6
+ const background = markdown_section.section_lines(body, '## 背景').join('\n')
7
+
8
+ return markdown_section.has_unfenced_line(background, BUG_DECLARATION_LINE)
9
+ }
10
+
11
+ const issue_bug_label = { BUG_DECLARATION_LINE, is_bug_fix }
12
+
13
+ export { issue_bug_label }
@@ -0,0 +1,74 @@
1
+ import {
2
+ BREAKING_CHANGE_LABEL,
3
+ BUG_LABEL,
4
+ ENHANCEMENT_LABEL,
5
+ has_label_name,
6
+ } from '#scripts/git/issue-labels'
7
+ import { issue_bug_label } from './issue-bug-label'
8
+ import { markdown_section } from './markdown-section'
9
+
10
+ const FEATURE_DECLARATIONS = ['- 目的: 機能追加', '- 目的: 機能改善']
11
+ const BREAKING_DECLARATION = '- 互換性: 破壊的変更'
12
+ const NON_BUG_DECLARATION = '- 種別: 非不具合'
13
+ const CLASSIFICATION_LABELS = [BUG_LABEL, ENHANCEMENT_LABEL, BREAKING_CHANGE_LABEL]
14
+ const CONFLICT = 'bug and enhancement declarations cannot be combined'
15
+ const MISSING_BUG_CLASSIFICATION = 'missing bug classification'
16
+ const CONFLICTING_BUG_CLASSIFICATIONS = 'conflicting bug classifications'
17
+
18
+ function required_labels(body: string): ReadonlyArray<string> {
19
+ const labels: Array<string> = []
20
+ const background = markdown_section.section_lines(body, '## 背景').join('\n')
21
+
22
+ if (issue_bug_label.is_bug_fix(body)) labels.push(BUG_LABEL)
23
+
24
+ if (FEATURE_DECLARATIONS.some((line) => markdown_section.has_unfenced_line(background, line))) {
25
+ labels.push(ENHANCEMENT_LABEL)
26
+ }
27
+
28
+ if (markdown_section.has_unfenced_line(background, BREAKING_DECLARATION)) {
29
+ labels.push(BREAKING_CHANGE_LABEL)
30
+ }
31
+
32
+ return labels
33
+ }
34
+
35
+ function labels_for(body: string, existing: ReadonlyArray<string>): ReadonlyArray<string> {
36
+ const missing = required_labels(body).filter((label) => !has_label_name(existing, label))
37
+
38
+ return [...existing, ...missing]
39
+ }
40
+
41
+ function bug_classification_problem(body: string): string | undefined {
42
+ const background = markdown_section.section_lines(body, '## 背景').join('\n')
43
+ const has_bug = issue_bug_label.is_bug_fix(body)
44
+ const has_non_bug = markdown_section.has_unfenced_line(background, NON_BUG_DECLARATION)
45
+
46
+ if (!has_bug && !has_non_bug) return MISSING_BUG_CLASSIFICATION
47
+ if (has_bug && has_non_bug) return CONFLICTING_BUG_CLASSIFICATIONS
48
+
49
+ return undefined
50
+ }
51
+
52
+ function problems(body: string): ReadonlyArray<string> {
53
+ const classification_problem = bug_classification_problem(body)
54
+ const found: Array<string> = []
55
+
56
+ if (classification_problem !== undefined) found.push(classification_problem)
57
+ const labels = required_labels(body)
58
+
59
+ if (labels.includes(BUG_LABEL) && labels.includes(ENHANCEMENT_LABEL)) found.push(CONFLICT)
60
+
61
+ return found
62
+ }
63
+
64
+ const issue_classification = {
65
+ BREAKING_DECLARATION,
66
+ CLASSIFICATION_LABELS,
67
+ FEATURE_DECLARATIONS,
68
+ NON_BUG_DECLARATION,
69
+ labels_for,
70
+ problems,
71
+ required_labels,
72
+ }
73
+
74
+ export { issue_classification }
@@ -2,6 +2,7 @@
2
2
  import { readFile } from 'node:fs/promises'
3
3
  import { fileURLToPath } from 'node:url'
4
4
  import { behavior_change_lint } from './behavior-change-lint'
5
+ import { issue_classification } from './issue-classification'
5
6
  import { issue_lint } from './issue-lint'
6
7
 
7
8
  // `josh issue:lint <path>` — read an Issue body from a file and print `ok`, or every template problem
@@ -13,6 +14,7 @@ import { issue_lint } from './issue-lint'
13
14
  const SUCCESS_EXIT_CODE = 0
14
15
  const FAILURE_EXIT_CODE = 1
15
16
  const OK_MESSAGE = 'ok'
17
+ const NO_LABELS = 'none'
16
18
  const USAGE = 'Usage: josh issue:lint <path-to-issue-body>'
17
19
 
18
20
  // Every problem a body has: the four base template headings it lacks, then the behavior-change
@@ -20,12 +22,17 @@ const USAGE = 'Usage: josh issue:lint <path-to-issue-body>'
20
22
  function problems(body: string): ReadonlyArray<string> {
21
23
  const missing = issue_lint.missing_headings(body).map((heading) => `missing heading: ${heading}`)
22
24
 
23
- return [...missing, ...behavior_change_lint.problems(body)]
25
+ return [
26
+ ...missing,
27
+ ...behavior_change_lint.problems(body),
28
+ ...issue_classification.problems(body),
29
+ ]
24
30
  }
25
31
 
26
- function report(found: ReadonlyArray<string>): number {
32
+ function report(found: ReadonlyArray<string>, body: string): number {
27
33
  if (found.length === 0) {
28
34
  console.info(OK_MESSAGE)
35
+ console.info(`labels: ${issue_classification.required_labels(body).join(', ') || NO_LABELS}`)
29
36
 
30
37
  return SUCCESS_EXIT_CODE
31
38
  }
@@ -44,7 +51,7 @@ async function run(body_path: string | undefined): Promise<number> {
44
51
 
45
52
  const body = await readFile(body_path, 'utf8')
46
53
 
47
- return report(problems(body))
54
+ return report(problems(body), body)
48
55
  }
49
56
 
50
57
  async function main(argv: ReadonlyArray<string>): Promise<void> {
@@ -15,8 +15,41 @@ function is_the_heading(line: string, heading: string): boolean {
15
15
  // Whether some body line, trimmed, is exactly `text`. Both linters ask this — of a `## heading` and of
16
16
  // the behavior-change declaration line — so the trim-and-match lives here rather than being written in
17
17
  // each. A required token mentioned inside a sentence is not the token, so only an exact line matches.
18
+ function next_fence(line: string, fence: string): string | undefined {
19
+ const marker = /^ {0,3}(`{3,}|~{3,})/u.exec(line)?.[1]
20
+
21
+ if (fence === '') return marker
22
+ const closing = new RegExp(
23
+ String.raw`^ {0,3}${fence.charAt(0)}{${String(fence.length)},}\s*$`,
24
+ 'u',
25
+ )
26
+
27
+ if (closing.test(line)) return ''
28
+
29
+ return undefined
30
+ }
31
+
32
+ function unfenced_lines(body: string): ReadonlyArray<string> {
33
+ let fence = ''
34
+ const visible: Array<string> = []
35
+
36
+ for (const line of body.split('\n')) {
37
+ const next = next_fence(line, fence)
38
+ const visible_line = line.replace(/^(?: {4}|\t).*/u, '')
39
+
40
+ visible.push(next === undefined && fence === '' ? visible_line : '')
41
+ fence = next ?? fence
42
+ }
43
+
44
+ return visible
45
+ }
46
+
47
+ function has_unfenced_line(body: string, text: string): boolean {
48
+ return unfenced_lines(body).some((line) => line.trim() === text)
49
+ }
50
+
18
51
  function has_line(body: string, text: string): boolean {
19
- return body.split('\n').some((line) => line.trim() === text)
52
+ return has_unfenced_line(body, text)
20
53
  }
21
54
 
22
55
  // The lines between `heading` and the next `## ` heading, trimmed of the heading line itself; an empty
@@ -24,16 +57,17 @@ function has_line(body: string, text: string): boolean {
24
57
  // only a line that is exactly the heading opens the section.
25
58
  function section_lines(body: string, heading: string): ReadonlyArray<string> {
26
59
  const lines = body.split('\n')
27
- const start = lines.findIndex((line) => is_the_heading(line, heading))
60
+ const visible = unfenced_lines(body)
61
+ const start = visible.findIndex((line) => is_the_heading(line, heading))
28
62
 
29
63
  if (start === -1) return []
30
64
 
31
65
  const rest = lines.slice(start + 1)
32
- const end = rest.findIndex((line) => is_heading(line))
66
+ const end = visible.slice(start + 1).findIndex((line) => is_heading(line))
33
67
 
34
68
  return end === -1 ? rest : rest.slice(0, end)
35
69
  }
36
70
 
37
- const markdown_section = { section_lines, is_heading, has_line }
71
+ const markdown_section = { section_lines, is_heading, has_line, has_unfenced_line }
38
72
 
39
73
  export { markdown_section }
@@ -77,10 +77,12 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
77
77
  "obf": "observations:flush",
78
78
  "ol": "oracle:list",
79
79
  "ov": "overrides",
80
+ "pf": "profile",
80
81
  "pg": "propagate",
81
82
  "pks": "pkg:scout",
82
83
  "pm": "prevent-main-commit",
83
84
  "ppu": "pre-push-unit",
85
+ "prc": "pr:classification",
84
86
  "pt": "port",
85
87
  "ptc": "pre-commit-type-check",
86
88
  "ptg": "pretool:guard",
@@ -131,6 +133,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
131
133
  "sl": "session:lang",
132
134
  "sp": "stash:pop",
133
135
  "ss": "secretlint-scan",
136
+ "st": "start",
134
137
  "sy": "sync",
135
138
  "sys": "sync:scope",
136
139
  "t": "test",
@@ -445,7 +448,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
445
448
  ],
446
449
  ],
447
450
  "init": [
448
- "",
451
+ "[--profile static|node]",
449
452
  "developer",
450
453
  [
451
454
  "files",
@@ -717,6 +720,13 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
717
720
  "network",
718
721
  ],
719
722
  ],
723
+ "pr:classification": [
724
+ "",
725
+ "automation",
726
+ [
727
+ "none",
728
+ ],
729
+ ],
720
730
  "pre-commit-type-check": [
721
731
  "",
722
732
  "automation",
@@ -745,6 +755,13 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
745
755
  "none",
746
756
  ],
747
757
  ],
758
+ "profile": [
759
+ "[--profile static|node]",
760
+ "developer",
761
+ [
762
+ "files",
763
+ ],
764
+ ],
748
765
  "propagate": [
749
766
  "[--dry-run] [--skip-publish-wait]",
750
767
  "maintainer",
@@ -1075,6 +1092,15 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
1075
1092
  "none",
1076
1093
  ],
1077
1094
  ],
1095
+ "start": [
1096
+ "[--profile static|node]",
1097
+ "developer",
1098
+ [
1099
+ "files",
1100
+ "git",
1101
+ "network",
1102
+ ],
1103
+ ],
1078
1104
  "stash:pop": [
1079
1105
  "<message>",
1080
1106
  "automation",