@joshuafolkken/kit 1.921.0 → 1.947.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 (97) hide show
  1. package/.claude/skills/workflow-commands/background-commands.md +6 -0
  2. package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
  3. package/.claude/skills/workflow-commands/backlogrun-lanes.md +4 -3
  4. package/.claude/skills/workflow-commands/backlogrun-progress.md +13 -12
  5. package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -1
  6. package/.claude/skills/workflow-commands/observation-filing.md +3 -3
  7. package/.claude/skills/workflow-commands/prerequisite.md +1 -1
  8. package/.github/workflows/ci.yml +26 -8
  9. package/.github/workflows/pr-classification.yml +76 -0
  10. package/README.md +47 -21
  11. package/dist/hooks/batch-guard.js +6 -6
  12. package/dist/hooks/{chunk-LC765SUA.js → chunk-3FWV6CFC.js} +119 -20
  13. package/dist/hooks/{chunk-UULARQ5Q.js → chunk-EYFJ74MJ.js} +13 -15
  14. package/dist/hooks/{chunk-O2FBZPSW.js → chunk-GIX5SAAB.js} +248 -231
  15. package/dist/hooks/{chunk-OLTAGGHH.js → chunk-IA7WC4NS.js} +1 -1
  16. package/dist/hooks/{chunk-UANRLZV7.js → chunk-IBFC25IX.js} +4 -4
  17. package/dist/hooks/{chunk-XMR3BS35.js → chunk-M4U4YNRE.js} +3 -3
  18. package/dist/hooks/{chunk-NWD47MW3.js → chunk-NKVLBB65.js} +3 -3
  19. package/dist/hooks/{chunk-OYU6KPRA.js → chunk-O3PEDF3M.js} +2 -2
  20. package/dist/hooks/{chunk-3HZ5MJIW.js → chunk-OM5VDM6F.js} +2 -2
  21. package/dist/hooks/{chunk-SSX23SJ2.js → chunk-RKUAD3I6.js} +1 -0
  22. package/dist/hooks/{chunk-77RLKZ5V.js → chunk-V7RH56HU.js} +2 -2
  23. package/dist/hooks/{chunk-FXAKJKTB.js → chunk-WPALEIRX.js} +1 -1
  24. package/dist/hooks/codex-hook-adapter.js +12 -12
  25. package/dist/hooks/duplicate-read-guard.js +7 -7
  26. package/dist/hooks/format-edited.js +3 -3
  27. package/dist/hooks/investigation-guard.js +7 -7
  28. package/dist/hooks/pretool-guard.js +11 -11
  29. package/dist/hooks/session-lang.js +2 -2
  30. package/dist/hooks/stop-guard.js +11 -5
  31. package/dist/josh.js +12 -14
  32. package/eslint/base.js +4 -1
  33. package/package.json +21 -6
  34. package/playwright.config.ts +9 -17
  35. package/ports/index.d.ts +1 -0
  36. package/ports/index.js +1 -0
  37. package/ports/web-server.d.ts +5 -0
  38. package/ports/web-server.js +73 -0
  39. package/prompts/collaboration-workflow/issue-template.md +1 -1
  40. package/scripts/agent/agent-role-profile.ts +11 -0
  41. package/scripts/ci/pr-classification.ts +27 -6
  42. package/scripts/document/document-byte-budget.ts +1 -1
  43. package/scripts/gate/hook-gate-reuse.ts +1 -1
  44. package/scripts/gate/project-checks.ts +60 -6
  45. package/scripts/gate/type-check-command.ts +43 -0
  46. package/scripts/git/constants.ts +4 -0
  47. package/scripts/git/git-command.ts +11 -5
  48. package/scripts/git/git-gh-pr-rollup.ts +56 -16
  49. package/scripts/git/git-staging.ts +6 -4
  50. package/scripts/git/git-stash.ts +73 -1
  51. package/scripts/git/stash-orphans.ts +24 -2
  52. package/scripts/git/stash-sweep-lock.ts +141 -0
  53. package/scripts/hooks/stop-guard.ts +2 -0
  54. package/scripts/init/init-actions.ts +3 -2
  55. package/scripts/init/init-ai-copy.ts +8 -5
  56. package/scripts/init/init-logic-migrate.ts +10 -1
  57. package/scripts/init/init-logic-templates.ts +27 -7
  58. package/scripts/init/init-logic-workspace.ts +89 -5
  59. package/scripts/init/init-logic.ts +7 -1
  60. package/scripts/init/init-static.ts +4 -1
  61. package/scripts/init/init.ts +60 -19
  62. package/scripts/init/project-profile.ts +14 -5
  63. package/scripts/issue/issue-merged.ts +75 -0
  64. package/scripts/issue/measure-rerun-cli.ts +2 -2
  65. package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +10 -0
  66. package/scripts/josh/josh-command-map.ts +1 -0
  67. package/scripts/josh/josh-command-types.ts +0 -3
  68. package/scripts/josh/josh-commands-ai.ts +6 -0
  69. package/scripts/josh/josh-commands-development.ts +6 -19
  70. package/scripts/lint/format.ts +68 -0
  71. package/scripts/lint/lint-parallel.ts +23 -36
  72. package/scripts/observations/observation-ledger-home.ts +22 -2
  73. package/scripts/observations/observation-ledger-line.ts +11 -0
  74. package/scripts/observations/observation-ledger-migrate.ts +146 -0
  75. package/scripts/observations/observation-ledger.ts +51 -3
  76. package/scripts/observations/observations-flush.ts +38 -7
  77. package/scripts/review/review-finding-ledger.ts +1 -1
  78. package/scripts/review/review-record-cli.ts +1 -7
  79. package/scripts/rules/delivered-rules.ts +10 -0
  80. package/scripts/rules/lane-background.ts +111 -0
  81. package/scripts/rules/stop-rules.ts +15 -2
  82. package/scripts/run/run-carry-flush.ts +2 -7
  83. package/scripts/run/run-hold-cli.ts +10 -2
  84. package/scripts/run/run-step-cli.ts +11 -1
  85. package/scripts/run/run-step.ts +36 -8
  86. package/scripts/run/run-tidy-cli.ts +50 -0
  87. package/scripts/run/run-tidy-lanes.ts +110 -0
  88. package/scripts/run/run-tidy-stashes.ts +160 -0
  89. package/scripts/run/run-tidy.ts +101 -0
  90. package/scripts/safe-chain/ci-installer-pin.ts +90 -0
  91. package/scripts/safe-chain/preinstall-command.ts +39 -0
  92. package/scripts/sync/sync.ts +5 -2
  93. package/scripts/test/pilot-files.ts +0 -1
  94. package/scripts/version/preinstall-version-update.ts +2 -0
  95. package/templates/pnpm-workspace.static.yaml +4 -0
  96. package/templates/workflows/ci.yml +21 -7
  97. package/tsconfig/base.json +4 -1
