@joshuafolkken/kit 1.983.0 → 1.998.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 (94) hide show
  1. package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
  2. package/.claude/skills/workflow-commands/backlogrun-steps.md +1 -1
  3. package/.github/workflows/sonar-qube.yml +12 -2
  4. package/CLAUDE.md +2 -2
  5. package/README.md +30 -21
  6. package/dist/CLAUDE.md +2 -2
  7. package/dist/hooks/batch-guard.js +4 -4
  8. package/dist/hooks/{chunk-ZT65LR3U.js → chunk-4GDR2YTV.js} +4 -4
  9. package/dist/hooks/{chunk-ASUZGZFT.js → chunk-56LGMJJV.js} +2 -2
  10. package/dist/hooks/{chunk-LBXQHXMC.js → chunk-762BDXDU.js} +220 -68
  11. package/dist/hooks/{chunk-IM3KM25Y.js → chunk-7WAOP42A.js} +5 -3
  12. package/dist/hooks/{chunk-P3UX7SFN.js → chunk-A2H5I2VD.js} +2 -2
  13. package/dist/hooks/{chunk-ERXMJJOV.js → chunk-AWTFKKFW.js} +1 -1
  14. package/dist/hooks/{chunk-APVOACVT.js → chunk-GBJUERYK.js} +1 -1
  15. package/dist/hooks/{chunk-5JX6HXDX.js → chunk-KPZN42RT.js} +3 -3
  16. package/dist/hooks/{chunk-47SPMGOD.js → chunk-PAXMLGSS.js} +26 -7
  17. package/dist/hooks/{chunk-RFPHKU7C.js → chunk-WMIR5SNX.js} +3 -3
  18. package/dist/hooks/codex-hook-adapter.js +10 -10
  19. package/dist/hooks/duplicate-read-guard.js +5 -5
  20. package/dist/hooks/format-edited.js +2 -2
  21. package/dist/hooks/investigation-guard.js +5 -5
  22. package/dist/hooks/pretool-guard.js +9 -9
  23. package/dist/hooks/stop-guard.js +98 -26
  24. package/dist/josh.js +5 -5
  25. package/package.json +5 -1
  26. package/prettier/basic.d.ts +3 -0
  27. package/prettier/basic.js +10 -0
  28. package/prettier/index.js +2 -2
  29. package/prettier/static.js +3 -10
  30. package/prompts/sonar-hotspot-handling.md +6 -0
  31. package/prompts/testing-guide.md +1 -1
  32. package/scripts/agent/agent-diagnostics.ts +1 -0
  33. package/scripts/agent/agent-role-profile.ts +1 -1
  34. package/scripts/behavior/behavior-rules.ts +8 -1
  35. package/scripts/build/build-claude-md.ts +13 -4
  36. package/scripts/document/document-byte-budget.ts +1 -1
  37. package/scripts/gate/gate-steps.ts +1 -1
  38. package/scripts/gate/project-checks.ts +12 -11
  39. package/scripts/gate/type-check-command.ts +1 -1
  40. package/scripts/git/git-branch.ts +14 -3
  41. package/scripts/git/git-issue.ts +13 -5
  42. package/scripts/git/git-pr.ts +16 -10
  43. package/scripts/git/git-preflight.ts +138 -0
  44. package/scripts/git/telegram-notify.ts +21 -0
  45. package/scripts/hooks/stop-guard.ts +2 -0
  46. package/scripts/init/basic-path-migration.ts +24 -0
  47. package/scripts/init/distributed-paths.ts +22 -0
  48. package/scripts/init/init-actions.ts +27 -19
  49. package/scripts/init/init-ai-copy.ts +86 -28
  50. package/scripts/init/{init-static.ts → init-basic.ts} +6 -6
  51. package/scripts/init/init-bootstrap.ts +4 -3
  52. package/scripts/init/init-install.ts +1 -1
  53. package/scripts/init/init-logic-templates.ts +1 -1
  54. package/scripts/init/init-logic.ts +1 -1
  55. package/scripts/init/init.ts +41 -19
  56. package/scripts/init/kit-setup-state.ts +30 -0
  57. package/scripts/init/kit-written-paths.ts +33 -0
  58. package/scripts/init/project-profile-cli.ts +15 -1
  59. package/scripts/init/project-profile.ts +41 -12
  60. package/scripts/init/session-language-environment.ts +83 -0
  61. package/scripts/init/start-exec.ts +28 -0
  62. package/scripts/init/start-plan.ts +35 -13
  63. package/scripts/init/start-prompt.ts +22 -8
  64. package/scripts/init/start-setup-pr.ts +138 -0
  65. package/scripts/init/start-steps.ts +30 -30
  66. package/scripts/init/start.ts +7 -3
  67. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +3 -3
  68. package/scripts/josh/josh-commands-development.ts +3 -3
  69. package/scripts/josh/josh-commands-project.ts +2 -2
  70. package/scripts/lint/cspell-cached.ts +3 -3
  71. package/scripts/lint/format.ts +1 -1
  72. package/scripts/lint/lint-parallel.ts +2 -2
  73. package/scripts/rules/filing-cap.ts +1 -0
  74. package/scripts/rules/issue-citation.ts +30 -1
  75. package/scripts/rules/last-prompt.ts +79 -0
  76. package/scripts/rules/stop-rules.ts +5 -3
  77. package/scripts/safe-chain/ci-installer-pin.ts +19 -12
  78. package/scripts/safe-chain/project-config.ts +1 -1
  79. package/scripts/sync/sync-ai-files.ts +204 -0
  80. package/scripts/sync/sync.ts +48 -184
  81. package/scripts/test/josh-harness-environment.ts +1 -1
  82. package/scripts/test/pilot-files.ts +0 -1
  83. package/scripts/test/test-declared-changed.ts +1 -1
  84. package/scripts/test/test-declared-logic.ts +55 -14
  85. package/scripts/test/test-declared.ts +23 -11
  86. package/scripts/test/test-unit-guard.ts +6 -6
  87. package/scripts/time-runtime/time-shell.ts +36 -2
  88. package/scripts-ai/git-workflow.ts +10 -0
  89. package/templates/CLAUDE.basic.md +8 -0
  90. package/templates/workflows/ci.yml +27 -0
  91. /package/{templates/CLAUDE.static.md → dist/CLAUDE.basic.md} +0 -0
  92. /package/templates/{cursorrules.static → cursorrules.basic} +0 -0
  93. /package/templates/{pnpm-workspace.static.yaml → pnpm-workspace.basic.yaml} +0 -0
  94. /package/templates/{prettierignore.static → prettierignore.basic} +0 -0
