@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
|
@@ -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 }
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
|
+
import { release_age } from '#scripts/version/release-age'
|
|
3
4
|
import { yaml_document } from '#scripts/yaml/yaml-document'
|
|
4
5
|
import { dump } from 'js-yaml'
|
|
5
6
|
import { z } from 'zod'
|
|
6
7
|
|
|
7
8
|
const WORKSPACE_FILE = 'pnpm-workspace.yaml'
|
|
8
9
|
const PROJECT_CONFIG_FILE = '.aikido'
|
|
10
|
+
const NPMRC_FILE = '.npmrc'
|
|
9
11
|
const EXCLUSIONS_FIELD = 'minimumPackageAgeExclusions'
|
|
12
|
+
const AGE_FIELD = 'minimumPackageAgeHours'
|
|
13
|
+
const MINUTES_PER_HOUR = 60
|
|
10
14
|
const workspace_schema = z.object({ minimumReleaseAgeExclude: z.array(z.string()).optional() })
|
|
11
15
|
|
|
12
16
|
function read_mapping(value: unknown, label: string): Record<string, unknown> {
|
|
@@ -72,27 +76,68 @@ function replace_safe_chain_block(existing: string, safe_chain: Record<string, u
|
|
|
72
76
|
return `${prefix}${prefix ? '\n' : ''}${replacement}${suffix}`
|
|
73
77
|
}
|
|
74
78
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
79
|
+
// Safe Chain enforces its own minimum package age (48 hours by default), so CI refused lockfiles pnpm
|
|
80
|
+
// had resolved under `.npmrc`'s shorter window (kit #2743). `.npmrc` is the source: its minutes are
|
|
81
|
+
// floored to whole hours so Safe Chain is never stricter than pnpm, and an undeclared window writes
|
|
82
|
+
// nothing; an explicit `0` opt-out is written as 0 so an earlier synchronized age cannot outlive it.
|
|
83
|
+
function package_age_hours(npmrc: string): number | undefined {
|
|
84
|
+
const minutes = release_age.parse_declared_minimum_release_age(npmrc)
|
|
85
|
+
if (minutes === undefined) return undefined
|
|
86
|
+
|
|
87
|
+
return Math.floor(minutes / MINUTES_PER_HOUR)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function merge_age(safe_chain: Record<string, unknown>, npmrc: string): Record<string, unknown> {
|
|
91
|
+
const hours = package_age_hours(npmrc)
|
|
92
|
+
if (hours === undefined) return safe_chain
|
|
93
|
+
|
|
94
|
+
return { ...safe_chain, [AGE_FIELD]: hours }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function merge_exclusions(
|
|
98
|
+
safe_chain: Record<string, unknown>,
|
|
99
|
+
exclusions: ReadonlyArray<string>,
|
|
100
|
+
): Record<string, unknown> {
|
|
80
101
|
const npm = read_mapping(safe_chain['npm'], 'safe-chain.npm')
|
|
81
|
-
if (JSON.stringify(npm[EXCLUSIONS_FIELD]) === JSON.stringify(exclusions)) return existing
|
|
82
|
-
if (exclusions.length === 0 && existing.length === 0) return existing
|
|
83
102
|
|
|
84
|
-
|
|
103
|
+
return { ...safe_chain, npm: { ...npm, [EXCLUSIONS_FIELD]: exclusions } }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function has_nothing_to_write(
|
|
107
|
+
existing: string,
|
|
108
|
+
exclusions: ReadonlyArray<string>,
|
|
109
|
+
npmrc: string,
|
|
110
|
+
): boolean {
|
|
111
|
+
return existing.length === 0 && exclusions.length === 0 && package_age_hours(npmrc) === undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function read_exclusions(workspace: string): ReadonlyArray<string> {
|
|
115
|
+
return workspace_schema.parse(yaml_document.parse_yaml(workspace)).minimumReleaseAgeExclude ?? []
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function merge_project_config(existing: string, workspace: string, npmrc = ''): string {
|
|
119
|
+
const exclusions = read_exclusions(workspace)
|
|
120
|
+
const aikido = yaml_document.parse_yaml(existing)
|
|
121
|
+
const safe_chain = read_mapping(aikido['safe-chain'], 'safe-chain')
|
|
122
|
+
const merged = merge_age(merge_exclusions(safe_chain, exclusions), npmrc)
|
|
123
|
+
if (JSON.stringify(merged) === JSON.stringify(safe_chain)) return existing
|
|
124
|
+
if (has_nothing_to_write(existing, exclusions, npmrc)) return existing
|
|
85
125
|
|
|
86
126
|
return replace_safe_chain_block(existing, merged)
|
|
87
127
|
}
|
|
88
128
|
|
|
129
|
+
function read_optional(file_path: string): string {
|
|
130
|
+
return existsSync(file_path) ? readFileSync(file_path, 'utf8') : ''
|
|
131
|
+
}
|
|
132
|
+
|
|
89
133
|
function sync_project_config(root: string): boolean {
|
|
90
134
|
const workspace_path = path.join(root, WORKSPACE_FILE)
|
|
91
135
|
if (!existsSync(workspace_path)) return false
|
|
92
136
|
const config_path = path.join(root, PROJECT_CONFIG_FILE)
|
|
93
|
-
const existing =
|
|
137
|
+
const existing = read_optional(config_path)
|
|
94
138
|
const workspace = readFileSync(workspace_path, 'utf8')
|
|
95
|
-
const
|
|
139
|
+
const npmrc = read_optional(path.join(root, NPMRC_FILE))
|
|
140
|
+
const merged = merge_project_config(existing, workspace, npmrc)
|
|
96
141
|
if (merged === existing) return false
|
|
97
142
|
writeFileSync(config_path, merged)
|
|
98
143
|
|
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')
|
|
@@ -368,7 +371,6 @@ function report_repository_settings(name_with_owner: string | undefined): void {
|
|
|
368
371
|
|
|
369
372
|
function sync_project_artifacts(is_force: boolean): void {
|
|
370
373
|
sync_ai_copy_all(is_force)
|
|
371
|
-
project_config.sync_project_config(PROJECT_ROOT)
|
|
372
374
|
sync_prettier_config(path.join(PROJECT_ROOT, 'prettier.config.js'))
|
|
373
375
|
sync_playwright_config(path.join(PROJECT_ROOT, 'playwright.config.ts'))
|
|
374
376
|
sync_deploy_vps(path.join(PROJECT_ROOT, '.github/workflows/deploy-vps.yml'))
|
|
@@ -379,6 +381,9 @@ function sync_project_artifacts(is_force: boolean): void {
|
|
|
379
381
|
|
|
380
382
|
sync_sonar_with_template(name_with_owner, is_force)
|
|
381
383
|
sync_config_files()
|
|
384
|
+
// After `sync_config_files`, which may add the `.npmrc` window `.aikido`'s age is derived from
|
|
385
|
+
// (joshuafolkken/kit#2743).
|
|
386
|
+
project_config.sync_project_config(PROJECT_ROOT)
|
|
382
387
|
sync_package_json_migrations(path.join(PROJECT_ROOT, PACKAGE_JSON))
|
|
383
388
|
report_repository_settings(name_with_owner)
|
|
384
389
|
}
|
|
@@ -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 = {
|