@@ -0,0 +1,73 @@
1
+ // The command Playwright's `webServer` runs, built so no package manager stands between Playwright
2
+ // and the server it starts.
3
+ //
4
+ // Playwright spawns the command detached and tears it down by signalling that process group. pnpm
5
+ // 11.27.1+ moves a script it runs without a controlling terminal into a group of its own, so the
6
+ // teardown reached pnpm alone and the dev / preview server survived holding Playwright's stdio pipes
7
+ // open — a hang #2386 patched by relying on pnpm to relay SIGTERM. `node --run` executes the same
8
+ // `package.json` script in the caller's process group, so the teardown signal reaches the server
9
+ // itself and there is no relay left to depend on (#2392).
10
+ //
11
+ // The script names stay the interface rather than a hard-coded `vite` invocation: a consumer's
12
+ // `preview` is not necessarily `vite preview` (wrangler serves the Cloudflare build), and each script
13
+ // resolves its own port through `josh port`. What `node --run` does not do is run `pre<name>` hooks,
14
+ // which a consumer can rely on — a `prepreview` applying local database migrations, say — so the hook
15
+ // is chained explicitly here instead of being dropped without a word. `post<name>` is not: a server
16
+ // script never ends on its own, so its post hook would never have run under pnpm either.
17
+ import { existsSync, readFileSync } from 'node:fs'
18
+ import path from 'node:path'
19
+ import { ports } from './index.js'
20
+
21
+ const PACKAGE_FILE_NAME = 'package.json'
22
+ const PRE_HOOK_PREFIX = 'pre'
23
+ const STEP_SEPARATOR = ' && '
24
+
25
+ /**
26
+ * The `scripts` of the project root's `package.json` — the same file `node --run` resolves from the
27
+ * working directory — or an empty record when there is none.
28
+ *
29
+ * @param {string} directory
30
+ * @returns {Record<string, unknown>}
31
+ */
32
+ function read_scripts(directory) {
33
+ const file = path.join(ports.resolve_project_directory(directory), PACKAGE_FILE_NAME)
34
+ if (!existsSync(file)) return {}
35
+
36
+ const { scripts } = JSON.parse(readFileSync(file, 'utf8'))
37
+
38
+ return typeof scripts === 'object' && scripts !== null ? scripts : {}
39
+ }
40
+
41
+ /**
42
+ * @param {string} name
43
+ * @param {Record<string, unknown>} scripts
44
+ * @returns {string[]}
45
+ */
46
+ function steps_for(name, scripts) {
47
+ const hook = `${PRE_HOOK_PREFIX}${name}`
48
+
49
+ return Object.hasOwn(scripts, hook) ? [hook, name] : [name]
50
+ }
51
+
52
+ /**
53
+ * Chain the named `package.json` scripts, each preceded by its `pre` hook when the project defines
54
+ * one, into a single shell command run through `node --run`.
55
+ *
56
+ * @param {string[]} names
57
+ * @param {string} [directory]
58
+ * @returns {string}
59
+ */
60
+ function script_command(names, directory = process.cwd()) {
61
+ const scripts = read_scripts(directory)
62
+
63
+ return names
64
+ .flatMap((name) => steps_for(name, scripts))
65
+ .map((step) => `node --run ${step}`)
66
+ .join(STEP_SEPARATOR)
67
+ }
68
+
69
+ const web_server = {
70
+ script_command,
71
+ }
72
+
73
+ export { web_server }
@@ -76,7 +76,7 @@ Issue には次の要素を必ず含める。
76
76
  - **互換性を壊す変更は影響を本文で宣言する。** 既存の利用者、API、設定の契約を変える場合は、`## 背景` の直下に `- 互換性: 破壊的変更` を書き、廃止対象と影響を説明する。移行措置や事前案内があっても破壊的変更なら宣言する。`pnpm josh issue:lint <本文ファイル>` が `labels: breaking-change` を返したら、同じ作成呼び出しに `-f 'labels[]=breaking-change'` を付ける。互換性を保つ変更には付けない。影響が曖昧なら起票文案で対象の利用者と契約を明確にしてから分類する
