@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,110 @@
|
|
|
1
|
+
import { git_spawn } from '#scripts/git/git-spawn'
|
|
2
|
+
import { git_stash } from '#scripts/git/git-stash'
|
|
3
|
+
import { lane_close } from '#scripts/lane/lane-close'
|
|
4
|
+
import { lane_registry, type LaneInfo } from '#scripts/lane/lane-registry'
|
|
5
|
+
import { run_hold } from './run-hold'
|
|
6
|
+
import { run_tidy, type Outcome, type Verdict } from './run-tidy'
|
|
7
|
+
|
|
8
|
+
// The lane half of `josh run:tidy` (joshuafolkken/kit#2701): each lane whose issue merged, read for
|
|
9
|
+
// what would make closing it lose work, then closed with its run record released. A lane whose work
|
|
10
|
+
// tree is already gone is `lane:prune`'s, so it is left to that command.
|
|
11
|
+
|
|
12
|
+
const CLOSED_KIND = 'closed'
|
|
13
|
+
const INCOMPLETE_REASON = 'the close left files behind'
|
|
14
|
+
const NOT_MERGED_REASON = 'not merged'
|
|
15
|
+
|
|
16
|
+
type IsMerged = (issue: string) => Promise<boolean>
|
|
17
|
+
|
|
18
|
+
// Any commit on the lane's branch that no remote-tracking ref reaches — `HEAD --not --remotes` — is
|
|
19
|
+
// work only this tree holds. A branch merged upstream is reached through the default branch even after
|
|
20
|
+
// its own remote branch was deleted, so a merged lane answers zero. A failed read keeps the lane.
|
|
21
|
+
async function has_unpushed(directory: string): Promise<boolean> {
|
|
22
|
+
try {
|
|
23
|
+
const count = await git_spawn.read([
|
|
24
|
+
'-C',
|
|
25
|
+
directory,
|
|
26
|
+
'rev-list',
|
|
27
|
+
'--count',
|
|
28
|
+
'HEAD',
|
|
29
|
+
'--not',
|
|
30
|
+
'--remotes',
|
|
31
|
+
])
|
|
32
|
+
|
|
33
|
+
return count !== '0'
|
|
34
|
+
} catch {
|
|
35
|
+
return true
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function hold_path_of(directory: string): Promise<string> {
|
|
40
|
+
const git_directory = await git_spawn.read(['-C', directory, 'rev-parse', '--absolute-git-dir'])
|
|
41
|
+
|
|
42
|
+
return run_hold.hold_path(git_directory)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// A live record belongs to a run still inside that tree — one finishing its post-merge steps — so the
|
|
46
|
+
// lane waits for the next sweep; an unreadable record is kept for the same reason.
|
|
47
|
+
function is_running(hold_path: string): boolean {
|
|
48
|
+
const read = run_hold.read_hold(hold_path)
|
|
49
|
+
|
|
50
|
+
return read.kind === 'held' || read.kind === 'unreadable'
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Asked only of a lane already read as merged, so the rule's "not merged" answer cannot come back.
|
|
54
|
+
async function lane_verdict(lane: LaneInfo, hold_path: string): Promise<Verdict> {
|
|
55
|
+
const verdict = run_tidy.lane_verdict({
|
|
56
|
+
issue: lane.issue,
|
|
57
|
+
is_merged: true,
|
|
58
|
+
has_changes: await git_stash.has_changes(lane.directory),
|
|
59
|
+
has_unpushed: await has_unpushed(lane.directory),
|
|
60
|
+
is_running: is_running(hold_path),
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
return verdict ?? run_tidy.keep(NOT_MERGED_REASON)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function close(issue: string, hold_path: string): Promise<Verdict> {
|
|
67
|
+
const outcome = await lane_close.close_lane(issue)
|
|
68
|
+
|
|
69
|
+
if (outcome.kind !== CLOSED_KIND) return run_tidy.keep(INCOMPLETE_REASON)
|
|
70
|
+
|
|
71
|
+
run_hold.release_hold(hold_path)
|
|
72
|
+
|
|
73
|
+
return run_tidy.CLEAN
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
async function settle(lane: LaneInfo): Promise<Verdict> {
|
|
77
|
+
try {
|
|
78
|
+
const hold_path = await hold_path_of(lane.directory)
|
|
79
|
+
const verdict = await lane_verdict(lane, hold_path)
|
|
80
|
+
|
|
81
|
+
return verdict.kind === 'clean' ? await close(lane.issue, hold_path) : verdict
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return run_tidy.keep(String(error))
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function tidy_lane(lane: LaneInfo, is_merged: IsMerged): Promise<Outcome | undefined> {
|
|
88
|
+
if (lane.is_stranded || !(await is_merged(lane.issue))) return undefined
|
|
89
|
+
|
|
90
|
+
return { target: `lane #${lane.issue}`, verdict: await settle(lane) }
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// One lane at a time: a close removes a work tree and prunes the worktree list the next read uses.
|
|
94
|
+
async function tidy_lanes(is_merged: IsMerged): Promise<Array<Outcome>> {
|
|
95
|
+
const outcomes: Array<Outcome> = []
|
|
96
|
+
const lanes = await lane_registry.list_lanes()
|
|
97
|
+
|
|
98
|
+
for (const lane of lanes) {
|
|
99
|
+
const outcome = await tidy_lane(lane, is_merged)
|
|
100
|
+
|
|
101
|
+
if (outcome !== undefined) outcomes.push(outcome)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return outcomes
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const run_tidy_lanes = { has_unpushed, tidy_lanes }
|
|
108
|
+
|
|
109
|
+
export type { IsMerged }
|
|
110
|
+
export { run_tidy_lanes }
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { git_stash, type StashEntry } from '#scripts/git/git-stash'
|
|
3
|
+
import { stash_orphans } from '#scripts/git/stash-orphans'
|
|
4
|
+
import { stash_sweep_lock } from '#scripts/git/stash-sweep-lock'
|
|
5
|
+
import { bounded_pool } from '#scripts/lib/bounded-pool'
|
|
6
|
+
import { observation_ledger } from '#scripts/observations/observation-ledger'
|
|
7
|
+
import { observation_ledger_home } from '#scripts/observations/observation-ledger-home'
|
|
8
|
+
import { run_tidy, type Outcome, type Verdict } from './run-tidy'
|
|
9
|
+
import type { IsMerged } from './run-tidy-lanes'
|
|
10
|
+
|
|
11
|
+
// The stash half of `josh run:tidy` (joshuafolkken/kit#2701): each entry whose named issues are all
|
|
12
|
+
// merged is dropped, and one that touches the observation ledger has its lines carried into the ledger
|
|
13
|
+
// first. The ledger is append-only and the digest counts repeats across it, so an entry's lines are
|
|
14
|
+
// never discarded with the entry.
|
|
15
|
+
|
|
16
|
+
// The same width `run-carry-stash.ts` reads issue states at.
|
|
17
|
+
const READ_CONCURRENCY = 8
|
|
18
|
+
const GONE_REASON = 'already dropped by another run'
|
|
19
|
+
const BUSY_REASON = 'another run held the stash sweep lock'
|
|
20
|
+
|
|
21
|
+
async function read_ledger(ledger_path: string): Promise<string> {
|
|
22
|
+
try {
|
|
23
|
+
return await readFile(ledger_path, 'utf8')
|
|
24
|
+
} catch {
|
|
25
|
+
return ''
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// An entry cut before joshuafolkken/kit#2724 holds its lines at the ledger's old path, so the lines of
|
|
30
|
+
// every ledger path it touches are carried to the current ledger.
|
|
31
|
+
async function added_ledger_lines(
|
|
32
|
+
hash: string,
|
|
33
|
+
ledger_paths: ReadonlyArray<string>,
|
|
34
|
+
): Promise<Array<string>> {
|
|
35
|
+
const added: Array<string> = []
|
|
36
|
+
|
|
37
|
+
for (const ledger_path of ledger_paths) {
|
|
38
|
+
const lines = await git_stash.added_lines(hash, ledger_path)
|
|
39
|
+
|
|
40
|
+
added.push(...lines)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return added
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// How many ledger lines went into the primary checkout's ledger before the drop.
|
|
47
|
+
async function carry_ledger(hash: string): Promise<number> {
|
|
48
|
+
const changed = await git_stash.changed_paths(hash)
|
|
49
|
+
const ledger_paths = changed.filter((file_path) => observation_ledger.is_ledger_path(file_path))
|
|
50
|
+
|
|
51
|
+
if (ledger_paths.length === 0) return 0
|
|
52
|
+
|
|
53
|
+
const ledger_path = observation_ledger_home.ledger_path()
|
|
54
|
+
const added = await added_ledger_lines(hash, ledger_paths)
|
|
55
|
+
const carried = run_tidy.ledger_carry(added, await read_ledger(ledger_path))
|
|
56
|
+
|
|
57
|
+
await observation_ledger_home.append(ledger_path, carried)
|
|
58
|
+
|
|
59
|
+
return carried.length
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function carried_note(count: number): string {
|
|
63
|
+
return count === 0 ? '' : ` (${String(count)} observation line(s) carried to the ledger)`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function drop(entry: StashEntry): Promise<Outcome> {
|
|
67
|
+
const carried = await carry_ledger(entry.selector)
|
|
68
|
+
const target = `stash "${entry.subject}"${carried_note(carried)}`
|
|
69
|
+
const is_dropped = await git_stash.drop_by_hash(entry.selector)
|
|
70
|
+
|
|
71
|
+
return { target, verdict: is_dropped ? run_tidy.CLEAN : run_tidy.keep(GONE_REASON) }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function merged_set(
|
|
75
|
+
issues: ReadonlyArray<string>,
|
|
76
|
+
is_merged: IsMerged,
|
|
77
|
+
): Promise<Set<string>> {
|
|
78
|
+
const answers = await bounded_pool.bounded_map(
|
|
79
|
+
issues,
|
|
80
|
+
READ_CONCURRENCY,
|
|
81
|
+
async (issue) => await is_merged(issue),
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
return new Set(issues.filter((_issue, index) => answers[index] === true))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function entry_verdict(entry: StashEntry, merged: ReadonlySet<string>): Verdict | undefined {
|
|
88
|
+
const issues = stash_orphans.issues_named(entry.subject)
|
|
89
|
+
|
|
90
|
+
return run_tidy.stash_verdict({ subject: entry.subject, issues }, merged)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function tidy_entry(
|
|
94
|
+
entry: StashEntry,
|
|
95
|
+
merged: ReadonlySet<string>,
|
|
96
|
+
): Promise<Outcome | undefined> {
|
|
97
|
+
const verdict = entry_verdict(entry, merged)
|
|
98
|
+
|
|
99
|
+
if (verdict === undefined) return undefined
|
|
100
|
+
|
|
101
|
+
if (verdict.kind === 'keep') return { target: `stash "${entry.subject}"`, verdict }
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
return await drop(entry)
|
|
105
|
+
} catch (error) {
|
|
106
|
+
return { target: `stash "${entry.subject}"`, verdict: run_tidy.keep(String(error)) }
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function tidy_entries(
|
|
111
|
+
entries: ReadonlyArray<StashEntry>,
|
|
112
|
+
merged: ReadonlySet<string>,
|
|
113
|
+
): Promise<Array<Outcome>> {
|
|
114
|
+
const outcomes: Array<Outcome> = []
|
|
115
|
+
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
const outcome = await tidy_entry(entry, merged)
|
|
118
|
+
|
|
119
|
+
if (outcome !== undefined) outcomes.push(outcome)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return outcomes
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// The report a sweep that never got the lock gives: every entry it would have dropped is kept.
|
|
126
|
+
function busy_outcome(entry: StashEntry, merged: ReadonlySet<string>): Outcome | undefined {
|
|
127
|
+
const verdict = entry_verdict(entry, merged)
|
|
128
|
+
|
|
129
|
+
if (verdict === undefined) return undefined
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
target: `stash "${entry.subject}"`,
|
|
133
|
+
verdict: verdict.kind === 'keep' ? verdict : run_tidy.keep(BUSY_REASON),
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function busy_outcomes(
|
|
138
|
+
entries: ReadonlyArray<StashEntry>,
|
|
139
|
+
merged: ReadonlySet<string>,
|
|
140
|
+
): Array<Outcome> {
|
|
141
|
+
return entries
|
|
142
|
+
.map((entry) => busy_outcome(entry, merged))
|
|
143
|
+
.filter((outcome): outcome is Outcome => outcome !== undefined)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Sequential drops under the repository-wide sweep lock: each resolves its position from its hash just
|
|
147
|
+
// before it goes, which holds only while no other sweep is shifting the stack or appending the same
|
|
148
|
+
// ledger lines. The GitHub reads stay outside the lock — they are the slow part and mutate nothing.
|
|
149
|
+
async function tidy_stashes(is_merged: IsMerged): Promise<Array<Outcome>> {
|
|
150
|
+
const entries = await git_stash.list_by_hash()
|
|
151
|
+
const issues = [...new Set(entries.flatMap((entry) => stash_orphans.issues_named(entry.subject)))]
|
|
152
|
+
const merged = await merged_set(issues, is_merged)
|
|
153
|
+
const outcomes = await stash_sweep_lock.with_lock(async () => await tidy_entries(entries, merged))
|
|
154
|
+
|
|
155
|
+
return outcomes ?? busy_outcomes(entries, merged)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const run_tidy_stashes = { tidy_stashes }
|
|
159
|
+
|
|
160
|
+
export { run_tidy_stashes }
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { observation_ledger_line } from '#scripts/observations/observation-ledger-line'
|
|
2
|
+
|
|
3
|
+
// The decisions behind `josh run:tidy` (joshuafolkken/kit#2701), kept apart from the git and GitHub
|
|
4
|
+
// reads so each can be pinned without either. A run start sweeps what merged work left behind — the
|
|
5
|
+
// lanes holding a seat after their branch merged, and the stashes of issues that are all merged — and
|
|
6
|
+
// every rule here is written so that doubt keeps the thing: a lane with any uncommitted change or any
|
|
7
|
+
// commit no remote has, and a stash naming any issue that is not merged or naming none at all, stay.
|
|
8
|
+
|
|
9
|
+
const LINE_SEPARATOR = '\n'
|
|
10
|
+
const CLEANED_HEADER = 'run:tidy — cleaned:'
|
|
11
|
+
const KEPT_HEADER = 'run:tidy — kept:'
|
|
12
|
+
const CHANGES_REASON = 'uncommitted changes'
|
|
13
|
+
const UNPUSHED_REASON = 'commits no remote has'
|
|
14
|
+
const RUNNING_REASON = 'held by a live run'
|
|
15
|
+
|
|
16
|
+
interface LaneFacts {
|
|
17
|
+
issue: string
|
|
18
|
+
is_merged: boolean
|
|
19
|
+
has_changes: boolean
|
|
20
|
+
has_unpushed: boolean
|
|
21
|
+
is_running: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface StashFacts {
|
|
25
|
+
subject: string
|
|
26
|
+
issues: ReadonlyArray<string>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Verdict = { kind: 'clean' } | { kind: 'keep'; reason: string }
|
|
30
|
+
|
|
31
|
+
interface Outcome {
|
|
32
|
+
target: string
|
|
33
|
+
verdict: Verdict
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CLEAN: Verdict = { kind: 'clean' }
|
|
37
|
+
|
|
38
|
+
function keep(reason: string): Verdict {
|
|
39
|
+
return { kind: 'keep', reason }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A lane whose issue is still open is some run's, so it is left out of the report entirely rather than
|
|
43
|
+
// listed as kept — the report names what merged work left behind, not every lane.
|
|
44
|
+
function lane_verdict(facts: LaneFacts): Verdict | undefined {
|
|
45
|
+
if (!facts.is_merged) return undefined
|
|
46
|
+
|
|
47
|
+
if (facts.has_changes) return keep(CHANGES_REASON)
|
|
48
|
+
|
|
49
|
+
if (facts.has_unpushed) return keep(UNPUSHED_REASON)
|
|
50
|
+
|
|
51
|
+
return facts.is_running ? keep(RUNNING_REASON) : CLEAN
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function unmerged_reason(unmerged: ReadonlyArray<string>): string {
|
|
55
|
+
const named = unmerged.map((issue) => `#${issue}`).join(', ')
|
|
56
|
+
|
|
57
|
+
return `${named} not merged`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Like a lane, a stash naming no merged issue is left out of the report: one naming no issue at all,
|
|
61
|
+
// or only open ones, is not merged work's residue, and listing it at every run start would bury the
|
|
62
|
+
// entries that are.
|
|
63
|
+
function stash_verdict(facts: StashFacts, merged: ReadonlySet<string>): Verdict | undefined {
|
|
64
|
+
const unmerged = facts.issues.filter((issue) => !merged.has(issue))
|
|
65
|
+
|
|
66
|
+
if (unmerged.length === facts.issues.length) return undefined
|
|
67
|
+
|
|
68
|
+
return unmerged.length === 0 ? CLEAN : keep(unmerged_reason(unmerged))
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The ledger lines a stash would carry away with it, less those the ledger already holds — the same
|
|
72
|
+
// line appended twice would read as a recurrence to the observation digest, which counts repeats.
|
|
73
|
+
function ledger_carry(added: ReadonlyArray<string>, ledger: string): Array<string> {
|
|
74
|
+
const entries = added.filter((line) => observation_ledger_line.is_ledger_entry_line(line))
|
|
75
|
+
|
|
76
|
+
return observation_ledger_line.missing_lines([...new Set(entries)], ledger)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function format_outcome(outcome: Outcome): string {
|
|
80
|
+
const suffix = outcome.verdict.kind === 'keep' ? ` — ${outcome.verdict.reason}` : ''
|
|
81
|
+
|
|
82
|
+
return ` ${outcome.target}${suffix}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function section(header: string, outcomes: ReadonlyArray<Outcome>): Array<string> {
|
|
86
|
+
return outcomes.length === 0 ? [] : [header, ...outcomes.map((item) => format_outcome(item))]
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// `undefined` when the sweep found nothing merged, so an ordinary run start prints nothing.
|
|
90
|
+
function format_report(outcomes: ReadonlyArray<Outcome>): string | undefined {
|
|
91
|
+
const cleaned = outcomes.filter((outcome) => outcome.verdict.kind === 'clean')
|
|
92
|
+
const kept = outcomes.filter((outcome) => outcome.verdict.kind === 'keep')
|
|
93
|
+
const lines = [...section(CLEANED_HEADER, cleaned), ...section(KEPT_HEADER, kept)]
|
|
94
|
+
|
|
95
|
+
return lines.length === 0 ? undefined : lines.join(LINE_SEPARATOR)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const run_tidy = { CLEAN, format_report, keep, lane_verdict, ledger_carry, stash_verdict }
|
|
99
|
+
|
|
100
|
+
export type { LaneFacts, Outcome, StashFacts, Verdict }
|
|
101
|
+
export { run_tidy }
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
3
|
+
import { execaSync } from 'execa'
|
|
4
|
+
|
|
5
|
+
// The workflows that install safe-chain on the runner. Both carry the release and the installer's
|
|
6
|
+
// SHA-256 as workflow-level env, so one pin per file moves every "Setup safe-chain" step at once
|
|
7
|
+
// (joshuafolkken/kit#2711). A consumer has only the first, and `josh sync` rewrites it from the
|
|
8
|
+
// template, so moving it anywhere but kit is harmless and short-lived.
|
|
9
|
+
const WORKFLOW_PATHS = ['.github/workflows/ci.yml', 'templates/workflows/ci.yml']
|
|
10
|
+
// `[ \t]` rather than `\s`: under the `m` flag `\s*` would run across line ends and swallow the
|
|
11
|
+
// blank line that follows the env block.
|
|
12
|
+
const VERSION_RE = /^(?<key>[ \t]*SAFE_CHAIN_INSTALLER_VERSION:[ \t]*)(?<value>\S+)[ \t]*$/mu
|
|
13
|
+
const SHA256_RE = /^(?<key>[ \t]*SAFE_CHAIN_INSTALLER_SHA256:[ \t]*)(?<value>\S+)[ \t]*$/mu
|
|
14
|
+
const VALUE_GROUP = 'value'
|
|
15
|
+
const DOWNLOAD_TIMEOUT_MS = 30_000
|
|
16
|
+
|
|
17
|
+
interface InstallerPin {
|
|
18
|
+
version: string
|
|
19
|
+
sha256: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function installer_url(version: string): string {
|
|
23
|
+
return `https://github.com/AikidoSec/safe-chain/releases/download/${version}/install-safe-chain.sh`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function extract_pinned_version(content: string): string | undefined {
|
|
27
|
+
return VERSION_RE.exec(content)?.groups?.[VALUE_GROUP]
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function rewrite_pin(content: string, pin: InstallerPin): string {
|
|
31
|
+
return content
|
|
32
|
+
.replace(VERSION_RE, (_match, key: string) => `${key}${pin.version}`)
|
|
33
|
+
.replace(SHA256_RE, (_match, key: string) => `${key}${pin.sha256}`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function fetch_installer_sha256(version: string): string | undefined {
|
|
37
|
+
// `--proto =https` keeps the release redirect from being followed onto plain HTTP.
|
|
38
|
+
const result = execaSync('curl', ['--proto', '=https', '-fsSL', installer_url(version)], {
|
|
39
|
+
encoding: 'buffer',
|
|
40
|
+
// The hash is of the file as published; execa would otherwise drop its trailing newline.
|
|
41
|
+
stripFinalNewline: false,
|
|
42
|
+
reject: false,
|
|
43
|
+
timeout: DOWNLOAD_TIMEOUT_MS,
|
|
44
|
+
})
|
|
45
|
+
if (result.exitCode !== 0 || result.stdout.length === 0) return undefined
|
|
46
|
+
|
|
47
|
+
return createHash('sha256').update(result.stdout).digest('hex')
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// The workflows whose pin lags `latest` — a missing file or one without the pin is not a target.
|
|
51
|
+
function stale_workflows(latest: string, workflow_paths: ReadonlyArray<string>): Array<string> {
|
|
52
|
+
return workflow_paths.filter((workflow_path) => {
|
|
53
|
+
if (!existsSync(workflow_path)) return false
|
|
54
|
+
const current = extract_pinned_version(readFileSync(workflow_path, 'utf8'))
|
|
55
|
+
|
|
56
|
+
return current !== undefined && current !== latest
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function write_pin(workflow_path: string, pin: InstallerPin): void {
|
|
61
|
+
console.info(`\n↑ Updating safe-chain installer in ${workflow_path} → ${pin.version}`)
|
|
62
|
+
writeFileSync(workflow_path, rewrite_pin(readFileSync(workflow_path, 'utf8'), pin), 'utf8')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sync(latest: string, workflow_paths: ReadonlyArray<string> = WORKFLOW_PATHS): void {
|
|
66
|
+
const targets = stale_workflows(latest, workflow_paths)
|
|
67
|
+
if (targets.length === 0) return
|
|
68
|
+
|
|
69
|
+
const sha256 = fetch_installer_sha256(latest)
|
|
70
|
+
|
|
71
|
+
if (sha256 === undefined) {
|
|
72
|
+
console.warn(`\n⚠ Could not download the safe-chain ${latest} installer; CI pin left as is`)
|
|
73
|
+
|
|
74
|
+
return
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const workflow_path of targets) write_pin(workflow_path, { version: latest, sha256 })
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const ci_installer_pin = {
|
|
81
|
+
WORKFLOW_PATHS,
|
|
82
|
+
installer_url,
|
|
83
|
+
extract_pinned_version,
|
|
84
|
+
rewrite_pin,
|
|
85
|
+
fetch_installer_sha256,
|
|
86
|
+
sync,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export { ci_installer_pin }
|
|
90
|
+
export type { InstallerPin }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// `setup-ci` only writes shims and adds them to PATH on a CI runner, so a local `pnpm install` is
|
|
2
|
+
// scanned only when the developer has enabled safe-chain's own shell integration. That integration
|
|
3
|
+
// runs pnpm behind safe-chain's registry proxy and hands `GLOBAL_AGENT_HTTP_PROXY` to it, which a bare
|
|
4
|
+
// pnpm never sets — the one signal a lifecycle script can read (joshuafolkken/kit#2707).
|
|
5
|
+
const SETUP_CI_CMD = 'pnpm dlx @aikidosec/safe-chain setup-ci'
|
|
6
|
+
const LEGACY_PREINSTALL_RE = /^pnpm dlx @aikidosec\/safe-chain(?:@\S+)? setup-ci$/u
|
|
7
|
+
const INTEGRATION_HINT =
|
|
8
|
+
'Warning: safe-chain is not scanning this install because its shell integration is not active.' +
|
|
9
|
+
' Install safe-chain (https://github.com/AikidoSec/safe-chain#installation) or run: safe-chain setup' +
|
|
10
|
+
' - then restart your terminal.'
|
|
11
|
+
// `preinstall` runs before any dependency is installed, kit included, so the check is plain Node with
|
|
12
|
+
// no imports. It never fails the install and stays silent on CI, where the runner is set up instead.
|
|
13
|
+
// The source avoids double quotes, `$` and backticks so the shell passes it to Node verbatim.
|
|
14
|
+
const LOCAL_INTEGRATION_CHECK_SOURCE = `if(!process.env.CI&&!process.env.GLOBAL_AGENT_HTTP_PROXY)console.warn('${INTEGRATION_HINT}')`
|
|
15
|
+
const LOCAL_INTEGRATION_CHECK_CMD = `node -e "${LOCAL_INTEGRATION_CHECK_SOURCE}"`
|
|
16
|
+
|
|
17
|
+
function with_local_check(setup_command: string): string {
|
|
18
|
+
return `${setup_command} && ${LOCAL_INTEGRATION_CHECK_CMD}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Upgrade a `preinstall` kit wrote before the check existed, pinned version included; any other
|
|
22
|
+
// value is the consumer's own and is left exactly as it is.
|
|
23
|
+
function migrate_preinstall(value: string): string {
|
|
24
|
+
if (!LEGACY_PREINSTALL_RE.test(value)) return value
|
|
25
|
+
|
|
26
|
+
return with_local_check(value)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const SAFE_CHAIN_CMD = with_local_check(SETUP_CI_CMD)
|
|
30
|
+
|
|
31
|
+
const safe_chain_preinstall = {
|
|
32
|
+
SAFE_CHAIN_CMD,
|
|
33
|
+
LOCAL_INTEGRATION_CHECK_CMD,
|
|
34
|
+
LOCAL_INTEGRATION_CHECK_SOURCE,
|
|
35
|
+
INTEGRATION_HINT,
|
|
36
|
+
migrate_preinstall,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export { safe_chain_preinstall }
|
package/scripts/sync/sync.ts
CHANGED
|
@@ -135,7 +135,10 @@ function did_migrate_prettierrc(destination_path: string): boolean {
|
|
|
135
135
|
|
|
136
136
|
const existing = readFileSync(legacy_path, 'utf8')
|
|
137
137
|
|
|
138
|
-
writeFileSync(
|
|
138
|
+
writeFileSync(
|
|
139
|
+
destination_path,
|
|
140
|
+
init_logic.merge_prettier_config(existing, path.dirname(destination_path)),
|
|
141
|
+
)
|
|
139
142
|
rmSync(legacy_path)
|
|
140
143
|
|
|
141
144
|
return true
|
|
@@ -143,7 +146,7 @@ function did_migrate_prettierrc(destination_path: string): boolean {
|
|
|
143
146
|
|
|
144
147
|
function write_merged_prettier_config(destination_path: string): void {
|
|
145
148
|
const existing = readFileSync(destination_path, 'utf8')
|
|
146
|
-
const merged = init_logic.merge_prettier_config(existing)
|
|
149
|
+
const merged = init_logic.merge_prettier_config(existing, path.dirname(destination_path))
|
|
147
150
|
|
|
148
151
|
if (merged === existing) {
|
|
149
152
|
console.info(' ✔ unchanged prettier.config.js')
|
|
@@ -212,7 +212,6 @@ const PILOT_FILES: ReadonlyArray<string> = [
|
|
|
212
212
|
'scripts/init/init-logic-migrate.test.ts',
|
|
213
213
|
'scripts/init/init-logic-secretlint.test.ts',
|
|
214
214
|
'scripts/init/init-logic-sonar.test.ts',
|
|
215
|
-
'scripts/init/init-logic-templates.test.ts',
|
|
216
215
|
'scripts/init/init-logic-workspace.test.ts',
|
|
217
216
|
'scripts/init/init-logic.cspell.test.ts',
|
|
218
217
|
'scripts/init/init-logic.merge.test.ts',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs'
|
|
2
|
+
import { ci_installer_pin } from '#scripts/safe-chain/ci-installer-pin'
|
|
2
3
|
import { execaSync } from 'execa'
|
|
3
4
|
|
|
4
5
|
const SAFE_CHAIN_PKG = '@aikidosec/safe-chain'
|
|
@@ -72,6 +73,7 @@ function sync(package_json_path: string): void {
|
|
|
72
73
|
}
|
|
73
74
|
|
|
74
75
|
write_if_updated(package_json_path, info.content, info.current_version, latest)
|
|
76
|
+
ci_installer_pin.sync(latest)
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
const preinstall_version_update = {
|
|
@@ -4,9 +4,13 @@ on:
|
|
|
4
4
|
push:
|
|
5
5
|
branches: [main]
|
|
6
6
|
|
|
7
|
+
# No `labeled` / `unlabeled`: a label added right after the pull request opens started a second
|
|
8
|
+
# run on the same commit, the concurrency block below cancelled one of the two, and a cancelled
|
|
9
|
+
# run left in the newer check suite blocked the merge (joshuafolkken/kit#2712). The one check that
|
|
10
|
+
# reads labels runs in `pr-classification.yml` instead.
|
|
7
11
|
pull_request:
|
|
8
12
|
branches: [main]
|
|
9
|
-
types: [opened, synchronize, reopened
|
|
13
|
+
types: [opened, synchronize, reopened]
|
|
10
14
|
|
|
11
15
|
# A push run is grouped by the commit it carries, so a later push to the default branch never
|
|
12
16
|
# cancels an earlier one. Without the sha every such push shares one group — `github.ref` is
|
|
@@ -25,6 +29,12 @@ concurrency:
|
|
|
25
29
|
# list (e.g. `chromium firefox webkit`) instead of editing this file, which josh sync overwrites.
|
|
26
30
|
env:
|
|
27
31
|
JOSH_PLAYWRIGHT_BROWSERS: ${{ vars.JOSH_PLAYWRIGHT_BROWSERS || 'chromium' }}
|
|
32
|
+
# The safe-chain release every "Setup safe-chain" step installs, and the SHA-256 of that release's
|
|
33
|
+
# install-safe-chain.sh. The installer puts the safe-chain binary under ~/.safe-chain/bin and
|
|
34
|
+
# adds it to PATH beside the shims; `pnpm dlx … setup-ci` left only the shims, which then let
|
|
35
|
+
# every install through without a scan (joshuafolkken/kit#2711). `josh latest` moves both together.
|
|
36
|
+
SAFE_CHAIN_INSTALLER_VERSION: 1.5.20
|
|
37
|
+
SAFE_CHAIN_INSTALLER_SHA256: 0ad25efe15d1fa56105157a454d647223e78eb0c53d1f85e3d10afcd722e7bfd
|
|
28
38
|
|
|
29
39
|
jobs:
|
|
30
40
|
playwright-image:
|
|
@@ -179,7 +189,11 @@ jobs:
|
|
|
179
189
|
${{ runner.os }}-cspell-cache-
|
|
180
190
|
|
|
181
191
|
- name: Setup safe-chain
|
|
182
|
-
run:
|
|
192
|
+
run: |
|
|
193
|
+
installer="$RUNNER_TEMP/install-safe-chain.sh"
|
|
194
|
+
curl --proto '=https' -fsSL "https://github.com/AikidoSec/safe-chain/releases/download/$SAFE_CHAIN_INSTALLER_VERSION/install-safe-chain.sh" -o "$installer"
|
|
195
|
+
echo "$SAFE_CHAIN_INSTALLER_SHA256 $installer" | sha256sum -c -
|
|
196
|
+
sh "$installer" --ci
|
|
183
197
|
|
|
184
198
|
- name: Install dependencies
|
|
185
199
|
env:
|
|
@@ -221,10 +235,6 @@ jobs:
|
|
|
221
235
|
- name: Run unit tests
|
|
222
236
|
run: pnpm josh test:unit
|
|
223
237
|
|
|
224
|
-
- name: Check release classification
|
|
225
|
-
if: github.event_name == 'pull_request'
|
|
226
|
-
run: pnpm josh pr:classification
|
|
227
|
-
|
|
228
238
|
- name: Check bundle size
|
|
229
239
|
run: pnpm size-limit
|
|
230
240
|
|
|
@@ -405,7 +415,11 @@ jobs:
|
|
|
405
415
|
${{ runner.os }}-build-cache-e2e-
|
|
406
416
|
|
|
407
417
|
- name: Setup safe-chain
|
|
408
|
-
run:
|
|
418
|
+
run: |
|
|
419
|
+
installer="$RUNNER_TEMP/install-safe-chain.sh"
|
|
420
|
+
curl --proto '=https' -fsSL "https://github.com/AikidoSec/safe-chain/releases/download/$SAFE_CHAIN_INSTALLER_VERSION/install-safe-chain.sh" -o "$installer"
|
|
421
|
+
echo "$SAFE_CHAIN_INSTALLER_SHA256 $installer" | sha256sum -c -
|
|
422
|
+
sh "$installer" --ci
|
|
409
423
|
|
|
410
424
|
- name: Install dependencies
|
|
411
425
|
env:
|
package/tsconfig/base.json
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"compilerOptions": {
|
|
3
3
|
"strict": true,
|
|
4
|
-
// tsx uses bundler-like resolution; "bundler" is the correct pairing for module:ESNext
|
|
4
|
+
// tsx uses bundler-like resolution; "bundler" is the correct pairing for module:ESNext.
|
|
5
|
+
// `module` is stated rather than inherited: TypeScript 5.x defaults it to a value "bundler"
|
|
6
|
+
// rejects (TS5095), so a consumer pinned below 6 failed its first check (#2725)
|
|
7
|
+
"module": "ESNext",
|
|
5
8
|
"moduleResolution": "bundler",
|
|
6
9
|
"esModuleInterop": true,
|
|
7
10
|
"forceConsistentCasingInFileNames": true,
|