@joshuafolkken/kit 1.929.0 → 1.953.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/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 +44 -22
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-GQ3EVWHY.js → chunk-452OKRQX.js} +421 -195
- package/dist/hooks/{chunk-2PGRQ62K.js → chunk-EYFJ74MJ.js} +11 -6
- package/dist/hooks/{chunk-LCJZE42I.js → chunk-IA7WC4NS.js} +1 -1
- package/dist/hooks/{chunk-BNFG2IFK.js → chunk-IBFC25IX.js} +4 -4
- package/dist/hooks/{chunk-I3JP3D4A.js → chunk-M4U4YNRE.js} +3 -3
- package/dist/hooks/{chunk-NJZSO7BK.js → chunk-NKVLBB65.js} +3 -3
- package/dist/hooks/{chunk-SXPTRFPG.js → chunk-O3PEDF3M.js} +1 -1
- package/dist/hooks/{chunk-7XQ6TK3R.js → chunk-OM5VDM6F.js} +2 -2
- package/dist/hooks/{chunk-ROULPSQC.js → chunk-V7RH56HU.js} +2 -2
- package/dist/hooks/{chunk-YO43HEPY.js → chunk-Z5TVZ4AP.js} +237 -235
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +2 -2
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +9 -9
- package/dist/hooks/stop-guard.js +9 -3
- package/dist/josh.js +11 -6
- package/dist/version/index.js +7 -4
- package/package.json +13 -1
- package/prompts/collaboration-workflow/issue-template.md +1 -1
- 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 +45 -15
- 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/git-worktree.ts +35 -4
- package/scripts/git/repository-lock.ts +138 -0
- package/scripts/git/stash-orphans.ts +24 -2
- package/scripts/git/stash-sweep-lock.ts +30 -0
- package/scripts/hooks/stop-guard.ts +2 -0
- package/scripts/init/init-actions.ts +3 -2
- 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 +52 -1
- package/scripts/init/init-logic.ts +6 -1
- package/scripts/init/init.ts +60 -19
- 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 +5 -5
- package/scripts/lint/lint-parallel.ts +19 -18
- 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-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/safe-chain/project-config.ts +55 -10
- package/scripts/sync/sync.ts +8 -3
- package/scripts/test/pilot-files.ts +0 -1
- package/scripts/version/preinstall-version-update.ts +2 -0
- package/scripts/version/release-age.ts +11 -6
- package/templates/workflows/ci.yml +21 -7
- package/tsconfig/base.json +4 -1
|
@@ -75,10 +75,61 @@ function answer_build_placeholders(existing: string, template: string): string {
|
|
|
75
75
|
})
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
const BUILD_ENTRY_KEY_PATTERN = /^[ \t]+(?<key>[^\s:#][^:#]*):/u
|
|
79
|
+
|
|
80
|
+
function build_entry_key(line: string): string | undefined {
|
|
81
|
+
const key = BUILD_ENTRY_KEY_PATTERN.exec(line)?.groups?.['key']
|
|
82
|
+
|
|
83
|
+
return key === undefined ? undefined : unquote(key)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function build_entry_keys(block: string): Set<string> {
|
|
87
|
+
return new Set(block.split('\n').flatMap((line) => build_entry_key(line) ?? []))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// The key-level merge keeps an existing `allowBuilds` whole, and `pnpm add -D @joshuafolkken/kit`
|
|
91
|
+
// before `josh init` always leaves one (esbuild's), so without this the template's other approvals
|
|
92
|
+
// never landed and the first `pnpm install` failed on the next build script, e.g. unrs-resolver's
|
|
93
|
+
// (joshuafolkken/kit#2710). Entries the project already answered keep their value.
|
|
94
|
+
// Only a block mapping can take appended entry lines; a flow map (`allowBuilds: { … }`) is left whole.
|
|
95
|
+
const BLOCK_MAPPING_HEADER_PATTERN = /^allowBuilds:[ \t]*(?:#[^\n]*)?(?:\n|$)/u
|
|
96
|
+
const LEADING_INDENT_PATTERN = /^[ \t]+/u
|
|
97
|
+
|
|
98
|
+
// Appended lines take the indent of the block's own entries: a mapping whose items start at
|
|
99
|
+
// different columns is invalid YAML, so a four-space block must not gain two-space lines.
|
|
100
|
+
function block_entry_indent(block: string): string | undefined {
|
|
101
|
+
const entry = block.split('\n').find((line) => build_entry_key(line) !== undefined)
|
|
102
|
+
|
|
103
|
+
return entry === undefined ? undefined : LEADING_INDENT_PATTERN.exec(entry)?.[0]
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function reindent(line: string, indent: string | undefined): string {
|
|
107
|
+
return indent === undefined ? line : line.replace(LEADING_INDENT_PATTERN, () => indent)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function add_missing_build_entries(existing: string, template: string): string {
|
|
111
|
+
const block = extract_yaml_block(existing, 'allowBuilds')
|
|
112
|
+
if (!BLOCK_MAPPING_HEADER_PATTERN.test(block)) return existing
|
|
113
|
+
const present = build_entry_keys(block)
|
|
114
|
+
const indent = block_entry_indent(block)
|
|
115
|
+
const missing = extract_yaml_block(template, 'allowBuilds')
|
|
116
|
+
.split('\n')
|
|
117
|
+
.filter((line) => {
|
|
118
|
+
const key = build_entry_key(line)
|
|
119
|
+
|
|
120
|
+
return key !== undefined && !present.has(key)
|
|
121
|
+
})
|
|
122
|
+
.map((line) => reindent(line, indent))
|
|
123
|
+
if (missing.length === 0) return existing
|
|
124
|
+
|
|
125
|
+
return existing.replace(block, () => [block, ...missing].join('\n'))
|
|
126
|
+
}
|
|
127
|
+
|
|
78
128
|
function merge_workspace_yaml(existing: string, template: string): string {
|
|
79
129
|
if (!existing.trim()) return template
|
|
80
130
|
const normalized = existing.endsWith('\n') ? existing : `${existing}\n`
|
|
81
|
-
const
|
|
131
|
+
const answered = answer_build_placeholders(remove_deprecated_yaml_keys(normalized), template)
|
|
132
|
+
const cleaned = add_missing_build_entries(answered, template)
|
|
82
133
|
if (!cleaned.trim()) return template
|
|
83
134
|
const existing_keys = new Set(extract_yaml_top_level_keys(cleaned))
|
|
84
135
|
const new_keys = extract_yaml_top_level_keys(template).filter((k) => !existing_keys.has(k))
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { json_format } from '#scripts/config-merge/json-format'
|
|
2
2
|
import { vscode_settings_schema } from '#scripts/lib/schemas'
|
|
3
|
+
import { safe_chain_preinstall } from '#scripts/safe-chain/preinstall-command'
|
|
3
4
|
import { distributed_paths } from './distributed-paths'
|
|
4
5
|
import { init_logic_deploy_vps } from './init-logic-deploy-vps'
|
|
5
6
|
import { init_logic_json_merge } from './init-logic-json-merge'
|
|
@@ -27,7 +28,7 @@ const NPMRC_LINES: ReadonlyArray<string> = [
|
|
|
27
28
|
const CSPELL_IMPORT = '@joshuafolkken/kit/cspell'
|
|
28
29
|
|
|
29
30
|
const LEFTHOOK_INSTALL_CMD = 'lefthook install'
|
|
30
|
-
const SAFE_CHAIN_CMD =
|
|
31
|
+
const { SAFE_CHAIN_CMD } = safe_chain_preinstall
|
|
31
32
|
const FIX_GH_PACKAGES_CMD = 'tsx node_modules/@joshuafolkken/kit/scripts/gh/fix-gh-packages.ts'
|
|
32
33
|
// Marker identifying a consumer script that already runs the fix-gh-packages command.
|
|
33
34
|
const FIX_GH_PACKAGES_MARKER = 'fix-gh-packages'
|
|
@@ -90,6 +91,7 @@ const AI_COPY_FILES: ReadonlyArray<string> = [
|
|
|
90
91
|
'.github/workflows/auto-tag.yml',
|
|
91
92
|
'.github/workflows/production.yml',
|
|
92
93
|
'.github/workflows/sonar-qube.yml',
|
|
94
|
+
'.github/workflows/pr-classification.yml',
|
|
93
95
|
'.github/pull_request_template.md',
|
|
94
96
|
'.github/release.yml',
|
|
95
97
|
'.github/dependabot.yml',
|
|
@@ -228,9 +230,12 @@ const PRETTIER_PLUGIN_DEV_DEPS: Record<string, string> = {
|
|
|
228
230
|
// format_json rather than JSON.stringify: it lays arrays out the way prettier does — inline while
|
|
229
231
|
// they fit within printWidth, one element per line once they do not — so the file kit writes is
|
|
230
232
|
// `prettier --check`-clean in the consumer whatever the entry count happens to be (#660).
|
|
233
|
+
// TypeScript 6 defaults `types` to `[]`, so a node project sees no `process` global — not even in the
|
|
234
|
+
// generated `playwright.config.ts` — until the Node types are named (joshuafolkken/kit#2710).
|
|
231
235
|
function generate_tsconfig(): string {
|
|
232
236
|
return json_format.format_json({
|
|
233
237
|
extends: TSCONFIG_EXTENDS,
|
|
238
|
+
compilerOptions: { types: ['node'] },
|
|
234
239
|
[TSCONFIG_EXCLUDE_FIELD]: TSCONFIG_EXCLUDE,
|
|
235
240
|
})
|
|
236
241
|
}
|
package/scripts/init/init.ts
CHANGED
|
@@ -27,6 +27,7 @@ const LEFTHOOK_BIN = 'lefthook'
|
|
|
27
27
|
const SAMPLE_INDENT_WIDTH = 4
|
|
28
28
|
const ARGUMENT_START_INDEX = 2
|
|
29
29
|
const SAMPLE_INDENT = ' '.repeat(SAMPLE_INDENT_WIDTH)
|
|
30
|
+
const INSTALL_HINT = '→ run `pnpm install` to install what package.json lists'
|
|
30
31
|
|
|
31
32
|
function write_new_file(action: FileAction, destination_path: string): void {
|
|
32
33
|
mkdirSync(path.dirname(destination_path), { recursive: true })
|
|
@@ -77,12 +78,25 @@ function execute_file_action(action: FileAction): void {
|
|
|
77
78
|
merge_existing_file(action.merge, destination_path, action.dest)
|
|
78
79
|
}
|
|
79
80
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
)
|
|
81
|
+
const engine_pin_schema = z.looseObject({ name: z.string(), version: z.string() })
|
|
82
|
+
|
|
83
|
+
const published_development_engines_schema = z.looseObject({
|
|
84
|
+
devEngines: z.looseObject({ packageManager: engine_pin_schema.optional() }).optional(),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
// pnpm strips `packageManager` from a published manifest, so the kit a consumer installs carries its
|
|
88
|
+
// pin only in `devEngines`. Falling back to that exact version keeps the consumer off the bare
|
|
89
|
+
// `>=12.1.0` range, which every later `pnpm` call rejects as an invalid packageManager specification.
|
|
90
|
+
function resolve_kit_package_manager(manifest: unknown): string | undefined {
|
|
91
|
+
const { packageManager: package_manager } = with_package_manager_schema.parse(manifest)
|
|
92
|
+
if (package_manager !== undefined && package_manager.length > 0) return package_manager
|
|
93
|
+
const pin = published_development_engines_schema.parse(manifest).devEngines?.packageManager
|
|
84
94
|
|
|
85
|
-
return
|
|
95
|
+
return pin === undefined ? undefined : `${pin.name}@${pin.version}`
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function get_kit_package_manager(): string | undefined {
|
|
99
|
+
return resolve_kit_package_manager(init_actions.read_package_json(PACKAGE_JSON))
|
|
86
100
|
}
|
|
87
101
|
|
|
88
102
|
function get_kit_development_engines(): Record<string, unknown> {
|
|
@@ -97,21 +111,34 @@ function get_kit_self_dependency(): Record<string, string> {
|
|
|
97
111
|
return { [KIT_PACKAGE_NAME]: version }
|
|
98
112
|
}
|
|
99
113
|
|
|
100
|
-
|
|
114
|
+
// Each config `init` generates runs a CLI or imports types the consumer has to resolve itself:
|
|
115
|
+
// `prettier.config.js` → prettier, `cspell.config.yaml` → cspell, `playwright.config.ts` →
|
|
116
|
+
// @types/node (with @playwright/test among the peers), `lefthook.yml` → lefthook. Without them the
|
|
117
|
+
// first `josh gate` after `pnpm install` fails (joshuafolkken/kit#2710).
|
|
118
|
+
const TOOL_DEVELOPMENT_DEPENDENCIES = ['prettier', 'cspell', '@types/node']
|
|
119
|
+
const LEFTHOOK_DEVELOPMENT_DEPENDENCY = 'lefthook'
|
|
120
|
+
|
|
121
|
+
function tool_dependency_names(has_git: boolean): ReadonlyArray<string> {
|
|
122
|
+
return has_git
|
|
123
|
+
? [...TOOL_DEVELOPMENT_DEPENDENCIES, LEFTHOOK_DEVELOPMENT_DEPENDENCY]
|
|
124
|
+
: TOOL_DEVELOPMENT_DEPENDENCIES
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Versions mirror kit's own devDependencies, so the consumer runs the toolchain kit is verified with.
|
|
128
|
+
function get_toolchain_development_dependencies(has_git: boolean): Record<string, string> {
|
|
101
129
|
const manifest = z
|
|
102
130
|
.object({
|
|
103
131
|
peerDependencies: z.record(z.string(), z.string()),
|
|
104
132
|
devDependencies: z.record(z.string(), z.string()),
|
|
105
133
|
})
|
|
106
134
|
.parse(init_actions.read_package_json(PACKAGE_JSON))
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
if (version === undefined) throw new Error(`Missing development version for ${name}`)
|
|
135
|
+
const names = [...Object.keys(manifest.peerDependencies), ...tool_dependency_names(has_git)]
|
|
136
|
+
const entries = names.map((name): [string, string] => {
|
|
137
|
+
const version = manifest.devDependencies[name]
|
|
138
|
+
if (version === undefined) throw new Error(`Missing development version for ${name}`)
|
|
112
139
|
|
|
113
|
-
|
|
114
|
-
|
|
140
|
+
return [name, version]
|
|
141
|
+
})
|
|
115
142
|
|
|
116
143
|
return Object.fromEntries(entries)
|
|
117
144
|
}
|
|
@@ -126,11 +153,11 @@ function apply_dependency_merges(content: string, has_git = true): string {
|
|
|
126
153
|
: Object.fromEntries(Object.entries(suggested).filter(([key]) => key !== 'prepare')),
|
|
127
154
|
)
|
|
128
155
|
const with_prettier = init_logic.merge_prettier_plugin_development_deps(merged)
|
|
129
|
-
const
|
|
156
|
+
const with_toolchain = init_logic.merge_development_dependencies(
|
|
130
157
|
with_prettier,
|
|
131
|
-
|
|
158
|
+
get_toolchain_development_dependencies(has_git),
|
|
132
159
|
)
|
|
133
|
-
const with_secretlint = init_logic.merge_secretlint_development_deps(
|
|
160
|
+
const with_secretlint = init_logic.merge_secretlint_development_deps(with_toolchain)
|
|
134
161
|
|
|
135
162
|
return init_logic.merge_development_dependencies(with_secretlint, get_kit_self_dependency())
|
|
136
163
|
}
|
|
@@ -191,12 +218,25 @@ function merge_project_package_json(shape: ProjectShape): void {
|
|
|
191
218
|
|
|
192
219
|
writeFileSync(package_json_path, merged)
|
|
193
220
|
console.info(` ✔ ${is_existing ? 'updated' : 'created'} package.json`)
|
|
221
|
+
// `init` writes the manifest but installs nothing, so a tool it just listed is absent until the
|
|
222
|
+
// user installs it — and `josh lint` then fails on it rather than skipping it
|
|
223
|
+
// (joshuafolkken/kit#2709). Saying so here is what tells the user the next step.
|
|
224
|
+
console.info(` ${INSTALL_HINT}`)
|
|
194
225
|
}
|
|
195
226
|
|
|
196
|
-
|
|
227
|
+
// A fresh project runs `init` before its first `pnpm install`, so lefthook — added to devDependencies
|
|
228
|
+
// above — is not there yet; the `prepare` script installs the hooks once it is.
|
|
229
|
+
function install_lefthook(project_root: string = PROJECT_ROOT): void {
|
|
197
230
|
console.info('\nLefthook:')
|
|
198
|
-
const bin = resolve_local_bin(
|
|
199
|
-
|
|
231
|
+
const bin = resolve_local_bin(project_root, LEFTHOOK_BIN)
|
|
232
|
+
|
|
233
|
+
if (!existsSync(bin)) {
|
|
234
|
+
console.info(' ℹ lefthook is not installed yet — `pnpm install` installs the git hooks')
|
|
235
|
+
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const result = execaSync(bin, ['install'], { cwd: project_root, stdio: 'inherit', reject: false })
|
|
200
240
|
|
|
201
241
|
if (result.exitCode === undefined) {
|
|
202
242
|
console.warn(' ⚠ lefthook install failed — run it manually: lefthook install')
|
|
@@ -281,6 +321,7 @@ const init = {
|
|
|
281
321
|
copy_ai_file: init_ai_copy.copy_ai_file,
|
|
282
322
|
apply_package_json_merges,
|
|
283
323
|
install_lefthook,
|
|
324
|
+
resolve_kit_package_manager,
|
|
284
325
|
}
|
|
285
326
|
|
|
286
327
|
// `init` is the entry point; `main` is exported for its own unit test, the way `sync.ts` exports the
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { git_gh_api_path } from '#scripts/git/git-gh-api-path'
|
|
2
|
+
import { git_gh_exec } from '#scripts/git/git-gh-exec'
|
|
3
|
+
|
|
4
|
+
// Whether an issue was closed by a merge, read from its REST timeline (joshuafolkken/kit#2701). The
|
|
5
|
+
// issue's own `state_reason` cannot tell: a pull request's `closes #N` and a person's "Close as
|
|
6
|
+
// completed" both answer `completed`. What only a merge leaves is a merged pull request referencing
|
|
7
|
+
// the issue, so the answer is the conjunction — closed now, as completed, and referenced by a merged
|
|
8
|
+
// pull request. A manual "completed" close still reads as merged when a merged pull request also
|
|
9
|
+
// references the issue — the one case the timeline cannot separate — and every other doubt falls to
|
|
10
|
+
// `false`, because a sweep deletes work on this answer.
|
|
11
|
+
|
|
12
|
+
const CLOSED_EVENT = 'closed'
|
|
13
|
+
const REOPENED_EVENT = 'reopened'
|
|
14
|
+
const MERGED_REFERENCE = 'merged-reference'
|
|
15
|
+
// A close by a pull request's `closes #N` carries no reason in the timeline; a person's "Close as
|
|
16
|
+
// completed" carries `completed`. Any other reason — `not_planned`, `duplicate` — is not a merge, and
|
|
17
|
+
// neither is one GitHub adds later.
|
|
18
|
+
const COMPLETED_REASONS: ReadonlySet<string> = new Set(['', 'completed'])
|
|
19
|
+
const FIELD_SEPARATOR = '\t'
|
|
20
|
+
const LINE_SEPARATOR = '\n'
|
|
21
|
+
// One `<event>\t<state_reason>` line per closed/reopened event, and a `merged-reference` line per
|
|
22
|
+
// merged pull request that cross-references the issue — the three kinds the answer reads.
|
|
23
|
+
const TIMELINE_FILTER = [
|
|
24
|
+
'.[]',
|
|
25
|
+
'| if .event == "cross-referenced" and .source.issue.pull_request.merged_at != null',
|
|
26
|
+
String.raw`then "${MERGED_REFERENCE}\t"`,
|
|
27
|
+
`elif .event == "${CLOSED_EVENT}" or .event == "${REOPENED_EVENT}"`,
|
|
28
|
+
String.raw`then "\(.event)\t\(.state_reason // "")"`,
|
|
29
|
+
'else empty end',
|
|
30
|
+
].join(' ')
|
|
31
|
+
|
|
32
|
+
interface TimelineRow {
|
|
33
|
+
event: string
|
|
34
|
+
reason: string
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function parse_rows(raw: string): Array<TimelineRow> {
|
|
38
|
+
return raw
|
|
39
|
+
.split(LINE_SEPARATOR)
|
|
40
|
+
.filter((line) => line !== '')
|
|
41
|
+
.map((line) => {
|
|
42
|
+
const [event = '', reason = ''] = line.split(FIELD_SEPARATOR)
|
|
43
|
+
|
|
44
|
+
return { event, reason }
|
|
45
|
+
})
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// The last closed/reopened event is the issue's current state, so a reopened issue is not merged
|
|
49
|
+
// even with a merged reference from an earlier attempt.
|
|
50
|
+
function is_merged_close(rows: ReadonlyArray<TimelineRow>): boolean {
|
|
51
|
+
const last = rows.findLast((row) => row.event === CLOSED_EVENT || row.event === REOPENED_EVENT)
|
|
52
|
+
|
|
53
|
+
if (last?.event !== CLOSED_EVENT || !COMPLETED_REASONS.has(last.reason)) return false
|
|
54
|
+
|
|
55
|
+
return rows.some((row) => row.event === MERGED_REFERENCE)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function read_merged(issue: string): Promise<boolean> {
|
|
59
|
+
try {
|
|
60
|
+
const raw = await git_gh_exec.exec_gh_api({
|
|
61
|
+
path: `${git_gh_api_path.issue_api_path(issue)}/timeline?per_page=100`,
|
|
62
|
+
should_paginate: true,
|
|
63
|
+
jq_filter: TIMELINE_FILTER,
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
return is_merged_close(parse_rows(raw))
|
|
67
|
+
} catch {
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const issue_merged = { is_merged_close, parse_rows, read_merged }
|
|
73
|
+
|
|
74
|
+
export type { TimelineRow }
|
|
75
|
+
export { issue_merged }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { execSync } from 'node:child_process'
|
|
3
|
-
import {
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
4
|
import { fileURLToPath } from 'node:url'
|
|
5
5
|
import { observation_ledger_home } from '#scripts/observations/observation-ledger-home'
|
|
6
6
|
import { baseline_measure, type Baseline } from './baseline-measure'
|
|
@@ -47,7 +47,7 @@ async function append_ledger(lines: ReadonlyArray<string>): Promise<void> {
|
|
|
47
47
|
// (joshuafolkken/kit#2419).
|
|
48
48
|
const ledger_path = observation_ledger_home.ledger_path()
|
|
49
49
|
|
|
50
|
-
await
|
|
50
|
+
await observation_ledger_home.append(ledger_path, lines)
|
|
51
51
|
console.info(`Recorded ${String(lines.length)} refuted premise(s) in ${ledger_path}.`)
|
|
52
52
|
}
|
|
53
53
|
|
|
@@ -116,6 +116,7 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
|
|
|
116
116
|
"rsp": "run:step",
|
|
117
117
|
"rst": "run:status",
|
|
118
118
|
"rt": "reconcile-templates",
|
|
119
|
+
"rtd": "run:tidy",
|
|
119
120
|
"rtl": "run:tail",
|
|
120
121
|
"rtr": "retrospective",
|
|
121
122
|
"rug": "rule:guard",
|
|
@@ -1041,6 +1042,15 @@ exports[`COMMAND_MAP — reference metadata > keeps every canonical command and
|
|
|
1041
1042
|
"network",
|
|
1042
1043
|
],
|
|
1043
1044
|
],
|
|
1045
|
+
"run:tidy": [
|
|
1046
|
+
"",
|
|
1047
|
+
"automation",
|
|
1048
|
+
[
|
|
1049
|
+
"git",
|
|
1050
|
+
"network",
|
|
1051
|
+
"files",
|
|
1052
|
+
],
|
|
1053
|
+
],
|
|
1044
1054
|
"run:wake": [
|
|
1045
1055
|
"[options]",
|
|
1046
1056
|
"automation",
|
|
@@ -69,8 +69,6 @@ interface CommandEntry {
|
|
|
69
69
|
// (joshuafolkken/kit#914).
|
|
70
70
|
const GATE_COMMAND = 'gate'
|
|
71
71
|
|
|
72
|
-
const PE = ['pnpm', 'exec'] as const
|
|
73
|
-
|
|
74
72
|
// joshuafolkken/kit#1256: three of the gate's four checks keep a content-addressed cache, so a
|
|
75
73
|
// second run reads only what changed. eslint had one from the start; the type check and the spell
|
|
76
74
|
// check rescanned the whole tree every time, which cost 10.5s of CPU against 2.8s cached.
|
|
@@ -219,7 +217,6 @@ export {
|
|
|
219
217
|
GATE_COMMAND,
|
|
220
218
|
IGNORED_CACHE_FILES,
|
|
221
219
|
OPTIONAL_ENV_FILE_FLAGS,
|
|
222
|
-
PE,
|
|
223
220
|
SHARED_CACHE_SPECS,
|
|
224
221
|
TS_BUILD_INFO_FILE,
|
|
225
222
|
TS_CACHE_FLAGS,
|
|
@@ -147,6 +147,12 @@ const AI_COMMANDS: Record<string, CommandEntry> = {
|
|
|
147
147
|
reference: ['[issue|--force]', 'automation', ['files']],
|
|
148
148
|
default_script_arguments: ['--release'],
|
|
149
149
|
},
|
|
150
|
+
'run:tidy': {
|
|
151
|
+
script: 'scripts/run/run-tidy-cli.ts',
|
|
152
|
+
description: 'Close merged lanes and drop stashes whose issues are all merged',
|
|
153
|
+
category: 'AI tools',
|
|
154
|
+
reference: ['', 'automation', ['git', 'network', 'files']],
|
|
155
|
+
},
|
|
150
156
|
'run:carry': {
|
|
151
157
|
script: 'scripts/run/run-carry-cli.ts',
|
|
152
158
|
description: 'Carry one invocation’s budget across its own session cuts',
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { GATE_COMMAND,
|
|
1
|
+
import { GATE_COMMAND, type CommandEntry } from './josh-command-types'
|
|
2
2
|
|
|
3
3
|
const FILE_ARGUMENTS = '[files...]'
|
|
4
4
|
const REQUIRED_FILE_ARGUMENTS = '<files...>'
|
|
@@ -22,7 +22,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
22
22
|
},
|
|
23
23
|
lint: {
|
|
24
24
|
script: 'scripts/lint/lint-parallel.ts',
|
|
25
|
-
description: 'Check code with prettier and eslint',
|
|
25
|
+
description: 'Check code with prettier and eslint (skips a tool a static project lacks)',
|
|
26
26
|
category: 'Development',
|
|
27
27
|
reference: ['', 'developer', ['processes']],
|
|
28
28
|
},
|
|
@@ -61,7 +61,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
61
61
|
},
|
|
62
62
|
format: {
|
|
63
63
|
script: 'scripts/lint/format.ts',
|
|
64
|
-
description: 'Format code with prettier and eslint',
|
|
64
|
+
description: 'Format code with prettier and eslint (skips a tool a static project lacks)',
|
|
65
65
|
category: 'Development',
|
|
66
66
|
reference: ['', 'developer', ['files', 'processes']],
|
|
67
67
|
},
|
|
@@ -182,8 +182,8 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
182
182
|
argument_targets: ['test:unit', 'test:e2e'],
|
|
183
183
|
},
|
|
184
184
|
check: {
|
|
185
|
-
|
|
186
|
-
description: 'Type-check TypeScript
|
|
185
|
+
script: 'scripts/gate/type-check-command.ts',
|
|
186
|
+
description: 'Type-check with tsc (skips a static project with no TypeScript to check)',
|
|
187
187
|
category: 'Development',
|
|
188
188
|
reference: ['[arguments...]', 'developer', ['processes']],
|
|
189
189
|
},
|
|
@@ -34,18 +34,25 @@ function skipped(check: string, reason: string): BufferedProcessResult {
|
|
|
34
34
|
return { output: `${project_checks.skip_notice(check, reason)}\n`, exit_code: 0, elapsed_ms: 0 }
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
async function
|
|
37
|
+
async function run_prettier(
|
|
38
|
+
directory: string,
|
|
39
|
+
prettier_args: ReadonlyArray<string>,
|
|
40
|
+
): Promise<BufferedProcessResult> {
|
|
38
41
|
const reason = project_checks.prettier_skip_reason(directory)
|
|
39
42
|
if (reason !== undefined) return skipped('prettier', reason)
|
|
40
43
|
|
|
41
|
-
return await buffered_process.run_buffered_process(
|
|
44
|
+
return await buffered_process.run_buffered_process(prettier_args)
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
async function
|
|
47
|
+
async function run_checked_eslint(
|
|
48
|
+
directory: string,
|
|
49
|
+
eslint_args: ReadonlyArray<string>,
|
|
50
|
+
cache_file: string,
|
|
51
|
+
): Promise<BufferedProcessResult> {
|
|
45
52
|
const reason = project_checks.eslint_skip_reason(directory)
|
|
46
53
|
if (reason !== undefined) return skipped('eslint', reason)
|
|
47
54
|
|
|
48
|
-
return await run_eslint(
|
|
55
|
+
return await run_eslint(eslint_args, cache_file)
|
|
49
56
|
}
|
|
50
57
|
|
|
51
58
|
function lint_exit_code(prettier: BufferedProcessResult, eslint: BufferedProcessResult): number {
|
|
@@ -56,15 +63,19 @@ function lint_exit_code(prettier: BufferedProcessResult, eslint: BufferedProcess
|
|
|
56
63
|
|
|
57
64
|
// The two checks are run from here whether they were pointed at the whole tree or at one change's
|
|
58
65
|
// files, so `josh lint:related` reaches prettier and eslint through the same buffering, the same
|
|
59
|
-
// "one failure does not abort the other" reading, and the same exit code (joshuafolkken/kit#1298)
|
|
66
|
+
// "one failure does not abort the other" reading, and the same exit code (joshuafolkken/kit#1298) —
|
|
67
|
+
// and through the same static-project skip, so a narrowed run never reaches a tool the whole-tree
|
|
68
|
+
// run would have skipped (joshuafolkken/kit#2709). The skip reasons answer nothing for a node
|
|
69
|
+
// project, so the one path serves both profiles.
|
|
60
70
|
async function run_lint_checks(
|
|
61
71
|
prettier_args: ReadonlyArray<string>,
|
|
62
72
|
eslint_args: ReadonlyArray<string>,
|
|
63
73
|
eslint_cache_file: string = ESLINT_CACHE_FILE,
|
|
64
74
|
): Promise<number> {
|
|
75
|
+
const directory = process.cwd()
|
|
65
76
|
const [prettier, eslint] = await Promise.all([
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
run_prettier(directory, prettier_args),
|
|
78
|
+
run_checked_eslint(directory, eslint_args, eslint_cache_file),
|
|
68
79
|
])
|
|
69
80
|
|
|
70
81
|
write_output(prettier)
|
|
@@ -74,17 +85,7 @@ async function run_lint_checks(
|
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
async function run_lint_parallel_checks(): Promise<number> {
|
|
77
|
-
|
|
78
|
-
if (!project_checks.is_static(directory)) return await run_lint_checks(PRETTIER_ARGS, ESLINT_ARGS)
|
|
79
|
-
const [prettier, eslint] = await Promise.all([
|
|
80
|
-
run_static_prettier(directory),
|
|
81
|
-
run_static_eslint(directory),
|
|
82
|
-
])
|
|
83
|
-
|
|
84
|
-
write_output(prettier)
|
|
85
|
-
write_output(eslint)
|
|
86
|
-
|
|
87
|
-
return lint_exit_code(prettier, eslint)
|
|
88
|
+
return await run_lint_checks(PRETTIER_ARGS, ESLINT_ARGS)
|
|
88
89
|
}
|
|
89
90
|
|
|
90
91
|
// `process.exitCode` rather than `process.exit()`: both reports are buffered and written here in
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises'
|
|
1
2
|
import path from 'node:path'
|
|
2
3
|
import { repo_discovery } from '#scripts/discovery/repo-discovery'
|
|
3
4
|
import { OBSERVATION_LEDGER_PATH } from './observation-ledger'
|
|
5
|
+
import { observation_ledger_migrate } from './observation-ledger-migrate'
|
|
4
6
|
|
|
5
7
|
// Where the observation ledger is read and written, whichever work tree a command runs in
|
|
6
8
|
// (joshuafolkken/kit#2419).
|
|
@@ -23,10 +25,28 @@ function is_lane(cwd: string = process.cwd()): boolean {
|
|
|
23
25
|
return path.resolve(ledger_root(cwd)) !== path.resolve(cwd)
|
|
24
26
|
}
|
|
25
27
|
|
|
28
|
+
// **Resolving the path migrates the ledger first** (joshuafolkken/kit#2724). Every reader and writer
|
|
29
|
+
// asks here — `review:record --check`, `review:findings`, the retrospective, the stash carry's
|
|
30
|
+
// duplicate check — so none of them can read an empty new ledger while the lines still sit at the old
|
|
31
|
+
// path, which for the review-record check would answer `not-required` for a round never recorded.
|
|
26
32
|
function ledger_path(cwd: string = process.cwd()): string {
|
|
27
|
-
|
|
33
|
+
const root = ledger_root(cwd)
|
|
34
|
+
|
|
35
|
+
observation_ledger_migrate.migrate(root)
|
|
36
|
+
|
|
37
|
+
return path.join(root, OBSERVATION_LEDGER_PATH)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The one append every ledger writer goes through. A consumer that does not keep the ledger has no
|
|
41
|
+
// `docs/` at its root, so the parent is created first — the one write path that can bring the ledger
|
|
42
|
+
// into existence there (joshuafolkken/kit#2402).
|
|
43
|
+
async function append(target: string, lines: ReadonlyArray<string>): Promise<void> {
|
|
44
|
+
if (lines.length === 0) return
|
|
45
|
+
|
|
46
|
+
await mkdir(path.dirname(target), { recursive: true })
|
|
47
|
+
await appendFile(target, `${lines.join('\n')}\n`, 'utf8')
|
|
28
48
|
}
|
|
29
49
|
|
|
30
|
-
const observation_ledger_home = { is_lane, ledger_path, ledger_root }
|
|
50
|
+
const observation_ledger_home = { append, is_lane, ledger_path, ledger_root }
|
|
31
51
|
|
|
32
52
|
export { observation_ledger_home }
|
|
@@ -83,10 +83,21 @@ function broken_ledger_lines(content: string): ReadonlyArray<BrokenLine> {
|
|
|
83
83
|
.filter((entry): entry is BrokenLine => entry.reason !== undefined)
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
// The lines of `candidates` the ledger does not already hold, in their order. The ledger is
|
|
87
|
+
// append-only and the digest counts repeats, so every path that moves lines into it — the stash carry
|
|
88
|
+
// (`run-tidy.ts`) and the old-path migration (`observation-ledger-migrate.ts`) — asks this rather than
|
|
89
|
+
// appending blind: the same line arriving twice would read as a recurrence.
|
|
90
|
+
function missing_lines(candidates: ReadonlyArray<string>, ledger: string): Array<string> {
|
|
91
|
+
const present = new Set(ledger.split('\n'))
|
|
92
|
+
|
|
93
|
+
return candidates.filter((line) => !present.has(line))
|
|
94
|
+
}
|
|
95
|
+
|
|
86
96
|
const observation_ledger_line = {
|
|
87
97
|
broken_ledger_lines,
|
|
88
98
|
is_ledger_entry_line,
|
|
89
99
|
line_reason,
|
|
100
|
+
missing_lines,
|
|
90
101
|
}
|
|
91
102
|
|
|
92
103
|
export type { BrokenLine }
|