@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.
- package/.claude/skills/workflow-commands/background-commands.md +6 -0
- package/.claude/skills/workflow-commands/backlogrun-child.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +4 -3
- package/.claude/skills/workflow-commands/backlogrun-progress.md +13 -12
- package/.claude/skills/workflow-commands/backlogrun-steps.md +2 -1
- package/.claude/skills/workflow-commands/observation-filing.md +3 -3
- package/.claude/skills/workflow-commands/prerequisite.md +1 -1
- package/.github/workflows/ci.yml +26 -8
- package/.github/workflows/pr-classification.yml +76 -0
- package/README.md +47 -21
- package/dist/hooks/batch-guard.js +6 -6
- package/dist/hooks/{chunk-LC765SUA.js → chunk-3FWV6CFC.js} +119 -20
- package/dist/hooks/{chunk-UULARQ5Q.js → chunk-EYFJ74MJ.js} +13 -15
- package/dist/hooks/{chunk-O2FBZPSW.js → chunk-GIX5SAAB.js} +248 -231
- package/dist/hooks/{chunk-OLTAGGHH.js → chunk-IA7WC4NS.js} +1 -1
- package/dist/hooks/{chunk-UANRLZV7.js → chunk-IBFC25IX.js} +4 -4
- package/dist/hooks/{chunk-XMR3BS35.js → chunk-M4U4YNRE.js} +3 -3
- package/dist/hooks/{chunk-NWD47MW3.js → chunk-NKVLBB65.js} +3 -3
- package/dist/hooks/{chunk-OYU6KPRA.js → chunk-O3PEDF3M.js} +2 -2
- package/dist/hooks/{chunk-3HZ5MJIW.js → chunk-OM5VDM6F.js} +2 -2
- package/dist/hooks/{chunk-SSX23SJ2.js → chunk-RKUAD3I6.js} +1 -0
- package/dist/hooks/{chunk-77RLKZ5V.js → chunk-V7RH56HU.js} +2 -2
- package/dist/hooks/{chunk-FXAKJKTB.js → chunk-WPALEIRX.js} +1 -1
- package/dist/hooks/codex-hook-adapter.js +12 -12
- package/dist/hooks/duplicate-read-guard.js +7 -7
- package/dist/hooks/format-edited.js +3 -3
- package/dist/hooks/investigation-guard.js +7 -7
- package/dist/hooks/pretool-guard.js +11 -11
- package/dist/hooks/session-lang.js +2 -2
- package/dist/hooks/stop-guard.js +11 -5
- package/dist/josh.js +12 -14
- package/eslint/base.js +4 -1
- package/package.json +21 -6
- package/playwright.config.ts +9 -17
- package/ports/index.d.ts +1 -0
- package/ports/index.js +1 -0
- package/ports/web-server.d.ts +5 -0
- package/ports/web-server.js +73 -0
- package/prompts/collaboration-workflow/issue-template.md +1 -1
- package/scripts/agent/agent-role-profile.ts +11 -0
- package/scripts/ci/pr-classification.ts +27 -6
- package/scripts/document/document-byte-budget.ts +1 -1
- package/scripts/gate/hook-gate-reuse.ts +1 -1
- package/scripts/gate/project-checks.ts +60 -6
- package/scripts/gate/type-check-command.ts +43 -0
- package/scripts/git/constants.ts +4 -0
- package/scripts/git/git-command.ts +11 -5
- package/scripts/git/git-gh-pr-rollup.ts +56 -16
- package/scripts/git/git-staging.ts +6 -4
- package/scripts/git/git-stash.ts +73 -1
- package/scripts/git/stash-orphans.ts +24 -2
- package/scripts/git/stash-sweep-lock.ts +141 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/init-actions.ts +3 -2
- package/scripts/init/init-ai-copy.ts +8 -5
- package/scripts/init/init-logic-migrate.ts +10 -1
- package/scripts/init/init-logic-templates.ts +27 -7
- package/scripts/init/init-logic-workspace.ts +89 -5
- package/scripts/init/init-logic.ts +7 -1
- package/scripts/init/init-static.ts +4 -1
- package/scripts/init/init.ts +60 -19
- package/scripts/init/project-profile.ts +14 -5
- package/scripts/issue/issue-merged.ts +75 -0
- package/scripts/issue/measure-rerun-cli.ts +2 -2
- package/scripts/josh/__snapshots__/josh-command-map.test.ts.snap +10 -0
- package/scripts/josh/josh-command-map.ts +1 -0
- package/scripts/josh/josh-command-types.ts +0 -3
- package/scripts/josh/josh-commands-ai.ts +6 -0
- package/scripts/josh/josh-commands-development.ts +6 -19
- package/scripts/lint/format.ts +68 -0
- package/scripts/lint/lint-parallel.ts +23 -36
- package/scripts/observations/observation-ledger-home.ts +22 -2
- package/scripts/observations/observation-ledger-line.ts +11 -0
- package/scripts/observations/observation-ledger-migrate.ts +146 -0
- package/scripts/observations/observation-ledger.ts +51 -3
- package/scripts/observations/observations-flush.ts +38 -7
- package/scripts/review/review-finding-ledger.ts +1 -1
- package/scripts/review/review-record-cli.ts +1 -7
- package/scripts/rules/delivered-rules.ts +10 -0
- package/scripts/rules/lane-background.ts +111 -0
- package/scripts/rules/stop-rules.ts +15 -2
- package/scripts/run/run-carry-flush.ts +2 -7
- package/scripts/run/run-hold-cli.ts +10 -2
- package/scripts/run/run-step-cli.ts +11 -1
- package/scripts/run/run-step.ts +36 -8
- package/scripts/run/run-tidy-cli.ts +50 -0
- package/scripts/run/run-tidy-lanes.ts +110 -0
- package/scripts/run/run-tidy-stashes.ts +160 -0
- package/scripts/run/run-tidy.ts +101 -0
- package/scripts/safe-chain/ci-installer-pin.ts +90 -0
- package/scripts/safe-chain/preinstall-command.ts +39 -0
- package/scripts/sync/sync.ts +5 -2
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/version/preinstall-version-update.ts +2 -0
- package/templates/pnpm-workspace.static.yaml +4 -0
- package/templates/workflows/ci.yml +21 -7
- 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
|
|
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({
|
|
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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
88
|
+
return JSON.parse(readFileSync(manifest_path, 'utf8'))
|
|
87
89
|
} catch {
|
|
88
|
-
return
|
|
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
|
|
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 }
|
package/scripts/git/constants.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
15
|
-
//
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
103
|
+
function remember_newest_run(run: RollupElement, newest: Map<string, RollupElement>): void {
|
|
63
104
|
const key = check_run_group_key(run)
|
|
64
|
-
const
|
|
65
|
-
if (key === undefined ||
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
-
if (key === undefined || typeof id !== 'number') return true
|
|
113
|
+
if (key === undefined || read_rank(run) === undefined) return true
|
|
74
114
|
|
|
75
|
-
return
|
|
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
|
|
82
|
-
for (const run of runs) remember_newest_run(run,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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 !
|
|
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 {
|
package/scripts/git/git-stash.ts
CHANGED
|
@@ -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',
|
|
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,
|