@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
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync, type Dirent } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { repo_origin } from '#scripts/discovery/repo-origin'
|
|
4
|
+
import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
|
|
4
5
|
import { execaSync } from 'execa'
|
|
5
6
|
|
|
6
7
|
type ProjectProfile = 'static' | 'node'
|
|
@@ -52,10 +53,6 @@ function read_manifest(root: string): Record<string, unknown> | undefined {
|
|
|
52
53
|
return parsed as Record<string, unknown>
|
|
53
54
|
}
|
|
54
55
|
|
|
55
|
-
function has_entries(value: unknown): boolean {
|
|
56
|
-
return typeof value === 'object' && value !== null && Object.keys(value).length > 0
|
|
57
|
-
}
|
|
58
|
-
|
|
59
56
|
function is_record(value: unknown): value is Record<string, unknown> {
|
|
60
57
|
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
61
58
|
}
|
|
@@ -70,8 +67,20 @@ function has_build_script(value: unknown): boolean {
|
|
|
70
67
|
return is_record(value) && ('build' in value || 'dev' in value)
|
|
71
68
|
}
|
|
72
69
|
|
|
70
|
+
// kit itself is not evidence of a Node toolchain: `pnpm add -D @joshuafolkken/kit` before
|
|
71
|
+
// `josh init` is the documented order, and it must still leave an `index.html` site static
|
|
72
|
+
// (joshuafolkken/kit#2693).
|
|
73
|
+
function has_project_dependencies(value: unknown): boolean {
|
|
74
|
+
if (!is_record(value)) return false
|
|
75
|
+
|
|
76
|
+
return Object.keys(value).some((name) => name !== KIT_PACKAGE_NAME)
|
|
77
|
+
}
|
|
78
|
+
|
|
73
79
|
function inferred_profile(manifest: Record<string, unknown>): ProfileResult {
|
|
74
|
-
if (
|
|
80
|
+
if (
|
|
81
|
+
has_project_dependencies(manifest['dependencies']) ||
|
|
82
|
+
has_project_dependencies(manifest['devDependencies'])
|
|
83
|
+
) {
|
|
75
84
|
return { profile: 'node', reason: 'package dependencies' }
|
|
76
85
|
}
|
|
77
86
|
|
|
@@ -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,10 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
ESLINT_CACHE_FLAGS,
|
|
3
|
-
GATE_COMMAND,
|
|
4
|
-
PE,
|
|
5
|
-
TS_CACHE_FLAGS,
|
|
6
|
-
type CommandEntry,
|
|
7
|
-
} from './josh-command-types'
|
|
1
|
+
import { GATE_COMMAND, type CommandEntry } from './josh-command-types'
|
|
8
2
|
|
|
9
3
|
const FILE_ARGUMENTS = '[files...]'
|
|
10
4
|
const REQUIRED_FILE_ARGUMENTS = '<files...>'
|
|
@@ -28,7 +22,7 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
28
22
|
},
|
|
29
23
|
lint: {
|
|
30
24
|
script: 'scripts/lint/lint-parallel.ts',
|
|
31
|
-
description: 'Check code with prettier and eslint',
|
|
25
|
+
description: 'Check code with prettier and eslint (skips a tool a static project lacks)',
|
|
32
26
|
category: 'Development',
|
|
33
27
|
reference: ['', 'developer', ['processes']],
|
|
34
28
|
},
|
|
@@ -66,15 +60,8 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
66
60
|
reference: ['', 'developer', ['none']],
|
|
67
61
|
},
|
|
68
62
|
format: {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
// unused variable anywhere in the tree stops prettier from running at all.
|
|
72
|
-
shell: [
|
|
73
|
-
'sh',
|
|
74
|
-
'-c',
|
|
75
|
-
`pnpm exec prettier --write . && pnpm exec eslint . --fix ${ESLINT_CACHE_FLAGS.join(' ')}`,
|
|
76
|
-
],
|
|
77
|
-
description: 'Format code with prettier and eslint',
|
|
63
|
+
script: 'scripts/lint/format.ts',
|
|
64
|
+
description: 'Format code with prettier and eslint (skips a tool a static project lacks)',
|
|
78
65
|
category: 'Development',
|
|
79
66
|
reference: ['', 'developer', ['files', 'processes']],
|
|
80
67
|
},
|
|
@@ -195,8 +182,8 @@ const DEV_COMMANDS: Record<string, CommandEntry> = {
|
|
|
195
182
|
argument_targets: ['test:unit', 'test:e2e'],
|
|
196
183
|
},
|
|
197
184
|
check: {
|
|
198
|
-
|
|
199
|
-
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)',
|
|
200
187
|
category: 'Development',
|
|
201
188
|
reference: ['[arguments...]', 'developer', ['processes']],
|
|
202
189
|
},
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { project_checks } from '#scripts/gate/project-checks'
|
|
4
|
+
import { ESLINT_CACHE_FLAGS } from '#scripts/josh/josh-command-types'
|
|
5
|
+
import { composite_arguments, USAGE_ERROR_EXIT_CODE } from '#scripts/josh/josh-composite-arguments'
|
|
6
|
+
import { execa } from 'execa'
|
|
7
|
+
|
|
8
|
+
const COMMAND_NAME = 'format'
|
|
9
|
+
const ARGV_OFFSET = 2
|
|
10
|
+
const FAIL_EXIT_CODE = 1
|
|
11
|
+
const PRETTIER_ARGS = ['exec', 'prettier', '--write', '.'] as const
|
|
12
|
+
const ESLINT_FIX_ARGS = ['exec', 'eslint', '.', '--fix', ...ESLINT_CACHE_FLAGS] as const
|
|
13
|
+
|
|
14
|
+
async function run_step(args: ReadonlyArray<string>, directory: string): Promise<number> {
|
|
15
|
+
const result = await execa('pnpm', args, { cwd: directory, stdio: 'inherit', reject: false })
|
|
16
|
+
|
|
17
|
+
return result.exitCode ?? FAIL_EXIT_CODE
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// prettier first, deliberately: `eslint --fix` exits 1 whenever a non-autofixable error remains, so
|
|
21
|
+
// putting it first would mean one unused variable anywhere in the tree stops prettier from running.
|
|
22
|
+
// A static project without Prettier or ESLint is skipped with the same reason `josh lint` prints
|
|
23
|
+
// (joshuafolkken/kit#2693, joshuafolkken/kit#2606).
|
|
24
|
+
async function run_unless_skipped(
|
|
25
|
+
check: string,
|
|
26
|
+
reason: string | undefined,
|
|
27
|
+
args: ReadonlyArray<string>,
|
|
28
|
+
directory: string,
|
|
29
|
+
): Promise<number> {
|
|
30
|
+
if (reason === undefined) return await run_step(args, directory)
|
|
31
|
+
console.info(project_checks.skip_notice(check, reason))
|
|
32
|
+
|
|
33
|
+
return 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function run_format(directory: string): Promise<number> {
|
|
37
|
+
const prettier_reason = project_checks.prettier_skip_reason(directory)
|
|
38
|
+
const prettier_exit_code = await run_unless_skipped(
|
|
39
|
+
'prettier',
|
|
40
|
+
prettier_reason,
|
|
41
|
+
PRETTIER_ARGS,
|
|
42
|
+
directory,
|
|
43
|
+
)
|
|
44
|
+
if (prettier_exit_code !== 0) return prettier_exit_code
|
|
45
|
+
const eslint_reason = project_checks.eslint_skip_reason(directory)
|
|
46
|
+
|
|
47
|
+
return await run_unless_skipped('eslint', eslint_reason, ESLINT_FIX_ARGS, directory)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The argument refusal survives leaving the `sh -c` composite shape: both tools run over the whole
|
|
51
|
+
// tree, so a path passed here would be silently discarded and every file rewritten instead.
|
|
52
|
+
async function run(argv: ReadonlyArray<string>, directory: string): Promise<number> {
|
|
53
|
+
if (argv.length > 0) {
|
|
54
|
+
console.error(composite_arguments.format_rejection(COMMAND_NAME, []))
|
|
55
|
+
|
|
56
|
+
return USAGE_ERROR_EXIT_CODE
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return await run_format(directory)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
63
|
+
process.exitCode = await run(process.argv.slice(ARGV_OFFSET), process.cwd())
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const format = { run, run_format }
|
|
67
|
+
|
|
68
|
+
export { ESLINT_FIX_ARGS, format }
|
|
@@ -34,32 +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
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
if (
|
|
43
|
-
return skipped('prettier', 'prettier is not installed')
|
|
44
|
-
}
|
|
37
|
+
async function run_prettier(
|
|
38
|
+
directory: string,
|
|
39
|
+
prettier_args: ReadonlyArray<string>,
|
|
40
|
+
): Promise<BufferedProcessResult> {
|
|
41
|
+
const reason = project_checks.prettier_skip_reason(directory)
|
|
42
|
+
if (reason !== undefined) return skipped('prettier', reason)
|
|
45
43
|
|
|
46
|
-
return await buffered_process.run_buffered_process(
|
|
44
|
+
return await buffered_process.run_buffered_process(prettier_args)
|
|
47
45
|
}
|
|
48
46
|
|
|
49
|
-
async function
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
if (!project_checks.has_bin(directory, 'eslint')) {
|
|
59
|
-
return skipped('eslint', 'eslint is not installed')
|
|
60
|
-
}
|
|
47
|
+
async function run_checked_eslint(
|
|
48
|
+
directory: string,
|
|
49
|
+
eslint_args: ReadonlyArray<string>,
|
|
50
|
+
cache_file: string,
|
|
51
|
+
): Promise<BufferedProcessResult> {
|
|
52
|
+
const reason = project_checks.eslint_skip_reason(directory)
|
|
53
|
+
if (reason !== undefined) return skipped('eslint', reason)
|
|
61
54
|
|
|
62
|
-
return await run_eslint(
|
|
55
|
+
return await run_eslint(eslint_args, cache_file)
|
|
63
56
|
}
|
|
64
57
|
|
|
65
58
|
function lint_exit_code(prettier: BufferedProcessResult, eslint: BufferedProcessResult): number {
|
|
@@ -70,15 +63,19 @@ function lint_exit_code(prettier: BufferedProcessResult, eslint: BufferedProcess
|
|
|
70
63
|
|
|
71
64
|
// The two checks are run from here whether they were pointed at the whole tree or at one change's
|
|
72
65
|
// files, so `josh lint:related` reaches prettier and eslint through the same buffering, the same
|
|
73
|
-
// "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.
|
|
74
70
|
async function run_lint_checks(
|
|
75
71
|
prettier_args: ReadonlyArray<string>,
|
|
76
72
|
eslint_args: ReadonlyArray<string>,
|
|
77
73
|
eslint_cache_file: string = ESLINT_CACHE_FILE,
|
|
78
74
|
): Promise<number> {
|
|
75
|
+
const directory = process.cwd()
|
|
79
76
|
const [prettier, eslint] = await Promise.all([
|
|
80
|
-
|
|
81
|
-
|
|
77
|
+
run_prettier(directory, prettier_args),
|
|
78
|
+
run_checked_eslint(directory, eslint_args, eslint_cache_file),
|
|
82
79
|
])
|
|
83
80
|
|
|
84
81
|
write_output(prettier)
|
|
@@ -88,17 +85,7 @@ async function run_lint_checks(
|
|
|
88
85
|
}
|
|
89
86
|
|
|
90
87
|
async function run_lint_parallel_checks(): Promise<number> {
|
|
91
|
-
|
|
92
|
-
if (!project_checks.is_static(directory)) return await run_lint_checks(PRETTIER_ARGS, ESLINT_ARGS)
|
|
93
|
-
const [prettier, eslint] = await Promise.all([
|
|
94
|
-
run_static_prettier(directory),
|
|
95
|
-
run_static_eslint(directory),
|
|
96
|
-
])
|
|
97
|
-
|
|
98
|
-
write_output(prettier)
|
|
99
|
-
write_output(eslint)
|
|
100
|
-
|
|
101
|
-
return lint_exit_code(prettier, eslint)
|
|
88
|
+
return await run_lint_checks(PRETTIER_ARGS, ESLINT_ARGS)
|
|
102
89
|
}
|
|
103
90
|
|
|
104
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 }
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync } from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
LEGACY_OBSERVATION_LEDGER_PATH,
|
|
5
|
+
MIGRATION_CLAIM_SUFFIX,
|
|
6
|
+
OBSERVATION_LEDGER_PATH,
|
|
7
|
+
} from './observation-ledger'
|
|
8
|
+
import { observation_ledger_line } from './observation-ledger-line'
|
|
9
|
+
|
|
10
|
+
// Moves the ledger's lines from the path it had before joshuafolkken/kit#2724 to the one it has now
|
|
11
|
+
// (`observation-ledger.ts` carries why both are recognized). Changing the constant alone split the
|
|
12
|
+
// appends across two files: a run still on the old code recreated `docs/observations.md`, and a
|
|
13
|
+
// consumer repository's existing ledger stayed where nothing read it. So resolving the ledger's path
|
|
14
|
+
// migrates first (`observation-ledger-home.ts`), and whatever reached the old path lands at the tail
|
|
15
|
+
// of the new one before anything reads or appends.
|
|
16
|
+
//
|
|
17
|
+
// **The old file is claimed with a rename before it is read.** Two sessions resolving the ledger at
|
|
18
|
+
// once would otherwise both read it and both copy its lines — and a repeated line under one key is
|
|
19
|
+
// what promotes an observation to an issue. A rename succeeds for exactly one of them; the other finds
|
|
20
|
+
// nothing.
|
|
21
|
+
//
|
|
22
|
+
// **A claim whose process is gone is absorbed like the old file itself.** A process killed between the
|
|
23
|
+
// rename and the append leaves the ledger's lines only in the claim file; the next migration finds it
|
|
24
|
+
// by its name and moves it, so no line is stranded where nothing looks.
|
|
25
|
+
//
|
|
26
|
+
// **Synchronous on purpose**: the path resolver every reader and writer calls is synchronous, and a
|
|
27
|
+
// migration with no await point cannot be interleaved with another write in the same process.
|
|
28
|
+
|
|
29
|
+
const NEWLINE = '\n'
|
|
30
|
+
const NO_SIGNAL = 0
|
|
31
|
+
const NO_SUCH_PROCESS = 'ESRCH'
|
|
32
|
+
|
|
33
|
+
function claim_path(legacy: string): string {
|
|
34
|
+
return `${legacy}.${String(process.pid)}${MIGRATION_CLAIM_SUFFIX}`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function claim(from: string, to: string): boolean {
|
|
38
|
+
try {
|
|
39
|
+
renameSync(from, to)
|
|
40
|
+
|
|
41
|
+
return true
|
|
42
|
+
} catch {
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Only `ESRCH` means the process is gone; `EPERM` means it runs under another user, and its claim is
|
|
48
|
+
// still its own.
|
|
49
|
+
function is_alive(pid: number): boolean {
|
|
50
|
+
try {
|
|
51
|
+
process.kill(pid, NO_SIGNAL)
|
|
52
|
+
|
|
53
|
+
return true
|
|
54
|
+
} catch (error) {
|
|
55
|
+
return (error as NodeJS.ErrnoException).code !== NO_SUCH_PROCESS
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A claim is named `<old ledger>.<pid>.migrating`, so the pid is what sits between the two.
|
|
60
|
+
function is_stale_claim(name: string, legacy_name: string): boolean {
|
|
61
|
+
const prefix = `${legacy_name}.`
|
|
62
|
+
|
|
63
|
+
if (!name.startsWith(prefix) || !name.endsWith(MIGRATION_CLAIM_SUFFIX)) return false
|
|
64
|
+
|
|
65
|
+
const pid = Number(name.slice(prefix.length, -MIGRATION_CLAIM_SUFFIX.length))
|
|
66
|
+
|
|
67
|
+
return Number.isSafeInteger(pid) && !is_alive(pid)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function stale_claims(legacy: string): ReadonlyArray<string> {
|
|
71
|
+
const directory = path.dirname(legacy)
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
return readdirSync(directory)
|
|
75
|
+
.filter((name) => is_stale_claim(name, path.basename(legacy)))
|
|
76
|
+
.map((name) => path.join(directory, name))
|
|
77
|
+
} catch {
|
|
78
|
+
return []
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function terminated(content: string): string {
|
|
83
|
+
return content.length === 0 || content.endsWith(NEWLINE) ? content : `${content}${NEWLINE}`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function read_or_undefined(file_path: string): string | undefined {
|
|
87
|
+
try {
|
|
88
|
+
return readFileSync(file_path, 'utf8')
|
|
89
|
+
} catch {
|
|
90
|
+
return undefined
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// What of the claimed file goes to the ledger's tail: all of it, verbatim, when there is no ledger yet,
|
|
95
|
+
// and otherwise only the lines the ledger does not already hold. **The old file can come back after
|
|
96
|
+
// its lines were moved** — `git restore`, `git reset --hard` or a stash without `-u` restores a
|
|
97
|
+
// tracked old ledger beside the untracked new one — and copying it whole again would repeat every line
|
|
98
|
+
// in the history, which the digest reads as every observation recurring.
|
|
99
|
+
function carried(content: string, ledger: string | undefined): string {
|
|
100
|
+
if (ledger === undefined) return terminated(content)
|
|
101
|
+
|
|
102
|
+
const lines = content.split(NEWLINE).filter((line) => line.length > 0)
|
|
103
|
+
const missing = observation_ledger_line.missing_lines(lines, ledger)
|
|
104
|
+
|
|
105
|
+
return missing.length === 0 ? '' : `${missing.join(NEWLINE)}${NEWLINE}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Moves one claimed file's lines to the tail of the ledger, then removes the claim.
|
|
109
|
+
function absorb(claimed: string, target: string): void {
|
|
110
|
+
mkdirSync(path.dirname(target), { recursive: true })
|
|
111
|
+
appendFileSync(target, carried(readFileSync(claimed, 'utf8'), read_or_undefined(target)), 'utf8')
|
|
112
|
+
rmSync(claimed)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function absorb_stale(legacy: string, target: string): number {
|
|
116
|
+
let count = 0
|
|
117
|
+
|
|
118
|
+
for (const stale of stale_claims(legacy)) {
|
|
119
|
+
const claimed = claim_path(legacy)
|
|
120
|
+
|
|
121
|
+
if (!claim(stale, claimed)) continue
|
|
122
|
+
|
|
123
|
+
absorb(claimed, target)
|
|
124
|
+
count += 1
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return count
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Returns whether anything was moved. `root` is the checkout the ledger lives in.
|
|
131
|
+
function migrate(root: string): boolean {
|
|
132
|
+
const legacy = path.join(root, LEGACY_OBSERVATION_LEDGER_PATH)
|
|
133
|
+
const target = path.join(root, OBSERVATION_LEDGER_PATH)
|
|
134
|
+
const recovered = absorb_stale(legacy, target)
|
|
135
|
+
const claimed = claim_path(legacy)
|
|
136
|
+
|
|
137
|
+
if (!claim(legacy, claimed)) return recovered > 0
|
|
138
|
+
|
|
139
|
+
absorb(claimed, target)
|
|
140
|
+
|
|
141
|
+
return true
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const observation_ledger_migrate = { claim_path, migrate }
|
|
145
|
+
|
|
146
|
+
export { observation_ledger_migrate }
|