@@ -1,12 +1,14 @@
1
1
  #!/usr/bin/env tsx
2
- import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
2
+ import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
3
3
  import path from 'node:path'
4
4
  import { fileURLToPath } from 'node:url'
5
+ import { project_checks } from '#scripts/gate/project-checks'
5
6
  import { gh_spawn } from '#scripts/gh/gh-spawn'
6
- import { transform_copied_content } from '#scripts/init/init-copy-content'
7
+ import { basic_path_migration } from '#scripts/init/basic-path-migration'
7
8
  import { init_logic } from '#scripts/init/init-logic'
8
9
  import { PACKAGE_DIR, PROJECT_ROOT } from '#scripts/init/init-paths'
9
10
  import { plugin_install_hint_module } from '#scripts/init/plugin-install-hint'
11
+ import { project_profile } from '#scripts/init/project-profile'
10
12
  import { auto_merge_setting } from '#scripts/repo/auto-merge-setting'
11
13
  import { repository_labels } from '#scripts/repo/repository-labels'
12
14
  import { project_config } from '#scripts/safe-chain/project-config'
@@ -14,121 +16,13 @@ import { security_updates } from '#scripts/security/security-updates'
14
16
  import { sonar_file } from '#scripts/security/sonar-file'
15
17
  import { did_refuse_self_run } from '#scripts/self-sync-guard/self-sync-refusal'
16
18
  import { package_manager_version } from '#scripts/version/package-manager-version'
17
- import { copy_directory_failure, directory_copy_blocker } from './directory-copy-guard'
18
19
  import { file_content } from './file-content'
19
- import { REMOVED_SKILL_MANIFEST } from './removed-skill-manifest'
20
- import { skill_migration, type MigrationResult } from './skill-migration'
20
+ import { sync_ai_files } from './sync-ai-files'
21
21
  import { sync_configs } from './sync-configs'
22
- import { sync_hook_safety } from './sync-hook-safety'
23
22
 
24
- const WORKSPACE_YAML = 'pnpm-workspace.yaml'
25
23
  const PACKAGE_JSON = 'package.json'
26
24
  const PACKAGE_JSON_UNCHANGED_MSG = ' ✔ unchanged package.json'