77
77
  - **`issue:lint` が返す分類ラベルをすべて起票時に指定する。** `bug`、`enhancement`、`breaking-change` は組み合わせて表示されることがある。`depth`・`route` など既存のラベルを残し、`labels: none` のときは分類ラベルを付けない。Issue が `enhancement` と `breaking-change` の両方を持つとき、PR のリリース分類は `breaking-change` 一つにする。Issue の目的の分類と PR のリリース分類を区別する
78
78
  - **`## 発火点`** には、その規則が破れる瞬間に最初に呼ばれるツールコール名を 1 つ書く。名前は `prompts/collaboration-workflow/rule-delivery.md` の配送表に載る、フックで配送可能なツール(`Bash` / `Edit` / `Read` / `Write` / `AskUserQuestion`)と突き合わせる。**表に無い名前**(そもそもツールコール名でない)と**不一致**(既知のツールだが配送できない)は区別して印字され、joshuafolkken/kit#2201 の「規則は正しいが名指ししたコールが実際に破れるコールの 1 つ後ろ」を起票時に検出する
79
- - **`## ベースライン`** は **「コマンド + 値」** で書く。``- `<測定コマンド>` → <現在の値>`` の形に限り、自然文だけの測定説明は受け付けない(再実行できないため)。マージ後に `pnpm josh measure:rerun` が同じコマンドをやり直し、**before / after の対**を印字する。値が動かなかったとき、反証された前提として `docs/observations.md` に 1 行追記される(既存の append-only 台帳をそのまま使い、第 2 の台帳は作らない)
79
+ - **`## ベースライン`** は **「コマンド + 値」** で書く。``- `<測定コマンド>` → <現在の値>`` の形に限り、自然文だけの測定説明は受け付けない(再実行できないため)。マージ後に `pnpm josh measure:rerun` が同じコマンドをやり直し、**before / after の対**を印字する。値が動かなかったとき、反証された前提として `docs/maintainers/observations.md` に 1 行追記される(既存の append-only 台帳を使い、別の台帳は作らない)
80
80
  - **`## 再現`** は **「コマンド + 実際の出力」** で書く。コマンドを backtick、出力をフェンス(``` または ~~~)で囲む。自然文の「確認した」は再実行できないため受け付けない(`## ベースライン` と同じ理由)。読みだけの起票を起票時に弾くためである
81
81
  - **検査は `pnpm josh issue:lint <path>` が行う。** 対象判定・3 見出しの有無・発火点の配送可否・ベースラインが「コマンド + 値」か・再現が「コマンド + 実際の出力」かと、起票時に必要な分類ラベルを、起票前の本文ファイルからまとめて答える(未リントの起票は `pnpm josh rule:guard` が拒否する)
82
82
 
@@ -201,6 +201,16 @@ function resolve_provider(environment: AgentEnvironment = process.env): Provider
201
201
  return detected_provider(environment) ?? handed_provider(environment)
202
202
  }
203
203
 
204
+ // Whether this session is woken when a background command it started completes
205
+ // (joshuafolkken/kit#2653). Claude Code re-invokes the session at the completion; a Codex session is not
206
+ // re-invoked, so a parent there can only wait by polling, each poll a model call over its whole context.
207
+ // Only a resolved Codex session answers no — an unresolved one keeps the behavior it had before.
208
+ function has_completion_callback(environment: AgentEnvironment = process.env): boolean {
209
+ const selected = resolve_provider(environment)
210
+
211
+ return selected.kind !== 'provider' || selected.provider !== OPENAI_PROVIDER
212
+ }
213
+
204
214
  // The mark a detached launch sets on its child: the provider this session resolved, or nothing when it
205
215
  // resolved none — the child then fails the same way this session would have.
206
216
  function handoff_environment(
@@ -309,6 +319,7 @@ const agent_role_profile = {
309
319
  WORKER,
310
320
  describe,
311
321
  handoff_environment,
322
+ has_completion_callback,
312
323
  is_safe_value,
313
324
  parse,
314
325
  phase_effort,
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from 'node:fs'
2
2
  import { fileURLToPath } from 'node:url'
3
+ import { execaSync } from 'execa'
3
4
  import { z } from 'zod'
4
5
 
5
6
  const BUGFIX = 'bugfix'
@@ -19,9 +20,10 @@ const LEGACY_LABELS: ReadonlySet<string> = new Set(['semver-major', 'semver-mino
19
20
 
20
21
  const LABEL = z.object({ name: z.string() })
21
22
  const USER = z.object({ login: z.string() })
22
- const PULL_REQUEST = z.object({ user: USER, labels: z.array(LABEL) })
23
+ const PULL_REQUEST = z.object({ number: z.number(), user: USER })
23
24
  const EVENT = z.object({
24
25
  pull_request: PULL_REQUEST,
26
+ repository: z.object({ full_name: z.string() }),
25
27
  })
26
28
 
27
29
  function classification_error(labels: ReadonlyArray<string>, author: string): string | undefined {
@@ -83,13 +85,32 @@ function select_issue_classification(issue_json: string): ReleaseClassification
83
85
  return label
84
86
  }
85
87
 
86
- function check_event(event_path: string): string | undefined {
88
+ // The labels as they stand when the check runs, not as the event carried them. `josh pr` opens the
89
+ // pull request and labels it in a second call, so the `opened` event carries none; judged from the
90
+ // payload, that run fails, and when it lands in the newer check suite GitHub keeps its red result —
91
+ // a rerun replays the same payload and fails again (joshuafolkken/kit#2712).
92
+ function fetch_current_labels(repository: string, pull_number: number): ReadonlyArray<string> {
93
+ const result = execaSync(
94
+ 'gh',
95
+ ['api', `repos/${repository}/issues/${String(pull_number)}/labels`, '--jq', '.[].name'],
96
+ { reject: false },
97
+ )
98
+
99
+ if (result.exitCode !== 0) {
100
+ throw new Error(`Could not read the pull request labels: ${result.stderr}`)
101
+ }
102
+
103
+ return result.stdout.split('\n').filter((name) => name !== '')
104
+ }
105
+
106
+ function check_event(
107
+ event_path: string,
108
+ read_labels: typeof fetch_current_labels = fetch_current_labels,
109
+ ): string | undefined {
87
110
  const event = EVENT.parse(JSON.parse(readFileSync(event_path, 'utf8')))
111
+ const labels = read_labels(event.repository.full_name, event.pull_request.number)
88
112
 
89
- return classification_error(
90
- event.pull_request.labels.map((label) => label.name),
91
- event.pull_request.user.login,
92
- )
113
+ return classification_error(labels, event.pull_request.user.login)
93
114
  }
94
115
 
95
116
  if (process.argv[1] === fileURLToPath(import.meta.url)) {
@@ -82,7 +82,7 @@ const DOCUMENT_BYTE_BUDGET: ReadonlyArray<DocumentBudget> = [
82
82
  { path: '.claude/skills/workflow-commands/rule-residency.md', bytes: 24_576 },
83
83
  { path: '.claude/skills/workflow-commands/target-repository.md', bytes: 8192 },
84
84
  { path: '.claude/skills/workflow-commands/working-tree-hold.md', bytes: 8192 },
85
- { path: 'docs/josh-commands.md', bytes: 163_840 },
85
+ { path: 'docs/josh-commands.md', bytes: 167_936 },
86
86
  { path: 'prompts/agent-rules.md', bytes: 4096 },
87
87
  { path: 'prompts/coding-standards.md', bytes: 16_384 },
88
88
  { path: 'prompts/collaboration-workflow.md', bytes: 8192 },
@@ -53,7 +53,7 @@ function is_force_requested(force_environment: string): boolean {
53
53
  // nothing" for "git could not be asked". Every predicate below treats the two differently.
54
54
  //
55
55
  // **The observation ledger is dropped from the reading, and it is the one line that may be**
56
- // (joshuafolkken/kit#1756). Since that issue `pnpm josh git` excludes `docs/observations.md` from
56
+ // (joshuafolkken/kit#1756). Since that issue `pnpm josh git` excludes the ledger from
57
57
  // what it stages, so a parent's appended line sits modified-but-never-staged for the whole interval
58
58
  // between the append and the next `pnpm josh observations:flush` — and every condition below reads a
59
59
  // non-empty status as "this operation carries a tree no check has read". Left in, one ledger line
@@ -2,6 +2,7 @@ import { existsSync, globSync, readFileSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import { find_local_bin_upwards } from '#scripts/build/local-bin'
4
4
  import { SKIP_MARKER } from '#scripts/test/skip-marker'
5
+ import { z } from 'zod'
5
6
 
6
7
  const PACKAGE_JSON = 'package.json'
7
8
  const IGNORED_DIRS = new Set([
@@ -36,6 +37,8 @@ const CSPELL_CONFIGS = [
36
37
  'cspell.yaml',
37
38
  ]
38
39
  const TYPE_CONFIGS = ['tsconfig.json', 'jsconfig.json']
40
+ const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies']
41
+ const object_schema = z.record(z.string(), z.unknown())
39
42
 
40
43
  function project_root(directory: string): string {
41
44
  let current = directory
@@ -78,28 +81,77 @@ function is_static_manifest(manifest: unknown): boolean {
78
81
  return josh.profile === 'static'
79
82
  }
80
83
 
81
- function is_static(directory: string): boolean {
84
+ function read_manifest(directory: string): unknown {
82
85
  try {
83
86
  const manifest_path = path.join(project_root(directory), PACKAGE_JSON)
84
- const manifest: unknown = JSON.parse(readFileSync(manifest_path, 'utf8'))
85
87
 
86
- return is_static_manifest(manifest)
88
+ return JSON.parse(readFileSync(manifest_path, 'utf8'))
87
89
  } catch {
88
- return false
90
+ return undefined
89
91
  }
90
92
  }
91
93
 
94
+ function is_static(directory: string): boolean {
95
+ return is_static_manifest(read_manifest(directory))
96
+ }
97
+
98
+ function is_declared(directory: string, package_name: string): boolean {
99
+ const manifest = object_schema.safeParse(read_manifest(directory))
100
+ if (!manifest.success) return false
101
+
102
+ return DEPENDENCY_FIELDS.some((field) => {
103
+ const dependencies = object_schema.safeParse(manifest.data[field])
104
+
105
+ return dependencies.success && Object.hasOwn(dependencies.data, package_name)
106
+ })
107
+ }
108
+
109
+ // A tool the manifest lists but `node_modules` lacks is a project that has not run `pnpm install`
110
+ // yet — typically right after `josh init` added it (joshuafolkken/kit#2709). Skipping it would pass a
111
+ // check the configuration asks for without running it, so only an undeclared tool is skipped; a
112
+ // declared one is run and fails until it is installed.
113
+ function missing_tool_reason(
114
+ directory: string,
115
+ package_name: string,
116
+ bin_name: string,
117
+ ): string | undefined {
118
+ if (has_bin(directory, bin_name) || is_declared(directory, package_name)) return undefined
119
+
120
+ return `${package_name} is not installed`
121
+ }
122
+
92
123
  function skip_notice(check: string, reason: string): string {
93
124
  return `josh ${check}: ${reason} ${SKIP_MARKER} ${check}.`
94
125
  }
95
126
 
127
+ // Shared by `josh check` and the gate's type-check step, so neither reaches `tsc` on a static
128
+ // project that has nothing to type-check (joshuafolkken/kit#2709).
96
129
  function type_check_skip_reason(directory: string): string | undefined {
97
130
  if (!is_static(directory)) return undefined
98
131
  if (!has_files(directory, TYPE_FILES)) return 'no TypeScript files were found'
99
132
  if (!has_config(directory, TYPE_CONFIGS)) return 'no TypeScript configuration was found'
100
- if (!has_bin(directory, 'tsc')) return 'typescript is not installed'
101
133
 
102
- return undefined
134
+ return missing_tool_reason(directory, 'typescript', 'tsc')
135
+ }
136
+
137
+ // Shared by `josh lint`, `josh lint:related` and `josh format`, so a static project without Web
138
+ // files — a Python or Rust project, which `josh init` gives no Prettier — is skipped for the same
139
+ // reason by all three (joshuafolkken/kit#2606, joshuafolkken/kit#2709).
140
+ function prettier_skip_reason(directory: string): string | undefined {
141
+ if (!is_static(directory)) return undefined
142
+ if (!has_files(directory, WEB_FILES)) return 'no HTML, CSS or JavaScript files were found'
143
+
144
+ return missing_tool_reason(directory, 'prettier', 'prettier')
145
+ }
146
+
147
+ // Shared by `josh lint`, `josh lint:related` and `josh format`, so a static project without ESLint
148
+ // is skipped for the same reason by all three (joshuafolkken/kit#2693, joshuafolkken/kit#2709).
149
+ function eslint_skip_reason(directory: string): string | undefined {
150
+ if (!is_static(directory)) return undefined
151
+ if (!has_files(directory, SCRIPT_FILES)) return 'no JavaScript or TypeScript files were found'
152
+ if (!has_config(directory, ESLINT_CONFIGS)) return 'no ESLint configuration was found'
153
+
154
+ return missing_tool_reason(directory, 'eslint', 'eslint')
103
155
  }
104
156
 
105
157
  const project_checks = {
@@ -109,10 +161,12 @@ const project_checks = {
109
161
  TYPE_CONFIGS,
110
162
  TYPE_FILES,
111
163
  WEB_FILES,
164
+ eslint_skip_reason,
112
165
  has_bin,
113
166
  has_config,
114
167
  has_files,
115
168
  is_static,
169
+ prettier_skip_reason,
116
170
  project_root,
117
171
  skip_notice,
118
172
  type_check_skip_reason,
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env tsx
2
+ import { fileURLToPath } from 'node:url'
3
+ import { TS_CACHE_FLAGS } from '#scripts/josh/josh-command-types'
4
+ import { execa } from 'execa'
5
+ import { project_checks } from './project-checks'
6
+
7
+ // `josh check` — the project-wide TypeScript check. It used to be a bare `tsc --noEmit` shell entry,
8
+ // so on a static project with nothing to type-check it failed with `Command "tsc" not found` while
9
+ // the gate, which asks `type_check_skip_reason` first, skipped the same step
10
+ // (joshuafolkken/kit#2709). Both now read that one decision.
11
+
12
+ const ARGV_OFFSET = 2
13
+ const FAIL_EXIT_CODE = 1
14
+ const TSC_ARGS = ['exec', 'tsc', '--noEmit', ...TS_CACHE_FLAGS] as const
15
+
16
+ async function run_type_check(
17
+ command_arguments: ReadonlyArray<string>,
18
+ directory: string,
19
+ ): Promise<number> {
20
+ const reason = project_checks.type_check_skip_reason(directory)
21
+
22
+ if (reason !== undefined) {
23
+ console.info(project_checks.skip_notice('check', reason))
24
+
25
+ return 0
26
+ }
27
+
28
+ const result = await execa('pnpm', [...TSC_ARGS, ...command_arguments], {
29
+ cwd: directory,
30
+ stdio: 'inherit',
31
+ reject: false,
32
+ })
33
+
34
+ return result.exitCode ?? FAIL_EXIT_CODE
35
+ }
36
+
37
+ if (process.argv[1] === fileURLToPath(import.meta.url)) {
38
+ process.exitCode = await run_type_check(process.argv.slice(ARGV_OFFSET), process.cwd())
39
+ }
40
+
41
+ const type_check_command = { run_type_check }
42
+
43
+ export { TSC_ARGS, type_check_command }
@@ -9,6 +9,9 @@ const GIT_COMMAND_UNIX = '/usr/bin/git'
9
9
  // here rather than beside either of them because `status` and `worktree_list` now sit in different
10
10
  // modules (joshuafolkken/kit#1640) and a copy in each is a second answer to one question.
11
11
  const PORCELAIN_FLAG = '--porcelain'
12
+ // The untracked-files mode every porcelain status reading passes (`git-command.ts` carries why it is
13
+ // passed and why it is `all`), shared so `run-carry-flush.ts`'s own reading cannot drift from it.
14
+ const UNTRACKED_FILES_FLAG = '--untracked-files=all'
12
15
 
13
16
  function get_git_command(): string {
14
17
  if (platform() === 'win32') {
@@ -37,5 +40,6 @@ export {
37
40
  UNTRACKED_FILE_PREFIX,
38
41
  SEPARATOR_LINE,
39
42
  PORCELAIN_FLAG,
43
+ UNTRACKED_FILES_FLAG,
40
44
  git_utilities,
41
45
  }
@@ -1,4 +1,4 @@
1
- import { PORCELAIN_FLAG } from './constants'
1
+ import { PORCELAIN_FLAG, UNTRACKED_FILES_FLAG } from './constants'
2
2
  import { git_push_transport } from './git-push-transport'
3
3
  import { git_spawn } from './git-spawn'
4
4
 
@@ -6,15 +6,21 @@ async function branch(): Promise<string> {
6
6
  return await git_spawn.read(['rev-parse', '--abbrev-ref', 'HEAD'])
7
7
  }
8
8
 
9
- // **`--untracked-files=normal` is passed rather than inherited** (joshuafolkken/kit#1381). Every
9
+ // **The untracked-files mode is passed rather than inherited** (joshuafolkken/kit#1381). Every
10
10
  // reader of this output depends on the `??` lines being there: `git-staging.ts` stages exactly those,
11
11
  // and `hook-gate-reuse.ts` reads an empty output as "this push carries the recorded tree". A person
12
12
  // with `status.showUntrackedFiles=no` in their git config — a common setting on large repositories —
13
13
  // gets porcelain output with those lines silently absent, so untracked files go unstaged and the
14
- // pre-push hook reuses a record for a commit that does not contain them. Naming git's own default
15
- // makes the reading answer to this codebase rather than to whoever ran it.
14
+ // pre-push hook reuses a record for a commit that does not contain them. Naming the mode makes the
15
+ // reading answer to this codebase rather than to whoever ran it.
16
+ //
17
+ // **`all` rather than `normal`, so a new file is named even inside an untracked directory**
18
+ // (joshuafolkken/kit#2724). `normal` collapses it to `?? docs/maintainers/`, and every reader that
19
+ // recognizes the observation ledger by its path then misses it: `git-staging.ts` stages the directory
20
+ // — ledger included — into an unrelated pull request, and the flush refuses the directory as "other
21
+ // changes".
16
22
  async function status(): Promise<string> {
17
- return await git_spawn.read(['status', PORCELAIN_FLAG, '--untracked-files=normal'])
23
+ return await git_spawn.read(['status', PORCELAIN_FLAG, UNTRACKED_FILES_FLAG])
18
24
  }
19
25
 
20
26
  // The absolute path every other git command's output is relative to. Asking git rather than reading
@@ -29,7 +29,9 @@ 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
+ // Both `app` and `check_suite` are objects carrying a numeric `id`, and so is a check run itself.
34
+ const id_holder_schema = z.object({ id: z.number() })
33
35
 
34
36
  const status_page_schema = z.looseObject({
35
37
  statuses: z.array(rollup_element_schema),
@@ -51,37 +53,75 @@ function read_pages<T>(raw_json: string, schema: z.ZodType<T>, message: string):
51
53
  return pages
52
54
  }
53
55
 
56
+ const NO_SUITE = -1
57
+
58
+ interface RunRank {
59
+ suite: number
60
+ run: number
61
+ }
62
+
63
+ function read_id(value: unknown): number | undefined {
64
+ const parsed = id_holder_schema.safeParse(value)
65
+
66
+ return parsed.success ? parsed.data.id : undefined
67
+ }
68
+
54
69
  function check_run_group_key(run: RollupElement): string | undefined {
55
- const { name, app: raw_app } = run
70
+ const { name, app } = run
56
71
  if (typeof name !== 'string') return undefined
57
- const app = check_run_app_schema.safeParse(raw_app)
58
72
 
59
- return JSON.stringify([name, app.success ? app.data.id : undefined])
73
+ return JSON.stringify([name, read_id(app)])
74
+ }
75
+
76
+ function read_rank(run: RollupElement): RunRank | undefined {
77
+ const id = read_id(run)
78
+ if (id === undefined) return undefined
79
+
80
+ return { suite: read_id(run['check_suite']) ?? NO_SUITE, run: id }
81
+ }
82
+
83
+ // **The newest check suite decides a name, not the newest check run** — the rule GitHub's merge box
84
+ // applies. The endpoint's default `filter=latest` already keeps only the newest run of each name
85
+ // *within* a suite (a re-run job lands in the suite it re-runs), so what is left is one run per
86
+ // suite. Across suites GitHub takes the one from the newer suite: PR #2717's cancelled suite was the
87
+ // older one and GitHub read the pull request `CLEAN`, while PR #2698's cancelled suite was the newer
88
+ // one and GitHub held it `BLOCKED`. Ranking by run id alone (joshuafolkken/kit#2644) picked #2698's
89
+ // older, successful suite — its jobs had queued later — so `followup` read green beside a `BLOCKED`
90
+ // pull request and waited out its whole budget (joshuafolkken/kit#2700).
91
+ function is_ranked_above(candidate: RunRank, current: RunRank): boolean {
92
+ if (candidate.suite !== current.suite) return candidate.suite > current.suite
93
+
94
+ return candidate.run > current.run
95
+ }
96
+
97
+ function should_replace(previous: RollupElement | undefined, rank: RunRank): boolean {
98
+ const previous_rank = previous === undefined ? undefined : read_rank(previous)
99
+
100
+ return previous_rank === undefined || is_ranked_above(rank, previous_rank)
60
101
  }
61
102
 
62
- function remember_newest_run(run: RollupElement, latest_ids: Map<string, number>): void {
103
+ function remember_newest_run(run: RollupElement, newest: Map<string, RollupElement>): void {
63
104
  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)
105
+ const rank = read_rank(run)
106
+ if (key === undefined || rank === undefined) return
107
+ if (should_replace(newest.get(key), rank)) newest.set(key, run)
68
108
  }
69
109
 
70
- function is_newest_run(run: RollupElement, latest_ids: Map<string, number>): boolean {
110
+ // A run with no name or id cannot be ranked, so it is kept rather than guessed to be superseded.
111
+ function is_newest_run(run: RollupElement, newest: Map<string, RollupElement>): boolean {
71
112
  const key = check_run_group_key(run)
72
- const { id } = run
73
- if (key === undefined || typeof id !== 'number') return true
113
+ if (key === undefined || read_rank(run) === undefined) return true
74
114
 
75
- return id === latest_ids.get(key)
115
+ return newest.get(key) === run
76
116
  }
77
117
 
78
118
  function to_check_run_items(check_runs_json: string): Array<RollupElement> {
79
119
  const pages = read_pages(check_runs_json, check_runs_page_schema, NOT_A_CHECK_RUNS_LISTING)
80
120
  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)
121
+ const newest = new Map<string, RollupElement>()
122
+ for (const run of runs) remember_newest_run(run, newest)
83
123
 
84
- return runs.filter((run) => is_newest_run(run, latest_ids))
124
+ return runs.filter((run) => is_newest_run(run, newest))
85
125
  }
86
126
 
87
127
  function to_status_context_items(status_json: string): Array<RollupElement> {
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  observation_ledger,
3
- OBSERVATION_LEDGER_PATH,
3
+ OBSERVATION_LEDGER_PATHS,
4
4
  } from '#scripts/observations/observation-ledger'
5
5
  import { git_command } from './git-command'
6
6
  import { git_prompt } from './git-prompt'
@@ -73,10 +73,12 @@ async function stage_untracked_files(files: ReadonlyArray<string>): Promise<void
73
73
  // entirely — a diff a reviewer has no reason to question. Excluding it here covers every entry
74
74
  // point, because this is the one staging step all of them go through;
75
75
  // `pnpm josh observations:flush` is what commits it, on a branch of its own.
76
- const PATHS_EXCLUDED_FROM_STAGING: ReadonlyArray<string> = [OBSERVATION_LEDGER_PATH]
76
+ // Both of its paths are excluded (joshuafolkken/kit#2724): a run on the old code still appends at
77
+ // the old one until `observation-ledger-migrate.ts` moves it.
78
+ const PATHS_EXCLUDED_FROM_STAGING: ReadonlyArray<string> = OBSERVATION_LEDGER_PATHS
77
79
 
78
80
  function is_stageable(file_path: string): boolean {
79
- return !PATHS_EXCLUDED_FROM_STAGING.includes(file_path)
81
+ return !observation_ledger.is_ledger_path(file_path)
80
82
  }
81
83
 
82
84
  // **Said out loud, because the alternative is an unexplained failure.** With the ledger the only
@@ -84,7 +86,7 @@ function is_stageable(file_path: string): boolean {
84
86
  // step dies on git's empty index with `Failed to commit changes` — a message naming nothing that
85
87
  // caused it. This line is what turns that into a diagnosis.
86
88
  //
87
- // **The paths are parsed rather than matched as substrings**: `docs/observations.md.bak` contains the
89
+ // **The paths are parsed rather than matched as substrings**: `docs/maintainers/observations.md.bak` contains the
88
90
  // ledger's path, and a hint naming a file the exclusion never touched is a hint that teaches the
89
91
  // reader to ignore it.
90
92
  function report_excluded_paths(status_output: string): void {
@@ -14,6 +14,12 @@ const FIELD_SEPARATOR = '\u{0}'
14
14
  // stash pushed with `-m`); `%x00` emits the NUL the fields are split on.
15
15
  const LIST_FORMAT = '--format=%gd%x00%gs'
16
16
  const SUBJECT_MESSAGE_SEPARATOR = ': '
17
+ // `%H` is the entry's commit hash; the second format pairs it with the position a drop needs.
18
+ const HASH_FORMAT = '--format=%H%x00%gs'
19
+ const HASH_POSITION_FORMAT = '--format=%H%x00%gd'
20
+ const NAME_ONLY_FLAG = '--name-only'
21
+ const ADDED_PREFIX = '+'
22
+ const FILE_HEADER_PREFIX = '+++'
17
23
 
18
24
  interface StashEntry {
19
25
  selector: string
@@ -84,7 +90,7 @@ async function pop(selector: string, directory?: string): Promise<void> {
84
90
  // by the unmerged paths it leaves: `--diff-filter=U` lists exactly those, empty on any other failure.
85
91
  async function has_conflict(directory?: string): Promise<boolean> {
86
92
  const unmerged = await git_spawn.read(
87
- git_args(directory, ['diff', '--name-only', '--diff-filter=U']),
93
+ git_args(directory, ['diff', NAME_ONLY_FLAG, '--diff-filter=U']),
88
94
  )
89
95
 
90
96
  return unmerged !== ''
@@ -102,6 +108,68 @@ async function push(message: string, directory?: string): Promise<void> {
102
108
  await git_spawn.read(git_args(directory, ['stash', 'push', '-u', '-m', message]))
103
109
  }
104
110
 
111
+ // The stack keyed by commit rather than position (joshuafolkken/kit#2701): a sweep that reads the
112
+ // stack, asks GitHub about each entry and only then drops one would drop whatever another lane pushed
113
+ // on top in the meantime if it kept the `stash@{n}` it read first. The `selector` of each entry here is
114
+ // its commit hash, which never moves.
115
+ async function list_by_hash(directory?: string): Promise<Array<StashEntry>> {
116
+ return parse_entries(await git_spawn.read(git_args(directory, ['stash', 'list', HASH_FORMAT])))
117
+ }
118
+
119
+ // The paths an entry carries, untracked files included — `push` stashes with `-u`.
120
+ async function changed_paths(hash: string, directory?: string): Promise<Array<string>> {
121
+ const raw = await git_spawn.read(
122
+ git_args(directory, ['stash', 'show', '--include-untracked', NAME_ONLY_FLAG, hash]),
123
+ )
124
+
125
+ return raw === '' ? [] : raw.split(ENTRY_SEPARATOR)
126
+ }
127
+
128
+ // A path untracked when the entry was pushed with `-u` lives in its third parent, not in its diff, so
129
+ // every line of it is an added one; a path absent from that parent reads as none.
130
+ async function untracked_lines(
131
+ hash: string,
132
+ file: string,
133
+ directory?: string,
134
+ ): Promise<Array<string>> {
135
+ try {
136
+ const raw = await git_spawn.read(git_args(directory, ['show', `${hash}^3:${file}`]))
137
+
138
+ return raw === '' ? [] : raw.split(ENTRY_SEPARATOR)
139
+ } catch {
140
+ return []
141
+ }
142
+ }
143
+
144
+ // The lines an entry adds to one path, as a unified diff against the commit it was pushed on — or, for
145
+ // a path that was untracked then, the whole file.
146
+ async function added_lines(hash: string, file: string, directory?: string): Promise<Array<string>> {
147
+ const diff = await git_spawn.read(git_args(directory, ['diff', `${hash}^1`, hash, '--', file]))
148
+
149
+ if (diff === '') return await untracked_lines(hash, file, directory)
150
+
151
+ return diff
152
+ .split(ENTRY_SEPARATOR)
153
+ .filter((line) => line.startsWith(ADDED_PREFIX) && !line.startsWith(FILE_HEADER_PREFIX))
154
+ .map((line) => line.slice(ADDED_PREFIX.length))
155
+ }
156
+
157
+ // The position is resolved from the hash immediately before the drop, so an entry pushed since the
158
+ // sweep's read cannot be dropped in its place; an entry already gone answers `false` rather than
159
+ // throwing. Sweeps are serialized by `stash-sweep-lock`, but a plain `git stash push` or `pop` from
160
+ // another command landing between the resolve and the drop still shifts the position — that window is
161
+ // the length of two local git calls.
162
+ async function drop_by_hash(hash: string, directory?: string): Promise<boolean> {
163
+ const raw = await git_spawn.read(git_args(directory, ['stash', 'list', HASH_POSITION_FORMAT]))
164
+ const entry = parse_entries(raw).find((candidate) => candidate.selector === hash)
165
+
166
+ if (entry === undefined) return false
167
+
168
+ await git_spawn.read(git_args(directory, ['stash', 'drop', entry.subject]))
169
+
170
+ return true
171
+ }
172
+
105
173
  // The message uncommitted work is pushed with, keyed on the issue so `josh stash:pop` can target it and
106
174
  // on the occasion so two pushes for one issue — a lane close and an `already-done` read — stay apart.
107
175
  function work_message(issue: string, occasion: string): string {
@@ -109,9 +177,13 @@ function work_message(issue: string, occasion: string): string {
109
177
  }
110
178
 
111
179
  const git_stash = {
180
+ added_lines,
181
+ changed_paths,
182
+ drop_by_hash,
112
183
  has_changes,
113
184
  has_conflict,
114
185
  list,
186
+ list_by_hash,
115
187
  matches,
116
188
  parse_entries,
117
189
  pop,