27
- const CLAUDE_MD_FILENAME = 'CLAUDE.md'
28
- const CLAUDE_SETTINGS_FILE = '.claude/settings.json'
29
- const CODEX_HOOKS_FILE = '.codex/hooks.json'
30
-
31
- function sync_ai_file(source_path: string, destination_path: string): boolean {
32
- const content = readFileSync(source_path, 'utf8')
33
-
34
- return file_content.write_text_if_changed(
35
- destination_path,
36
- transform_copied_content(destination_path, content),
37
- )
38
- }
39
-
40
- // Both copied hook files must run against the consumer's installed bundle. When sync is run from a
41
- // newer source than that install, writing either file would leave hooks
42
- // that fail every prompt (joshuafolkken/kit#1930); skip it and print how to update instead.
43
- function should_skip_hook_file(filename: string): boolean {
44
- if (filename !== CLAUDE_SETTINGS_FILE && filename !== CODEX_HOOKS_FILE) return false
45
- const warning = sync_hook_safety.hook_write_warning(
46
- PROJECT_ROOT,
47
- sync_hook_safety.read_version_at(path.join(PACKAGE_DIR, PACKAGE_JSON)),
48
- filename,
49
- )
50
- if (warning === undefined) return false
51
- console.warn(warning)
52
-
53
- return true
54
- }
55
-
56
- function sync_file(filename: string): void {
57
- if (should_skip_hook_file(filename)) return
58
- const did_change = sync_ai_file(
59
- path.join(PACKAGE_DIR, filename),
60
- path.join(PROJECT_ROOT, filename),
61
- )
62
-
63
- console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${filename}`)
64
- }
65
-
66
- function sync_file_mapping(source_path: string, destination_path: string): void {
67
- if (!existsSync(source_path)) {
68
- console.warn(` ⚠ skipped ${path.basename(destination_path)} (not found in package)`)
69
-
70
- return
71
- }
72
-
73
- // Routed through sync_ai_file rather than cpSync: templates/workflows/ci.yml is a mapped
74
- // file, so a byte copy would hand the consumer the template's own action pins. The pins
75
- // have to be resolved from .github/workflows at write time (joshuafolkken/kit#747).
76
- const did_change = sync_ai_file(source_path, destination_path)
77
-
78
- console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${path.basename(destination_path)}`)
79
- }
80
-
81
- function sync_workspace_yaml(
82
- template_path: string,
83
- destination_path: string,
84
- is_force = false,
85
- ): boolean {
86
- const template = readFileSync(template_path, 'utf8')
87
- const existing =
88
- !is_force && existsSync(destination_path) ? readFileSync(destination_path, 'utf8') : ''
89
- const merged = init_logic.merge_workspace_yaml(existing, template)
90
-
91
- return file_content.write_text_if_changed(destination_path, merged)
92
- }
93
-
94
- function sync_ai_copy_file(filename: string, is_force: boolean): void {
95
- if (filename === WORKSPACE_YAML) {
96
- const did_change = sync_workspace_yaml(
97
- path.join(PACKAGE_DIR, WORKSPACE_YAML),
98
- path.join(PROJECT_ROOT, WORKSPACE_YAML),
99
- is_force,
100
- )
101
-
102
- console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${WORKSPACE_YAML}`)
103
-
104
- return
105
- }
106
-
107
- sync_file(filename)
108
- }
109
-
110
- // The reason this directory was not synced, or nothing when it was. Both halves answer the same
111
- // question — what stops the copy — so the caller has one line to print either way.
112
- function directory_sync_failure(
113
- directory_name: string,
114
- on_copy: (did_change: boolean) => void,
115
- ): string | undefined {
116
- const source_path = path.join(PACKAGE_DIR, directory_name)
117
- const destination_path = path.join(PROJECT_ROOT, directory_name)
118
-
119
- return (
120
- directory_copy_blocker(source_path, destination_path) ??
121
- copy_directory_failure(source_path, destination_path, on_copy)
122
- )
123
- }
124
-
125
- function sync_directory(directory_name: string): void {
126
- const failure = directory_sync_failure(directory_name, (did_change) => {
127
- console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${directory_name}/`)
128
- })
129
-
130
- if (failure !== undefined) console.warn(` ⚠ skipped ${directory_name}/ (${failure})`)
131
- }
25
+ const BASIC_PRETTIER_CONFIG = 'prettier.config.mjs'
132
26
 
133
27
  function did_migrate_prettierrc(destination_path: string): boolean {
134
28
  const legacy_path = path.join(path.dirname(destination_path), '.prettierrc')
@@ -171,6 +65,20 @@ function sync_prettier_config(destination_path: string): void {
171
65
  write_merged_prettier_config(destination_path)
172
66
  }
173
67
 
68
+ // A basic project's prettier config written before joshuafolkken/kit#2829 still imports the static
69
+ // preset path; it is moved onto the basic one with the rule `josh init` applies.
70
+ function sync_basic_prettier_config(destination_path: string): void {
71
+ if (!existsSync(destination_path)) return
72
+
73
+ const existing = readFileSync(destination_path, 'utf8')
74
+ const did_change = file_content.write_text_if_changed(
75
+ destination_path,
76
+ basic_path_migration.migrate_basic_paths(existing),
77
+ )
78
+
79
+ console.info(` ✔ ${did_change ? 'synced ' : 'unchanged'} ${BASIC_PRETTIER_CONFIG}`)
80
+ }
81
+
174
82
  function sync_playwright_config(destination_path: string): void {
175
83
  if (!existsSync(destination_path)) return
176
84
 
@@ -220,70 +128,6 @@ function sync_sonar_with_template(name_with_owner: string | undefined, is_force
220
128
  console.info(` ✔ synced ${destination}`)
221
129
  }
222
130
 
223
- // A consumer's stale copy of a distributed skill is removed once it still matches the shipment and
224
- // kept with a warning once it does not (joshuafolkken/kit#1879). Plugin skills compare against the
225
- // package source; a retired skill compares against the frozen manifest (joshuafolkken/kit#1990). The
226
- // note distinguishes the two, and `absent` — the consumer never had the copy — prints nothing.
227
- function report_migration_result(result: MigrationResult): void {
228
- if (result.action === 'absent') return
229
-
230
- if (result.action === 'removed') {
231
- console.info(
232
- ` ✔ removed ${result.directory}/ (${skill_migration.removed_note(result.source)})`,
233
- )
234
-
235
- return
236
- }
237
-
238
- console.warn(` ⚠ kept ${result.directory}/ (${skill_migration.kept_note(result.source)})`)
239
- }
240
-
241
- function migrate_removed_skills(): void {
242
- const plugin = skill_migration.migrate_removed_skill_directories(PACKAGE_DIR, PROJECT_ROOT)
243
- const retired = skill_migration.migrate_manifest_skills(PROJECT_ROOT, REMOVED_SKILL_MANIFEST)
244
-
245
- for (const result of [...plugin, ...retired]) report_migration_result(result)
246
- }
247
-
248
- // CLAUDE.md is not byte-copied (joshuafolkken/kit#1878): a consumer's file is one @import of kit's
249
- // published rules plus the project's own additions below it. Ensure the import line is present
250
- // without ever disturbing those additions — so this ignores --force, which would otherwise mean
251
- // discarding a consumer's content.
252
- function sync_claude_md(destination_path: string): void {
253
- const existing = existsSync(destination_path) ? readFileSync(destination_path, 'utf8') : undefined
254
- const ensured = init_logic.ensure_claude_md_import(existing)
255
-
256
- if (ensured === existing) {
257
- console.info(` ✔ unchanged ${CLAUDE_MD_FILENAME}`)
258
-
259
- return
260
- }
261
-
262
- mkdirSync(path.dirname(destination_path), { recursive: true })
263
- writeFileSync(destination_path, ensured)
264
- console.info(` ✔ synced ${CLAUDE_MD_FILENAME}`)
265
- }
266
-
267
- function sync_ai_copy_all(is_force: boolean): void {
268
- console.info('AI files:')
269
-
270
- sync_claude_md(path.join(PROJECT_ROOT, CLAUDE_MD_FILENAME))
271
-
272
- for (const filename of init_logic.get_ai_copy_files()) {
273
- sync_ai_copy_file(filename, is_force)
274
- }
275
-
276
- for (const { src, dest } of init_logic.get_ai_copy_file_mappings()) {
277
- sync_file_mapping(path.join(PACKAGE_DIR, src), path.join(PROJECT_ROOT, dest))
278
- }
279
-
280
- for (const directory_name of init_logic.get_ai_copy_directories()) {
281
- sync_directory(directory_name)
282
- }
283
-
284
- migrate_removed_skills()
285
- }
286
-
287
131
  function sync_config_files(): void {
288
132
  sync_configs.sync_npmrc(path.join(PROJECT_ROOT, '.npmrc'))
289
133
  sync_configs.sync_gitignore(path.join(PROJECT_ROOT, '.gitignore'))
@@ -376,7 +220,7 @@ function report_repository_settings(name_with_owner: string | undefined): void {
376
220
  }
377
221
 
378
222
  function sync_project_artifacts(is_force: boolean): void {
379
- sync_ai_copy_all(is_force)
223
+ sync_ai_files.sync_ai_copy_all(is_force)
380
224
  sync_prettier_config(path.join(PROJECT_ROOT, 'prettier.config.js'))
381
225
  sync_playwright_config(path.join(PROJECT_ROOT, 'playwright.config.ts'))
382
226
  sync_deploy_vps(path.join(PROJECT_ROOT, '.github/workflows/deploy-vps.yml'))
@@ -394,29 +238,47 @@ function sync_project_artifacts(is_force: boolean): void {
394
238
  report_repository_settings(name_with_owner)
395
239
  }
396
240
 
241
+ // A basic project gets what `josh init` gives that profile and nothing more: its AI file set and the
242
+ // move of its prettier preset path. The full toolchain's configs, Sonar, package.json migrations and
243
+ // repository settings are never written into it (joshuafolkken/kit#2827).
244
+ function sync_basic_artifacts(is_force: boolean): void {
245
+ console.info('profile: basic (package.json josh.profile)\n')
246
+ sync_ai_files.sync_ai_copy_all(is_force, project_profile.inspect_project(PROJECT_ROOT, 'basic'))
247
+ sync_basic_prettier_config(path.join(PROJECT_ROOT, BASIC_PRETTIER_CONFIG))
248
+ }
249
+
250
+ function sync_full_artifacts(is_force: boolean): void {
251
+ sync_project_artifacts(is_force)
252
+ plugin_install_hint_module.report_plugin_install_hint()
253
+ }
254
+
397
255
  // Checked before anything is written, never per file: the damage is the whole run, and a partial
398
256
  // sync that stopped halfway would leave the source repository in a state neither `git checkout` nor
399
257
  // a re-run describes (joshuafolkken/kit#868).
258
+ //
259
+ // Only a recorded basic profile switches the file set; an unrecorded one stays full, as every
260
+ // project initialized before the profile was recorded was set up.
400
261
  function main(): void {
401
262
  if (did_refuse_self_run(PACKAGE_DIR, PROJECT_ROOT)) return
402
263
 
403
264
  const is_force = process.argv.includes('--force')
404
265
 
405
266
  console.info('\n🔄 Syncing @joshuafolkken/kit AI files\n')
406
- sync_project_artifacts(is_force)
407
- plugin_install_hint_module.report_plugin_install_hint()
267
+ if (project_checks.is_basic(PROJECT_ROOT)) sync_basic_artifacts(is_force)
268
+ else sync_full_artifacts(is_force)
408
269
  console.info('\n✅ Done.\n')
409
270
  }
410
271
 
411
272
  if (process.argv[1] === fileURLToPath(import.meta.url)) main()
412
273
 
413
274
  const sync = {
414
- should_skip_hook_file,
415
- sync_file_mapping,
416
- sync_ai_file,
417
- sync_workspace_yaml,
418
- sync_claude_md,
275
+ should_skip_hook_file: sync_ai_files.should_skip_hook_file,
276
+ sync_file_mapping: sync_ai_files.sync_file_mapping,
277
+ sync_ai_file: sync_ai_files.sync_ai_file,
278
+ sync_workspace_yaml: sync_ai_files.sync_workspace_yaml,
279
+ sync_claude_md: sync_ai_files.sync_claude_md,
419
280
  sync_prettier_config,
281
+ sync_basic_prettier_config,
420
282
  sync_playwright_config,
421
283
  sync_deploy_vps,
422
284
  sync_package_manager_version,
@@ -427,4 +289,6 @@ const sync = {
427
289
 
428
290
  // `sync` is the entry point; `sync_directory` and `main` are exported for their own unit tests, the
429
291
  // way `git-pr-checks.ts` exports the helpers beside its namespace.
292
+ const { sync_directory } = sync_ai_files
293
+
430
294
  export { main, sync, sync_directory }
@@ -85,7 +85,7 @@ type FixtureFile = readonly [string, string]
85
85
 
86
86
  const TOOLCHAIN_FILES: ReadonlyArray<FixtureFile> = [
87
87
  ['.gitignore', 'node_modules\n.*cache\n.tsbuildinfo\npnpm-lock.yaml\n'],
88
- ['.prettierignore', '*.json\npnpm-lock.yaml\n'],
88
+ ['.prettierignore', '*.json\npnpm-lock.yaml\n_tmp_*\n'],
89
89
  ['.prettierrc', json_file({ semi: false, singleQuote: true, useTabs: true })],
90
90
  [
91
91
  'cspell.config.yaml',
@@ -199,7 +199,6 @@ const PILOT_FILES: ReadonlyArray<string> = [
199
199
  'scripts/hooks/prompt-hooks.test.ts',
200
200
  'scripts/hooks/reserved-run.test.ts',
201
201
  'scripts/init/claude-plugin-config.test.ts',
202
- 'scripts/init/init-actions.test.ts',
203
202
  'scripts/init/init-copy-content.test.ts',
204
203
  'scripts/init/init-logic-deploy-vps.test.ts',
205
204
  'scripts/init/init-logic-development-engines.test.ts',
@@ -62,7 +62,7 @@ function source_paths(): ReadonlyArray<string> {
62
62
  // The verdict over the current working tree. `paths` stays injectable for a direct caller; when it is
63
63
  // omitted the injected set is read if one is in force, else the real tree.
64
64
  function current_verdict(paths: ReadonlyArray<string> = source_paths()): Verdict {
65
- return test_declared_logic.verdict_for(paths, project_checks.is_static(process.cwd()))
65
+ return test_declared_logic.verdict_for(paths, project_checks.is_basic(process.cwd()))
66
66
  }
67
67
 
68
68
  // Put the injection back to what it was before a `with_paths` span — cleared to the live reader when
@@ -14,7 +14,8 @@
14
14
  // a clone avoided. Here only paths that are mechanically certain to touch no runtime code are exempt —
15
15
  // the non-executable config and cosmetic-asset arms of `CLAUDE.md`'s "Non-runtime updates" exception
16
16
  // stay `required`, and a person declares that exception in the Step 0 work summary. Under-exempting
17
- // costs one declared exception; over-exempting ships an untested runtime change.
17
+ // costs one declared exception; over-exempting ships an untested runtime change. The one widening is
18
+ // the basic profile's manual check below, for files no runner kit has could test.
18
19
 
19
20
  type Verdict = 'required' | 'exempt' | 'satisfied'
20
21
 
@@ -27,7 +28,30 @@ const TEST_SUFFIXES: ReadonlyArray<string> = ['.e2e.ts', '.test.ts']
27
28
  const EXEMPT_PATHS: ReadonlyArray<string> = ['.editorconfig']
28
29
  const EXEMPT_PREFIXES: ReadonlyArray<string> = ['.idea/', '.vscode/', 'prompts/']
29
30
  const EXEMPT_SUFFIXES: ReadonlyArray<string> = ['.md']
30
- const STATIC_VISUAL_SUFFIXES: ReadonlyArray<string> = ['.html', '.css']
31
+ const BASIC_VISUAL_SUFFIXES: ReadonlyArray<string> = ['.html', '.css']
32
+
33
+ // What stays `required` under the basic profile (joshuafolkken/kit#2820): the languages kit's own
34
+ // runner can test, and the data and config formats a JS/TS test can load. Any other file — HTML/CSS, or
35
+ // the source of a project kit does not run, such as Lua — has no test kit could execute, so `required`
36
+ // there would be unsatisfiable; it is confirmed by hand instead. The kept side is enumerated rather than
37
+ // the manual one, because that side is closed.
38
+ const BASIC_REQUIRED_SUFFIXES: ReadonlyArray<string> = [
39
+ '.ts',
40
+ '.tsx',
41
+ '.mts',
42
+ '.cts',
43
+ '.js',
44
+ '.jsx',
45
+ '.mjs',
46
+ '.cjs',
47
+ '.svelte',
48
+ '.json',
49
+ '.jsonc',
50
+ '.json5',
51
+ '.yaml',
52
+ '.yml',
53
+ '.toml',
54
+ ]
31
55
 
32
56
  function normalize(paths: ReadonlyArray<string>): Array<string> {
33
57
  return paths.map((path) => path.trim()).filter((path) => path !== '')
@@ -37,12 +61,21 @@ function is_test_file(path: string): boolean {
37
61
  return TEST_SUFFIXES.some((suffix) => path.endsWith(suffix))
38
62
  }
39
63
 
40
- function is_static_visual(path: string, is_static: boolean): boolean {
41
- return is_static && STATIC_VISUAL_SUFFIXES.some((suffix) => path.endsWith(suffix))
64
+ // Matched case-insensitively, so `Main.TS` is not mistaken for a language kit cannot test.
65
+ function is_basic_manual(path: string, is_basic: boolean): boolean {
66
+ const lower = path.toLowerCase()
67
+
68
+ return is_basic && BASIC_REQUIRED_SUFFIXES.every((suffix) => !lower.endsWith(suffix))
69
+ }
70
+
71
+ function is_basic_visual(path: string): boolean {
72
+ const lower = path.toLowerCase()
73
+
74
+ return BASIC_VISUAL_SUFFIXES.some((suffix) => lower.endsWith(suffix))
42
75
  }
43
76
 
44
- function is_exempt(path: string, is_static = false): boolean {
45
- if (EXEMPT_PATHS.includes(path) || is_static_visual(path, is_static)) return true
77
+ function is_exempt(path: string, is_basic = false): boolean {
78
+ if (EXEMPT_PATHS.includes(path) || is_basic_manual(path, is_basic)) return true
46
79
 
47
80
  return (
48
81
  EXEMPT_SUFFIXES.some((suffix) => path.endsWith(suffix)) ||
@@ -52,31 +85,37 @@ function is_exempt(path: string, is_static = false): boolean {
52
85
 
53
86
  // A runtime file is one that is neither a test nor exempt. Its presence with no test file beside it is
54
87
  // the whole of what `required` reports.
55
- function is_runtime(path: string, is_static = false): boolean {
56
- return !is_test_file(path) && !is_exempt(path, is_static)
88
+ function is_runtime(path: string, is_basic = false): boolean {
89
+ return !is_test_file(path) && !is_exempt(path, is_basic)
57
90
  }
58
91
 
59
92
  // **A test file changed → `satisfied`, whatever else did.** A runtime file with no test beside it →
60
93
  // `required`. Everything left — an all-exempt change, and the empty diff — is `exempt`, because there
61
94
  // is no runtime file the change failed to test.
62
- function verdict_for(paths: ReadonlyArray<string>, is_static = false): Verdict {
95
+ function verdict_for(paths: ReadonlyArray<string>, is_basic = false): Verdict {
63
96
  const changed = normalize(paths)
64
97
 
65
98
  if (changed.some((path) => is_test_file(path))) return 'satisfied'
66
- if (changed.some((path) => is_runtime(path, is_static))) return 'required'
99
+ if (changed.some((path) => is_runtime(path, is_basic))) return 'required'
67
100
 
68
101
  return 'exempt'
69
102
  }
70
103
 
71
104
  // The runtime files with no test beside them — the `required` detail line, so the answer says which
72
105
  // files it is about rather than only that it refused.
73
- function runtime_files(paths: ReadonlyArray<string>, is_static = false): Array<string> {
74
- return normalize(paths).filter((path) => is_runtime(path, is_static))
106
+ function runtime_files(paths: ReadonlyArray<string>, is_basic = false): Array<string> {
107
+ return normalize(paths).filter((path) => is_runtime(path, is_basic))
75
108
  }
76
109
 
77
110
  // The paths that made the change exempt — the `exempt` detail line.
78
- function exempt_files(paths: ReadonlyArray<string>, is_static = false): Array<string> {
79
- return normalize(paths).filter((path) => is_exempt(path, is_static))
111
+ function exempt_files(paths: ReadonlyArray<string>, is_basic = false): Array<string> {
112
+ return normalize(paths).filter((path) => is_exempt(path, is_basic))
113
+ }
114
+
115
+ // The paths exempt only because the basic profile has no runner for them — the ones a person still has
116
+ // to confirm by hand, as opposed to docs and editor settings, which nobody checks.
117
+ function manual_check_files(paths: ReadonlyArray<string>, is_basic: boolean): Array<string> {
118
+ return normalize(paths).filter((path) => is_exempt(path, is_basic) && !is_exempt(path))
80
119
  }
81
120
 
82
121
  const test_declared_logic = {
@@ -85,9 +124,11 @@ const test_declared_logic = {
85
124
  EXEMPT_SUFFIXES,
86
125
  TEST_SUFFIXES,
87
126
  exempt_files,
127
+ is_basic_visual,
88
128
  is_exempt,
89
129
  is_runtime,
90
130
  is_test_file,
131
+ manual_check_files,
91
132
  runtime_files,
92
133
  verdict_for,
93
134
  }
@@ -76,36 +76,48 @@ function typed_runtime_file(path: string): string {
76
76
  return `${test_type_logic.test_type_for(path)} — ${path}`
77
77
  }
78
78
 
79
+ const BROWSER_CHECK = 'confirm the rendered page in a browser'
80
+ const RUN_CHECK = 'run the changed code by hand and report what you observed'
81
+
82
+ // The manual confirmation a basic-profile exemption owes, one instruction per kind of file it covers:
83
+ // HTML/CSS is looked at in a browser, a source kit cannot test is run by hand (joshuafolkken/kit#2820).
84
+ function manual_instruction(paths: ReadonlyArray<string>, is_basic: boolean): string {
85
+ const manual = test_declared_logic.manual_check_files(paths, is_basic)
86
+ const has_visual = manual.some((path) => test_declared_logic.is_basic_visual(path))
87
+ const has_source = manual.some((path) => !test_declared_logic.is_basic_visual(path))
88
+ const steps = [has_visual ? BROWSER_CHECK : '', has_source ? RUN_CHECK : ''].filter(Boolean)
89
+
90
+ return steps.length === 0 ? '' : ` — ${steps.join('; ')}`
91
+ }
92
+
79
93
  // The word is stdout so a caller can read it alone; the reason — which files, and why — is stderr.
80
- function exempt_detail(paths: ReadonlyArray<string>, is_static: boolean): string {
81
- const exempt = test_declared_logic.exempt_files(paths, is_static)
82
- const has_visual = is_static && exempt.some((path) => /\.(?:html|css)$/u.test(path))
83
- const instruction = has_visual ? ' — confirm the rendered page in a browser' : ''
94
+ function exempt_detail(paths: ReadonlyArray<string>, is_basic: boolean): string {
95
+ const exempt = test_declared_logic.exempt_files(paths, is_basic)
84
96
 
85
- return `exempt paths: ${exempt.join(', ')}${instruction}`
97
+ return `exempt paths: ${exempt.join(', ')}${manual_instruction(paths, is_basic)}`
86
98
  }
87
99
 
88
- function detail_for(verdict: Verdict, paths: ReadonlyArray<string>, is_static = false): string {
100
+ function detail_for(verdict: Verdict, paths: ReadonlyArray<string>, is_basic = false): string {
89
101
  if (verdict === 'required') {
90
102
  const typed = test_declared_logic
91
- .runtime_files(paths, is_static)
103
+ .runtime_files(paths, is_basic)
92
104
  .map((path) => typed_runtime_file(path))
93
105
 
94
106
  return `runtime files with no test: ${typed.join(', ')}`
95
107
  }
96
108
 
97
- if (verdict === 'exempt') return exempt_detail(paths, is_static)
109
+ if (verdict === 'exempt') return exempt_detail(paths, is_basic)
98
110
 
99
111
  return 'a test file changed'
100
112
  }
101
113
 
102
114
  function report(
103
115
  paths: ReadonlyArray<string>,
104
- is_static: boolean = project_checks.is_static(process.cwd()),
116
+ is_basic: boolean = project_checks.is_basic(process.cwd()),
105
117
  ): { detail: string; verdict: Verdict } {
106
- const verdict = test_declared_logic.verdict_for(paths, is_static)
118
+ const verdict = test_declared_logic.verdict_for(paths, is_basic)
107
119
 
108
- return { detail: detail_for(verdict, paths, is_static), verdict }
120
+ return { detail: detail_for(verdict, paths, is_basic), verdict }
109
121
  }
110
122
 
111
123
  function run(): void {
@@ -21,8 +21,8 @@ const NODE_MODULES = 'node_modules'
21
21
  // - **`vitest` absent is the young project.** `josh init` installs no vitest and writes no
22
22
  // `test:unit` script, so a freshly-bootstrapped project always lands here — which is the case the
23
23
  // skip was written for, and it keeps it.
24
- // - **`vitest` present is the node project's declaration that it runs unit tests.** Zero matching
25
- // files there is a broken state — a mis-scoped glob, or a deleted suite — and fails. A static
24
+ // - **`vitest` present is the full project's declaration that it runs unit tests.** Zero matching
25
+ // files there is a broken state — a mis-scoped glob, or a deleted suite — and fails. A basic
26
26
  // project may carry vitest as an unused dependency without declaring a unit suite; zero files
27
27
  // there are reported as skipped, never as a test pass.
28
28
  //
@@ -47,19 +47,19 @@ type GuardAction = 'run' | typeof SKIP_ACTION | typeof SKIP_NO_TESTS_ACTION | ty
47
47
  // body, and without this a gate that skipped the whole unit suite printed the same five lines as one
48
48
  // that ran it.
49
49
  const SKIP_REASON = 'vitest is not installed'
50
- const SKIP_NO_TESTS_REASON = 'no unit test files were found in this static project'
50
+ const SKIP_NO_TESTS_REASON = 'no unit test files were found in this basic project'
51
51
  const FAIL_REASON =
52
52
  'vitest is installed but no *.{test,spec}.{ts,js} test file was found — refusing to report a unit check that ran nothing'
53
53
 
54
54
  function resolve_guard_action(
55
55
  is_installed: boolean,
56
56
  has_tests: boolean,
57
- is_static = false,
57
+ is_basic = false,
58
58
  ): GuardAction {
59
59
  if (!is_installed) return SKIP_ACTION
60
60
  if (has_tests) return 'run'
61
61
 
62
- return is_static ? SKIP_NO_TESTS_ACTION : FAIL_ACTION
62
+ return is_basic ? SKIP_NO_TESTS_ACTION : FAIL_ACTION
63
63
  }
64
64
 
65
65
  function is_vitest_installed(project_directory: string): boolean {
@@ -160,7 +160,7 @@ async function run_guarded_vitest(
160
160
  const action = resolve_guard_action(
161
161
  is_installed,
162
162
  has_tests,
163
- project_checks.is_static(project_directory),
163
+ project_checks.is_basic(project_directory),
164
164
  )
165
165
 
166
166
  if (action !== 'run') return report_no_run(action, command_label)
@@ -45,7 +45,17 @@ const CHAIN_PATTERN = /&&|\|\||;/u
45
45
  // invisible here. Erring this way is the cheaper error for every caller: a missed reading costs
46
46
  // nothing beyond the reading, while a fragment read as a command answers about work the shell never
47
47
  // did, on the shape this repository writes constantly.
48
- const QUOTED_SPAN_PATTERN = /'[^']*'|"[^"]*"/gu
48
+ //
49
+ // **Escapes and heredoc bodies are text too** (joshuafolkken/kit#2841). A backslash outside single
50
+ // quotes makes the next character literal, so `it\'s` opens nothing and a `"` closes only at an
51
+ // unescaped `"`; inside `'…'` a backslash is an ordinary character, so `'C:\'` still closes. A heredoc
52
+ // body is not shell syntax at all — an `it's` there is prose — so it is removed before the quotes are
53
+ // paired, keeping the rest of its marker line (`cat <<EOF > f && git add f`) where the shell reads it.
54
+ // Without both, a stray quote paired with one much later and erased every command between them.
55
+ const QUOTED_SPAN_PATTERN = /\\[\s\S]|'[^']*'|"(?:\\[\s\S]|[^"\\])*"/gu
56
+ const HEREDOC_MARKER = /(?<!<)<<-?\s*(['"]?)(\w+)\1/u
57
+ const HEREDOC_DELIMITER_GROUP = 2
58
+ const LINE_BREAK = '\n'
49
59
  // `set -o pipefail` tells the shell to report the pipeline with the first failing command's status,
50
60
  // so nothing in it is discarded. The flag letters are loose because `set -eo pipefail` and
51
61
  // `set -euo pipefail` are the same instruction, and the `set` word is required so that a path or a
@@ -146,13 +156,37 @@ function leading_word(command: string): string {
146
156
  //
147
157
  // **A pipeline under `set -o pipefail` discards nothing**, so it is not this function's business —
148
158
  // answering otherwise would name the very form a caller is told to use instead.
159
+ // A line outside a heredoc body is kept with its marker blanked, and a marker opens a body whose
160
+ // delimiter is returned; inside one, every line is dropped through the line that is the delimiter alone.
161
+ function open_heredoc(line: string, kept: Array<string>): string | undefined {
162
+ kept.push(line.replace(HEREDOC_MARKER, ' '))
163
+
164
+ return HEREDOC_MARKER.exec(line)?.[HEREDOC_DELIMITER_GROUP]
165
+ }
166
+
167
+ function close_heredoc(line: string, delimiter: string): string | undefined {
168
+ return line.trim() === delimiter ? undefined : delimiter
169
+ }
170
+
171
+ function without_heredoc_bodies(command: string): string {
172
+ const kept: Array<string> = []
173
+ const body: { delimiter?: string | undefined } = {}
174
+
175
+ for (const line of command.split(LINE_BREAK)) {
176
+ body.delimiter =
177
+ body.delimiter === undefined ? open_heredoc(line, kept) : close_heredoc(line, body.delimiter)
178
+ }
179
+
180
+ return kept.join(LINE_BREAK)
181
+ }
182
+
149
183
  // Quoted text replaced by a space, which is what a reader walking into the middle of a chain has to
150
184
  // do before it can trust an operator it finds there. Exported because `time-writes.ts` needs the same
151
185
  // removal for a different reason — a quoted `sed` script holds `|` and slashes that read as a
152
186
  // pipeline and as paths — and two spellings of "remove the quotes" would disagree the first time one
153
187
  // of them learned about a quoting form the other did not.
154
188
  function unquoted(command: string): string {
155
- return command.replaceAll(QUOTED_SPAN_PATTERN, ' ')
189
+ return without_heredoc_bodies(command).replaceAll(QUOTED_SPAN_PATTERN, ' ')
156
190
  }
157
191
 
158
192
  function discarded_commands(command: string): Array<string> {
@@ -5,6 +5,7 @@ import { git_commit } from '../scripts/git/git-commit'
5
5
  import { git_error } from '../scripts/git/git-error'
6
6
  import { git_issue, type IssueInfo } from '../scripts/git/git-issue'
7
7
  import { git_pr } from '../scripts/git/git-pr'
8
+ import { git_preflight } from '../scripts/git/git-preflight'
8
9
  import { git_prompt, type WorkflowConfirmations } from '../scripts/git/git-prompt'
9
10
  import { git_push } from '../scripts/git/git-push'
10
11
  import { git_staging } from '../scripts/git/git-staging'
@@ -206,6 +207,14 @@ async function execute_workflow(
206
207
  const { cli_issue_input, is_auto_mode, commit_suffix } = parse_positionals(positionals)
207
208
  const is_non_interactive = is_non_interactive_mode(is_auto_mode, values)
208
209
 
210
+ // Unattended, nobody can fix a precondition between stages — so all are asked before staging.
211
+ if (is_non_interactive) {
212
+ await git_preflight.check({
213
+ cli_input: cli_issue_input,
214
+ will_open_pr: values['skip-pr'] !== true,
215
+ })
216
+ }
217
+
209
218
  await git_staging.check_and_confirm_staging(values.yes === true)
210
219
 
211
220
  const issue_info = await prepare_issue_info(cli_issue_input, is_non_interactive)
@@ -235,6 +244,7 @@ try {
235
244
  }
236
245
 
237
246
  const git_workflow = {
247
+ execute_workflow,
238
248
  parse_positionals,
239
249
  get_workflow_confirmations,
240
250
  prepare_issue_info,
@@ -0,0 +1,8 @@
1
+ # Project instructions
2
+
3
+ - 既存のファイルと利用者の設定を尊重し、変更する前に内容を確認する。
4
+ - 変更には検証を付け、実行した結果を報告する。
5
+ - HTML、CSS、JavaScript のファイルがある場合は Prettier で整形する。
6
+ - 画面に見える変更をした場合はブラウザーで表示と操作を確認する。
7
+ - JavaScript または TypeScript の動作を変更した場合は、その動作を確認する自動テストを追加する。
8
+ - Git や GitHub を使う場合だけ、それぞれのワークフローを利用する。