@joshuafolkken/kit 1.1035.0 → 1.1050.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/verify-ui/SKILL.md +1 -1
- package/.claude/skills/workflow-commands/SKILL.md +2 -2
- package/.claude/skills/workflow-commands/background-commands.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-child.md +2 -2
- package/.claude/skills/workflow-commands/backlogrun-lanes.md +1 -1
- package/.claude/skills/workflow-commands/backlogrun-park.md +6 -0
- package/.claude/skills/workflow-commands/backlogrun-progress.md +30 -36
- package/.claude/skills/workflow-commands/backlogrun-steps.md +16 -16
- package/.claude/skills/workflow-commands/chain-rule.md +2 -2
- package/.claude/skills/workflow-commands/followup-reference.md +2 -2
- package/.claude/skills/workflow-commands/issue-comments.md +1 -1
- package/.claude/skills/workflow-commands/observation-filing.md +41 -86
- package/.claude/skills/workflow-commands/pre-gate-cut.md +33 -36
- package/.claude/skills/workflow-commands/working-tree-hold.md +2 -2
- package/.github/workflows/auto-tag.yml +21 -0
- package/.github/workflows/publish.yml +9 -10
- package/dist/hooks/batch-guard.js +4 -4
- package/dist/hooks/{chunk-HU66PWWL.js → chunk-5BK3K7V4.js} +89 -41
- package/dist/hooks/{chunk-GLBDBJOK.js → chunk-7VPDUBRN.js} +49 -151
- package/dist/hooks/{chunk-GJT4BNVE.js → chunk-DMQN45FN.js} +3 -3
- package/dist/hooks/{chunk-D67KVGGY.js → chunk-EJDX2TOB.js} +13 -3
- package/dist/hooks/{chunk-KG6PZD72.js → chunk-GQ7KP45M.js} +2 -2
- package/dist/hooks/{chunk-Y27M6A7X.js → chunk-HJIISEAC.js} +4 -4
- package/dist/hooks/{chunk-XPLP27WA.js → chunk-NAS4C7GP.js} +4 -26
- package/dist/hooks/{chunk-ZXNE6NY3.js → chunk-NJQOBE6L.js} +2 -2
- package/dist/hooks/{chunk-EIFCAJRB.js → chunk-RLDX5SOC.js} +1184 -946
- package/dist/hooks/{chunk-HOYJU5RM.js → chunk-WQ4OSBSS.js} +10 -3
- package/dist/hooks/codex-hook-adapter.js +10 -10
- package/dist/hooks/duplicate-read-guard.js +5 -5
- package/dist/hooks/format-edited.js +15 -34
- package/dist/hooks/investigation-guard.js +5 -5
- package/dist/hooks/pretool-guard.js +23 -16
- package/dist/hooks/stop-guard.js +13 -188
- package/dist/josh.js +4 -106
- package/dist/version/index.js +381 -9
- package/package.json +6 -6
- package/prompts/collaboration-workflow/file-edits.md +6 -53
- package/prompts/collaboration-workflow/residency.md +1 -1
- package/prompts/collaboration-workflow/rule-delivery.md +4 -35
- package/prompts/collaboration-workflow/shell-body.md +7 -9
- package/prompts/collaboration-workflow/turn-batching.md +21 -37
- package/prompts/collaboration-workflow/wip-cap.md +15 -18
- package/prompts/sonar-hotspot-handling.md +1 -1
- package/scripts/adopt/adopt-toolkits.ts +2 -0
- package/scripts/auto-ok/auto-ok-cli.ts +9 -9
- package/scripts/backlog/backlog-next.ts +24 -8
- package/scripts/backlog/backlog-plan-cli.ts +5 -1
- package/scripts/backlog/backlog-pool.ts +31 -11
- package/scripts/backlog/backlog-rank.ts +106 -0
- package/scripts/backlog/backlog-waves.ts +42 -29
- package/scripts/build/build-hooks.ts +7 -2
- package/scripts/delegation/delegation-policy.ts +11 -0
- package/scripts/document/document-byte-budget.ts +5 -5
- package/scripts/document/entry-read-budget.ts +5 -4
- package/scripts/epic/epic-lane-offer.ts +11 -22
- package/scripts/epic/epic-rank.ts +37 -0
- package/scripts/epic/epic-solo.ts +29 -28
- package/scripts/git/git-command.ts +1 -1
- package/scripts/git/git-execa-error.ts +6 -1
- package/scripts/git/git-next-issues.ts +34 -5
- package/scripts/git/git-preflight.ts +22 -4
- package/scripts/git/git-push-transport.ts +12 -69
- package/scripts/git/git-spawn.ts +79 -3
- package/scripts/git/git-ssh-keepalive.ts +74 -0
- package/scripts/git/gone-branch.ts +3 -2
- package/scripts/git/issue-labels.ts +12 -0
- package/scripts/git/issue-rank.ts +89 -0
- package/scripts/hooks/format-edited-cli.ts +15 -0
- package/scripts/hooks/format-edited-file.ts +2 -9
- package/scripts/hooks/pretool-guard-cli.ts +21 -0
- package/scripts/hooks/pretool-guard.ts +14 -15
- package/scripts/init/start-exec.ts +7 -1
- package/scripts/init/start-plan.ts +18 -5
- package/scripts/init/start-setup-pr.ts +46 -19
- package/scripts/init/start-steps.ts +41 -9
- package/scripts/init/start.ts +40 -10
- package/scripts/issue/issue-cite.ts +19 -1
- package/scripts/josh/josh-command-map.ts +0 -102
- package/scripts/josh/josh-commands-development.ts +2 -2
- package/scripts/josh/josh-commands-project.ts +1 -1
- package/scripts/lane/lane-child-marker.ts +16 -2
- package/scripts/lane/lane-cli.ts +32 -14
- package/scripts/lane/lane-report.ts +10 -4
- package/scripts/propagate/propagate-plan.ts +185 -0
- package/scripts/propagate/propagate-run.ts +5 -1
- package/scripts/propagate/propagate-steps.ts +16 -1
- package/scripts/propagate/propagate-targets.ts +13 -2
- package/scripts/propagate/propagate.ts +60 -42
- package/scripts/registry-migration/migrate-logic.ts +1 -0
- package/scripts/release/github-release.ts +1 -1
- package/scripts/review/review-attest.ts +44 -0
- package/scripts/review/review-brief-cli.ts +49 -2
- package/scripts/review/review-brief.ts +46 -1
- package/scripts/review/review-stamps.ts +7 -0
- package/scripts/rules/implementation-cut.ts +1 -1
- package/scripts/rules/issue-fold.ts +1 -2
- package/scripts/rules/lane-carry-conflict.ts +1 -2
- package/scripts/rules/lane-park.ts +1 -2
- package/scripts/rules/pre-gate-cut.ts +2 -2
- package/scripts/run/run-headless.ts +22 -6
- package/scripts/run/run-parent-cut-hook.ts +144 -0
- package/scripts/run/run-progress-read.ts +2 -1
- package/scripts/run/run-progress.ts +21 -8
- package/scripts/run/run-ship-cli.ts +6 -74
- package/scripts/run/run-ship-preflight.ts +87 -0
- package/scripts/run/run-ship-review-steps.ts +5 -26
- package/scripts/run/run-ship-review.ts +9 -0
- package/scripts/run/run-ship-scoped.ts +44 -0
- package/scripts/run/run-ship-stage.ts +5 -1
- package/scripts/run/run-ship-steps.ts +105 -0
- package/scripts/run/run-ship.ts +2 -0
- package/scripts/run/run-wake-cli.ts +2 -2
- package/scripts/run/run-wake-describe.ts +19 -1
- package/scripts/run/run-watcher-hook.ts +1 -1
- package/scripts/time-runtime/time-bundle-call.ts +2 -2
- package/scripts/version/npm-registry.ts +97 -0
- package/scripts/version/version-commands.ts +5 -4
- package/scripts/version/version-remote.ts +26 -12
package/scripts/lane/lane-cli.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { fileURLToPath } from 'node:url'
|
|
3
3
|
import { epic_busy } from '#scripts/epic/epic-busy'
|
|
4
4
|
import { git_gh_command } from '#scripts/git/git-gh-command'
|
|
5
|
+
import type { OpenIssueData } from '#scripts/git/schemas'
|
|
6
|
+
import { issue_cite, type IssueCiter } from '#scripts/issue/issue-cite'
|
|
5
7
|
import { error_text } from '#scripts/lib/error-message'
|
|
6
8
|
import { run_carry } from '#scripts/run/run-carry'
|
|
7
9
|
import { lane_await } from './lane-await'
|
|
@@ -150,10 +152,10 @@ function report_sweep(outcome: SweepOutcome): number {
|
|
|
150
152
|
|
|
151
153
|
// `none` on standard output for the empty case as well, so `lane:list` keeps the contract the rest
|
|
152
154
|
// of the commands follow — the prose belongs on standard error with every other explanation.
|
|
153
|
-
function report_lanes(lanes: ReadonlyArray<LaneInfo
|
|
155
|
+
function report_lanes(lanes: ReadonlyArray<LaneInfo>, cite: IssueCiter): number {
|
|
154
156
|
if (lanes.length === 0) console.error(lane_report.NO_LANES)
|
|
155
157
|
|
|
156
|
-
console.info(lanes.length === 0 ? NONE_TOKEN : lane_report.describe_lanes(lanes))
|
|
158
|
+
console.info(lanes.length === 0 ? NONE_TOKEN : lane_report.describe_lanes(lanes, cite))
|
|
157
159
|
|
|
158
160
|
return SUCCESS_EXIT_CODE
|
|
159
161
|
}
|
|
@@ -192,23 +194,39 @@ const OCCUPANCY_UNREADABLE =
|
|
|
192
194
|
// The `in-progress` issues this repository shows running, read the way `epic:next` reads lane
|
|
193
195
|
// occupancy — never rebuilt (joshuafolkken/kit#2235). A read that could not see the whole listing is
|
|
194
196
|
// `undefined`, so the difference is skipped rather than computed against a set known to be partial.
|
|
195
|
-
async function
|
|
197
|
+
async function to_holders(repo: string): Promise<ReadonlyArray<OpenIssueData> | undefined> {
|
|
196
198
|
const read = await epic_busy.read_repository(repo)
|
|
197
199
|
if (read.kind === 'idle') return []
|
|
198
200
|
|
|
199
|
-
return read.kind === 'busy' ? read.issues
|
|
201
|
+
return read.kind === 'busy' ? read.issues : undefined
|
|
200
202
|
}
|
|
201
203
|
|
|
202
|
-
|
|
204
|
+
// The repository and its running issues, read once: the listing cites each lane from the titles here
|
|
205
|
+
// (joshuafolkken/kit#2943) and the occupancy check compares against the same set.
|
|
206
|
+
interface InProgress {
|
|
207
|
+
repo: string | undefined
|
|
208
|
+
holders: ReadonlyArray<OpenIssueData> | undefined
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function read_in_progress(): Promise<InProgress> {
|
|
203
212
|
try {
|
|
204
213
|
const repo = await git_gh_command.repo_get_name_with_owner()
|
|
205
214
|
|
|
206
|
-
return repo === undefined ? undefined : await
|
|
215
|
+
return { repo, holders: repo === undefined ? undefined : await to_holders(repo) }
|
|
207
216
|
} catch {
|
|
208
|
-
return undefined
|
|
217
|
+
return { repo: undefined, holders: undefined }
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
220
|
|
|
221
|
+
function citer_of(in_progress: InProgress): IssueCiter {
|
|
222
|
+
const holders = in_progress.holders ?? []
|
|
223
|
+
|
|
224
|
+
return issue_cite.citer(
|
|
225
|
+
in_progress.repo,
|
|
226
|
+
new Map(holders.map((issue) => [String(issue.number), issue.title])),
|
|
227
|
+
)
|
|
228
|
+
}
|
|
229
|
+
|
|
212
230
|
// A stranded lane's work tree is gone, so it holds no issue: it counts as no lane rather than a live
|
|
213
231
|
// one, which is what turns its surviving `in-progress` label into a `stopped` anomaly.
|
|
214
232
|
function live_lane_issues(lanes: ReadonlyArray<LaneInfo>): Array<number> {
|
|
@@ -217,16 +235,15 @@ function live_lane_issues(lanes: ReadonlyArray<LaneInfo>): Array<number> {
|
|
|
217
235
|
|
|
218
236
|
// The lane/label difference, printed to standard error beside the listing. Wrapped so a GitHub read
|
|
219
237
|
// that fails skips only this section — `lane:list`'s job is to list the lanes it already read.
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
if (in_progress === undefined) {
|
|
238
|
+
function report_occupancy(lanes: ReadonlyArray<LaneInfo>, in_progress: InProgress): void {
|
|
239
|
+
if (in_progress.holders === undefined) {
|
|
224
240
|
console.error(OCCUPANCY_UNREADABLE)
|
|
225
241
|
|
|
226
242
|
return
|
|
227
243
|
}
|
|
228
244
|
|
|
229
|
-
const
|
|
245
|
+
const holders = in_progress.holders.map((issue) => issue.number)
|
|
246
|
+
const report = lane_occupancy.classify(holders, {
|
|
230
247
|
kind: 'lanes',
|
|
231
248
|
issues: live_lane_issues(lanes),
|
|
232
249
|
})
|
|
@@ -239,9 +256,10 @@ async function list_command(rest: ReadonlyArray<string>): Promise<number> {
|
|
|
239
256
|
if (rest.length > 0) return report_usage()
|
|
240
257
|
|
|
241
258
|
const lanes = await lane_registry.list_lanes()
|
|
242
|
-
const
|
|
259
|
+
const in_progress = await read_in_progress()
|
|
260
|
+
const code = report_lanes(lanes, citer_of(in_progress))
|
|
243
261
|
|
|
244
|
-
|
|
262
|
+
report_occupancy(lanes, in_progress)
|
|
245
263
|
|
|
246
264
|
return code
|
|
247
265
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { agent_role_profile } from '#scripts/agent/agent-role-profile'
|
|
2
|
+
import { issue_cite, type IssueCiter } from '#scripts/issue/issue-cite'
|
|
2
3
|
import { run_liveness } from '#scripts/run/run-liveness'
|
|
3
4
|
import type { LaneInfo } from './lane-registry'
|
|
4
5
|
|
|
@@ -48,9 +49,14 @@ function event_column(lane: LaneInfo): string {
|
|
|
48
49
|
return state ?? 'agent -'
|
|
49
50
|
}
|
|
50
51
|
|
|
51
|
-
|
|
52
|
+
// The plain `#N`, for a caller that could not read the repository the issue would link into.
|
|
53
|
+
const PLAIN_CITE: IssueCiter = issue_cite.citer(undefined, new Map())
|
|
54
|
+
|
|
55
|
+
// The issue leads as a citation (joshuafolkken/kit#2943): a lane status report copies this row, and a
|
|
56
|
+
// bare `#N` copied into it is what the Stop guard sends back.
|
|
57
|
+
function describe_lane(lane: LaneInfo, cite: IssueCiter = PLAIN_CITE): string {
|
|
52
58
|
return [
|
|
53
|
-
|
|
59
|
+
cite(lane.issue),
|
|
54
60
|
`seat ${value_or_unknown(lane.seat)}`,
|
|
55
61
|
`dev ${value_or_unknown(lane.development_port)}`,
|
|
56
62
|
`preview ${value_or_unknown(lane.preview_port)}`,
|
|
@@ -63,10 +69,10 @@ function describe_lane(lane: LaneInfo): string {
|
|
|
63
69
|
].join(COLUMN_SEPARATOR)
|
|
64
70
|
}
|
|
65
71
|
|
|
66
|
-
function describe_lanes(lanes: ReadonlyArray<LaneInfo
|
|
72
|
+
function describe_lanes(lanes: ReadonlyArray<LaneInfo>, cite: IssueCiter = PLAIN_CITE): string {
|
|
67
73
|
if (lanes.length === 0) return NO_LANES
|
|
68
74
|
|
|
69
|
-
return lanes.map((lane) => describe_lane(lane)).join('\n')
|
|
75
|
+
return lanes.map((lane) => describe_lane(lane, cite)).join('\n')
|
|
70
76
|
}
|
|
71
77
|
|
|
72
78
|
function describe_ports(lane: LaneInfo): string {
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { adopt_toolkits } from '#scripts/adopt/adopt-toolkits'
|
|
2
|
+
import { propagate_run, type RunStep, type StepResult } from './propagate-run'
|
|
3
|
+
import { propagate_steps, type Release, type ReleasePlan } from './propagate-steps'
|
|
4
|
+
import { propagate_targets, type Manifest, type PropagateTarget } from './propagate-targets'
|
|
5
|
+
|
|
6
|
+
// What one `josh propagate` run carries, decided from the repository it runs in (joshuafolkken/kit#2879).
|
|
7
|
+
//
|
|
8
|
+
// The supplier is kit or any toolkit built on it — app-kit, game-kit — and a toolkit's propagation
|
|
9
|
+
// carries the base packages under it too, at the versions that toolkit itself has installed and was
|
|
10
|
+
// verified with rather than at the base's newest. Each consumer is delivered by the topmost toolkit
|
|
11
|
+
// it installs: a lower supplier skips it, because syncing only the base would write the overlay's
|
|
12
|
+
// paths back to the base's originals (`docs/sync.md` → base first, overlay after), and would hand the
|
|
13
|
+
// consumer a base the toolkit above it never verified. Discovery, ordering and the CLI name are
|
|
14
|
+
// `josh adopt`'s own reads (`scripts/adopt/adopt-toolkits.ts`), not a second copy of them.
|
|
15
|
+
|
|
16
|
+
const REFUSAL_PREFIX = 'Refusing to propagate:'
|
|
17
|
+
const SUPPLIER_REFUSAL = [
|
|
18
|
+
`${REFUSAL_PREFIX} this is not a ${adopt_toolkits.TOOLKIT_SCOPE} toolkit's own repository.`,
|
|
19
|
+
'Propagation carries a published release outward, so it runs from the toolkit that published it',
|
|
20
|
+
'— kit, or a toolkit built on it that ships a CLI. Run it from that checkout instead.',
|
|
21
|
+
].join('\n')
|
|
22
|
+
const UNRESOLVED_CAUSE =
|
|
23
|
+
'declared here but not installed with a runnable CLI, so the consumers would receive this toolkit without it. Run `pnpm install`.'
|
|
24
|
+
const MISPLACED_CAUSE =
|
|
25
|
+
'declared under `dependencies`, which leaves it out of the plan. Move it to `devDependencies`.'
|
|
26
|
+
const ARROW = ' → '
|
|
27
|
+
const LIST_SEPARATOR = ', '
|
|
28
|
+
|
|
29
|
+
interface Supplier {
|
|
30
|
+
package_name: string
|
|
31
|
+
bin_name: string
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface SupplierResolution {
|
|
35
|
+
supplier?: Supplier
|
|
36
|
+
refusal?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface BaseResolution {
|
|
40
|
+
bases?: Array<Release>
|
|
41
|
+
cause?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface PlanResolution {
|
|
45
|
+
plan?: ReleasePlan
|
|
46
|
+
refusal?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// A toolkit is a scoped package that ships a CLI — the same reading `josh adopt` applies to an
|
|
50
|
+
// installed one, applied here to the repository's own manifest.
|
|
51
|
+
function toolkit_name(manifest: Manifest): string | undefined {
|
|
52
|
+
const { name } = manifest
|
|
53
|
+
|
|
54
|
+
return name !== undefined && adopt_toolkits.is_toolkit_package(name) ? name : undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function supplier_of(manifest: Manifest | undefined): Supplier | undefined {
|
|
58
|
+
const package_name = manifest === undefined ? undefined : toolkit_name(manifest)
|
|
59
|
+
if (manifest === undefined || package_name === undefined) return undefined
|
|
60
|
+
const bin_name = adopt_toolkits.bin_name_of(package_name, manifest)
|
|
61
|
+
|
|
62
|
+
return bin_name === undefined ? undefined : { package_name, bin_name }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function resolve_supplier(project_root: string): SupplierResolution {
|
|
66
|
+
const supplier = supplier_of(propagate_targets.read_manifest(project_root))
|
|
67
|
+
|
|
68
|
+
return supplier === undefined ? { refusal: SUPPLIER_REFUSAL } : { supplier }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A base toolkit pinned to the version installed in the supplier's own checkout — the one the
|
|
72
|
+
// supplier's verification gate ran against.
|
|
73
|
+
function pin_installed(project_root: string, release: Release): Release | undefined {
|
|
74
|
+
const version = propagate_targets.installed_version(project_root, release.package_name)
|
|
75
|
+
|
|
76
|
+
return version === undefined ? undefined : { ...release, version }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Why a declared base toolkit would fall out of the plan, naming it — either way `josh adopt` refuses
|
|
80
|
+
// one for (joshuafolkken/kit#1540). A base left out is not merely left behind: every consumer that
|
|
81
|
+
// installs it would then read it as a toolkit above this one and be skipped.
|
|
82
|
+
function incomplete_cause(project_root: string): string | undefined {
|
|
83
|
+
const misplaced = adopt_toolkits.misplaced_toolkits(project_root)
|
|
84
|
+
if (misplaced.length > 0) return `${misplaced.join(LIST_SEPARATOR)} — ${MISPLACED_CAUSE}`
|
|
85
|
+
const unresolved = adopt_toolkits.unresolved_toolkits(project_root)
|
|
86
|
+
|
|
87
|
+
return unresolved.length === 0
|
|
88
|
+
? undefined
|
|
89
|
+
: `${unresolved.join(LIST_SEPARATOR)} — ${UNRESOLVED_CAUSE}`
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// The supplier's base toolkits, base first, or the names that cannot be carried.
|
|
93
|
+
function base_releases(project_root: string, supplier: Supplier): BaseResolution {
|
|
94
|
+
const cause = incomplete_cause(project_root)
|
|
95
|
+
if (cause !== undefined) return { cause }
|
|
96
|
+
|
|
97
|
+
const bases = adopt_toolkits
|
|
98
|
+
.discover_toolkits(project_root)
|
|
99
|
+
.filter((release) => release.package_name !== supplier.package_name)
|
|
100
|
+
.map((release) => pin_installed(project_root, release))
|
|
101
|
+
|
|
102
|
+
return bases.every((release) => release !== undefined) ? { bases } : { cause: UNRESOLVED_CAUSE }
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// The releases one run carries: the base toolkits first, then the supplier at its own version, so the
|
|
106
|
+
// sync runs base first and the supplier's overlay is written last.
|
|
107
|
+
function build_plan(project_root: string, version: string): PlanResolution {
|
|
108
|
+
const { supplier, refusal } = resolve_supplier(project_root)
|
|
109
|
+
if (supplier === undefined) return { refusal: refusal ?? SUPPLIER_REFUSAL }
|
|
110
|
+
const { bases, cause } = base_releases(project_root, supplier)
|
|
111
|
+
if (bases === undefined) return { refusal: `${REFUSAL_PREFIX} ${cause ?? UNRESOLVED_CAUSE}` }
|
|
112
|
+
const releases = [...bases, { ...supplier, version }]
|
|
113
|
+
|
|
114
|
+
return { plan: { releases, origin: propagate_steps.PROPAGATE_ORIGIN } }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// The package the run publishes and waits for — by construction the plan's last release.
|
|
118
|
+
function supplier_name(plan: ReleasePlan): string {
|
|
119
|
+
return plan.releases.at(-1)?.package_name ?? ''
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// A declared scoped package counts as a toolkit unless it is demonstrably a library: installed, and
|
|
123
|
+
// shipping no CLI. One that is not installed yet cannot be told apart, and treating it as a toolkit
|
|
124
|
+
// errs toward leaving the consumer to the propagation above rather than reverting its overlay.
|
|
125
|
+
function may_be_toolkit(repository_path: string, package_name: string): boolean {
|
|
126
|
+
if (adopt_toolkits.read_bin_name(repository_path, package_name) !== undefined) return true
|
|
127
|
+
|
|
128
|
+
return adopt_toolkits.is_unreachable_toolkit(repository_path, package_name)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function carriers_of(target: PropagateTarget, plan: ReleasePlan): Array<string> {
|
|
132
|
+
const carried = new Set(plan.releases.map((release) => release.package_name))
|
|
133
|
+
|
|
134
|
+
return adopt_toolkits
|
|
135
|
+
.all_declared_toolkits(target.path)
|
|
136
|
+
.filter((name) => !carried.has(name) && may_be_toolkit(target.path, name))
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A consumer that installs a toolkit this run does not carry is left to that toolkit's propagation.
|
|
140
|
+
// Only a candidate that would otherwise be processed or reported current is re-classified.
|
|
141
|
+
function mark_carried(target: PropagateTarget, plan: ReleasePlan): PropagateTarget {
|
|
142
|
+
if (target.state !== 'ready' && target.state !== 'up_to_date') return target
|
|
143
|
+
const carriers = carriers_of(target, plan)
|
|
144
|
+
|
|
145
|
+
return carriers.length === 0 ? target : { ...target, state: 'carried_above', carriers }
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// What the dry run prints for the two steps that repeat per release: the versions each package is
|
|
149
|
+
// pinned to, and the sync order.
|
|
150
|
+
function plan_details(plan: ReleasePlan): Readonly<Record<string, string>> {
|
|
151
|
+
return {
|
|
152
|
+
[propagate_run.STEP_UPGRADE]: plan.releases
|
|
153
|
+
.map((release) => `${release.package_name}@${release.version}`)
|
|
154
|
+
.join(LIST_SEPARATOR),
|
|
155
|
+
[propagate_run.STEP_SYNC]: propagate_steps
|
|
156
|
+
.sync_commands(plan)
|
|
157
|
+
.map(({ command }) => command.join(' '))
|
|
158
|
+
.join(ARROW),
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// The dry-run runner: it reports what each step would do and touches nothing.
|
|
163
|
+
function create_step_describer(plan: ReleasePlan): RunStep {
|
|
164
|
+
const details = plan_details(plan)
|
|
165
|
+
|
|
166
|
+
return (target: PropagateTarget, step: string): StepResult => {
|
|
167
|
+
const detail = details[step]
|
|
168
|
+
const suffix = detail === undefined ? '' : `: ${detail}`
|
|
169
|
+
|
|
170
|
+
console.info(` ${target.repo}: would run ${step}${suffix}`)
|
|
171
|
+
|
|
172
|
+
return { step, is_ok: true }
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const propagate_plan = {
|
|
177
|
+
resolve_supplier,
|
|
178
|
+
build_plan,
|
|
179
|
+
supplier_name,
|
|
180
|
+
mark_carried,
|
|
181
|
+
create_step_describer,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export type { Supplier }
|
|
185
|
+
export { propagate_plan }
|
|
@@ -65,13 +65,17 @@ const SKIP_REASONS: Readonly<Record<string, string>> = {
|
|
|
65
65
|
missing_checkout: 'no local checkout at the mapped path — reported, not cloned',
|
|
66
66
|
unreadable: 'package.json could not be read',
|
|
67
67
|
not_selected: 'not the repository --target named',
|
|
68
|
+
carried_above: 'delivered by the propagation of a toolkit above this one',
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
// The reason text for a candidate that is not going to be processed. A missing local checkout is
|
|
71
72
|
// reported rather than cloned: propagation writes to a working tree, and creating one nobody asked
|
|
72
73
|
// for is not a step this command may take on its own.
|
|
73
74
|
function skip_reason(target: PropagateTarget): string {
|
|
74
|
-
|
|
75
|
+
const reason = SKIP_REASONS[target.state] ?? 'not eligible'
|
|
76
|
+
if (target.carriers === undefined) return reason
|
|
77
|
+
|
|
78
|
+
return `${reason}: ${target.carriers.join(', ')}`
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
const LEFTOVER_NOTE = 'upgrade/sync changes left uncommitted'
|
|
@@ -88,13 +88,28 @@ function release_names(releases: ReadonlyArray<Release>): string {
|
|
|
88
88
|
return `${names.slice(0, -1).join(NAME_SEPARATOR)}${LAST_SEPARATOR}${last}`
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
// Each release with its own version, for a plan whose releases disagree on one — a toolkit's
|
|
92
|
+
// propagation carries the base packages at the versions that toolkit was verified with
|
|
93
|
+
// (joshuafolkken/kit#2879), so one shared "to <version>" would name a version most of them are not.
|
|
94
|
+
function versioned_names(releases: ReadonlyArray<Release>): string {
|
|
95
|
+
const named = releases.map((release) => ({
|
|
96
|
+
...release,
|
|
97
|
+
package_name: `${release.package_name} to ${release.version}`,
|
|
98
|
+
}))
|
|
99
|
+
|
|
100
|
+
return `Upgrade ${release_names(named)}`
|
|
101
|
+
}
|
|
102
|
+
|
|
91
103
|
// One release reproduces `issue_title` exactly, which is what keeps a `propagate` run's issue title
|
|
92
104
|
// unchanged by the generalization.
|
|
93
105
|
function plan_title(releases: ReadonlyArray<Release>): string {
|
|
94
106
|
const [first] = releases
|
|
95
107
|
if (first === undefined) return EMPTY_PLAN_TITLE
|
|
108
|
+
const is_one_version = releases.every((release) => release.version === first.version)
|
|
96
109
|
|
|
97
|
-
return
|
|
110
|
+
return is_one_version
|
|
111
|
+
? issue_title(release_names(releases), first.version)
|
|
112
|
+
: versioned_names(releases)
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
function plan_body(plan: ReleasePlan): string {
|
|
@@ -20,9 +20,17 @@ const MANIFEST_NAME = 'package.json'
|
|
|
20
20
|
const NODE_MODULES = 'node_modules'
|
|
21
21
|
|
|
22
22
|
// Why a candidate is not going to be processed, or that it is. `not_selected` is the one state set
|
|
23
|
-
// after classification: a candidate `--target` left out (joshuafolkken/kit#2755).
|
|
23
|
+
// after classification: a candidate `--target` left out (joshuafolkken/kit#2755). `carried_above` is
|
|
24
|
+
// the other: a consumer that installs a toolkit above the supplier, which that toolkit's own
|
|
25
|
+
// propagation delivers instead (joshuafolkken/kit#2879).
|
|
24
26
|
type TargetState =
|
|
25
|
-
|
|
27
|
+
| 'ready'
|
|
28
|
+
| 'up_to_date'
|
|
29
|
+
| 'not_downstream'
|
|
30
|
+
| 'missing_checkout'
|
|
31
|
+
| 'unreadable'
|
|
32
|
+
| 'not_selected'
|
|
33
|
+
| 'carried_above'
|
|
26
34
|
|
|
27
35
|
interface PropagateTarget {
|
|
28
36
|
repo: string
|
|
@@ -31,6 +39,9 @@ interface PropagateTarget {
|
|
|
31
39
|
// The range the consumer currently declares, when it declares one at all. Reported so a skip can
|
|
32
40
|
// say what it skipped rather than only that it did.
|
|
33
41
|
declared_range?: string
|
|
42
|
+
// The toolkits above the supplier this consumer installs, when it is `carried_above` — named in
|
|
43
|
+
// the skip so the report says which propagation will deliver it.
|
|
44
|
+
carriers?: ReadonlyArray<string>
|
|
34
45
|
}
|
|
35
46
|
|
|
36
47
|
// Only the fields propagation reads. `passthrough` is deliberate — a consumer manifest carries far
|
|
@@ -4,15 +4,14 @@ import { repo_discovery } from '#scripts/discovery/repo-discovery'
|
|
|
4
4
|
import { PROJECT_ROOT } from '#scripts/init/init-paths'
|
|
5
5
|
import { cli_flags } from '#scripts/lib/cli-flags'
|
|
6
6
|
import { status_icons } from '#scripts/lib/status-icons'
|
|
7
|
-
import { self_sync_guard } from '#scripts/self-sync-guard/self-sync-guard-logic'
|
|
8
|
-
import { KIT_PACKAGE_NAME } from '#scripts/version/kit-descriptor'
|
|
9
7
|
import { derive_versions_endpoint } from '#scripts/version/version-command-config'
|
|
10
8
|
import { version_targets } from '#scripts/version/version-targets'
|
|
11
9
|
import { propagate_git } from './propagate-git'
|
|
10
|
+
import { propagate_plan } from './propagate-plan'
|
|
12
11
|
import { propagate_publish } from './propagate-publish'
|
|
13
12
|
import { propagate_run } from './propagate-run'
|
|
14
13
|
import { propagate_select, type TargetSelection } from './propagate-select'
|
|
15
|
-
import { propagate_steps } from './propagate-steps'
|
|
14
|
+
import { propagate_steps, type ReleasePlan } from './propagate-steps'
|
|
16
15
|
import { propagate_targets, type PropagateTarget } from './propagate-targets'
|
|
17
16
|
|
|
18
17
|
// `josh propagate` — carry the release this repository just published into every consumer checked
|
|
@@ -78,7 +77,8 @@ function parse_options(argv: ReadonlyArray<string>): RunOptions {
|
|
|
78
77
|
return usage === undefined ? options : { ...options, usage }
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
// Propagation runs from the supplier's own repository, and only there
|
|
80
|
+
// Propagation runs from the supplier's own repository, and only there — kit's, or a toolkit's built
|
|
81
|
+
// on it (joshuafolkken/kit#2879).
|
|
82
82
|
//
|
|
83
83
|
// This is also what decides who propagates when several sessions are running: in the
|
|
84
84
|
// per-repository concurrency model there is one session per checkout, so the session standing in
|
|
@@ -86,13 +86,7 @@ function parse_options(argv: ReadonlyArray<string>): RunOptions {
|
|
|
86
86
|
// convention enforced at the boundary, not a lock — two checkouts of the supplier would both pass,
|
|
87
87
|
// which is why each consumer is additionally refused unless its working tree is clean.
|
|
88
88
|
function refuse_outside_source_repository(project_root: string): string | undefined {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
return [
|
|
92
|
-
`Refusing to propagate: this is not ${KIT_PACKAGE_NAME}'s own repository.`,
|
|
93
|
-
'Propagation carries a published release outward, so it runs from the package that published it.',
|
|
94
|
-
`Run it from the ${KIT_PACKAGE_NAME} checkout instead.`,
|
|
95
|
-
].join('\n')
|
|
89
|
+
return propagate_plan.resolve_supplier(project_root).refusal
|
|
96
90
|
}
|
|
97
91
|
|
|
98
92
|
// The version to carry: this repository's own declared version, which is what the merge published.
|
|
@@ -144,10 +138,14 @@ function resolve_run_version(project_root: string, is_dry_run = false): RunVersi
|
|
|
144
138
|
return resolve_supplier_version(project_root, is_dry_run)
|
|
145
139
|
}
|
|
146
140
|
|
|
147
|
-
async function await_publish(
|
|
141
|
+
async function await_publish(
|
|
142
|
+
package_name: string,
|
|
143
|
+
target_version: string,
|
|
144
|
+
is_skipped: boolean,
|
|
145
|
+
): Promise<boolean> {
|
|
148
146
|
if (is_skipped) return true
|
|
149
|
-
console.info(`Waiting for ${
|
|
150
|
-
const endpoint = derive_versions_endpoint(
|
|
147
|
+
console.info(`Waiting for ${package_name}@${target_version} to appear in the registry…`)
|
|
148
|
+
const endpoint = derive_versions_endpoint(package_name)
|
|
151
149
|
const result = await propagate_publish.wait_for_publish(endpoint, target_version)
|
|
152
150
|
|
|
153
151
|
if (result.state === 'published') {
|
|
@@ -179,49 +177,65 @@ function announce_run_version(resolved: RunVersion): string | undefined {
|
|
|
179
177
|
|
|
180
178
|
interface RunPlan {
|
|
181
179
|
version: string
|
|
180
|
+
releases: ReleasePlan
|
|
182
181
|
targets: ReadonlyArray<PropagateTarget>
|
|
183
182
|
}
|
|
184
183
|
|
|
185
184
|
// The consumers this run will consider, narrowed by `--target`. Resolved before the publish wait so
|
|
186
|
-
// a mistyped name fails at once instead of after minutes of polling the registry.
|
|
187
|
-
|
|
185
|
+
// a mistyped name fails at once instead of after minutes of polling the registry. A consumer that
|
|
186
|
+
// installs a toolkit above the supplier is left to that toolkit's propagation (joshuafolkken/kit#2879).
|
|
187
|
+
function resolve_run_targets(
|
|
188
|
+
releases: ReleasePlan,
|
|
189
|
+
version: string,
|
|
190
|
+
target: string | undefined,
|
|
191
|
+
): TargetSelection {
|
|
188
192
|
const map = repo_discovery.discover_repositories(PROJECT_ROOT)
|
|
189
|
-
const
|
|
193
|
+
const supplier = propagate_plan.supplier_name(releases)
|
|
194
|
+
const targets = propagate_targets
|
|
195
|
+
.resolve_targets(map, supplier, version)
|
|
196
|
+
.map((candidate) => propagate_plan.mark_carried(candidate, releases))
|
|
190
197
|
|
|
191
198
|
return propagate_select.select_target(targets, target)
|
|
192
199
|
}
|
|
193
200
|
|
|
194
|
-
//
|
|
201
|
+
// Print why the run stopped before any consumer was touched.
|
|
202
|
+
function refuse_run(refusal: string): void {
|
|
203
|
+
console.error(refusal)
|
|
204
|
+
console.error(UNTOUCHED_NOTE)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
interface PlanOutcome {
|
|
208
|
+
plan?: RunPlan
|
|
209
|
+
refusal?: string
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// The releases and the consumers for a version already accepted, or the reason there are none.
|
|
213
|
+
function plan_consumers(version: string, options: RunOptions): PlanOutcome {
|
|
214
|
+
const { plan: releases, refusal } = propagate_plan.build_plan(PROJECT_ROOT, version)
|
|
215
|
+
if (releases === undefined) return { refusal: refusal ?? UNTOUCHED_NOTE }
|
|
216
|
+
const selection = resolve_run_targets(releases, version, options.target)
|
|
217
|
+
if (selection.refusal !== undefined) return { refusal: selection.refusal }
|
|
218
|
+
|
|
219
|
+
return { plan: { version, releases, targets: selection.targets } }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// The version, the releases and the consumers, or nothing once whatever stopped the run has been
|
|
223
|
+
// printed.
|
|
195
224
|
function plan_run(options: RunOptions): RunPlan | undefined {
|
|
196
225
|
const version = announce_run_version(resolve_run_version(PROJECT_ROOT, options.is_dry_run))
|
|
197
226
|
if (version === undefined) return undefined
|
|
198
|
-
const
|
|
227
|
+
const { plan, refusal } = plan_consumers(version, options)
|
|
228
|
+
if (refusal !== undefined) refuse_run(refusal)
|
|
199
229
|
|
|
200
|
-
|
|
201
|
-
console.error(selection.refusal)
|
|
202
|
-
console.error(UNTOUCHED_NOTE)
|
|
203
|
-
|
|
204
|
-
return undefined
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
return { version, targets: selection.targets }
|
|
230
|
+
return plan
|
|
208
231
|
}
|
|
209
232
|
|
|
210
233
|
// Everything after the publish wait: run each consumer, print one report.
|
|
211
234
|
function propagate_to_consumers(plan: RunPlan, is_dry_run: boolean): number {
|
|
212
|
-
const {
|
|
235
|
+
const { releases, targets } = plan
|
|
213
236
|
const step = is_dry_run
|
|
214
|
-
?
|
|
215
|
-
: propagate_steps.create_step_runner(
|
|
216
|
-
releases: [
|
|
217
|
-
{
|
|
218
|
-
package_name: KIT_PACKAGE_NAME,
|
|
219
|
-
version,
|
|
220
|
-
bin_name: propagate_steps.JOSH_BIN,
|
|
221
|
-
},
|
|
222
|
-
],
|
|
223
|
-
origin: propagate_steps.PROPAGATE_ORIGIN,
|
|
224
|
-
})
|
|
237
|
+
? propagate_plan.create_step_describer(releases)
|
|
238
|
+
: propagate_steps.create_step_runner(releases)
|
|
225
239
|
const results = propagate_run.run_targets(
|
|
226
240
|
targets,
|
|
227
241
|
step,
|
|
@@ -236,8 +250,12 @@ function propagate_to_consumers(plan: RunPlan, is_dry_run: boolean): number {
|
|
|
236
250
|
|
|
237
251
|
// A dry run never waits: the whole reason to ask for one is to see the target list, and a wait that
|
|
238
252
|
// times out would end the run before the list was ever printed.
|
|
239
|
-
async function resolve_publish(
|
|
240
|
-
return await await_publish(
|
|
253
|
+
async function resolve_publish(plan: RunPlan, options: RunOptions): Promise<boolean> {
|
|
254
|
+
return await await_publish(
|
|
255
|
+
propagate_plan.supplier_name(plan.releases),
|
|
256
|
+
plan.version,
|
|
257
|
+
options.is_dry_run || options.is_publish_wait_skipped,
|
|
258
|
+
)
|
|
241
259
|
}
|
|
242
260
|
|
|
243
261
|
async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
@@ -251,7 +269,7 @@ async function run(argv: ReadonlyArray<string>): Promise<number> {
|
|
|
251
269
|
|
|
252
270
|
const plan = plan_run(options)
|
|
253
271
|
if (plan === undefined) return FAILURE_EXIT_CODE
|
|
254
|
-
if (!(await resolve_publish(plan
|
|
272
|
+
if (!(await resolve_publish(plan, options))) return FAILURE_EXIT_CODE
|
|
255
273
|
|
|
256
274
|
return propagate_to_consumers(plan, options.is_dry_run)
|
|
257
275
|
}
|
|
@@ -4,7 +4,7 @@ import { z } from 'zod'
|
|
|
4
4
|
|
|
5
5
|
const API_URL = 'https://api.github.com/repos/joshuafolkken/kit/releases'
|
|
6
6
|
const WORKFLOW_RUNS_URL =
|
|
7
|
-
'https://api.github.com/repos/joshuafolkken/kit/actions/workflows/publish.yml/runs?
|
|
7
|
+
'https://api.github.com/repos/joshuafolkken/kit/actions/workflows/publish.yml/runs?per_page=100'
|
|
8
8
|
const WORKFLOW_JOBS_URL = 'https://api.github.com/repos/joshuafolkken/kit/actions/runs'
|
|
9
9
|
const TAG_PATTERN = /^v\d+\.\d+\.\d+(?:[.-][0-9A-Za-z.-]+)?$/u
|
|
10
10
|
const REQUEST_TIMEOUT_MS = 30_000
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from 'node:crypto'
|
|
2
|
+
import { lstatSync } from 'node:fs'
|
|
2
3
|
import { git_command } from '#scripts/git/git-command'
|
|
3
4
|
import { PROJECT_ROOT } from '#scripts/init/init-paths'
|
|
4
5
|
import { stamp_file } from '#scripts/josh/stamp-file'
|
|
@@ -240,6 +241,47 @@ function check(now: number = Date.now(), root?: string): AttestVerdict {
|
|
|
240
241
|
return verdict
|
|
241
242
|
}
|
|
242
243
|
|
|
244
|
+
function is_written_since(source: string, taken_at: string): boolean {
|
|
245
|
+
try {
|
|
246
|
+
const stats = lstatSync(source)
|
|
247
|
+
|
|
248
|
+
return stats.isFile() && stats.mtimeMs >= Date.parse(taken_at)
|
|
249
|
+
} catch {
|
|
250
|
+
return false
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// **Whether the last briefed review of this branch finished** (joshuafolkken/kit#2945). A ship resumed
|
|
255
|
+
// after a stop re-issues round 1, and without this answer it briefed the whole change again — the
|
|
256
|
+
// files the finished review had just read, re-read at full cost. It is asked before `record_target`
|
|
257
|
+
// mints the next nonce, since that call drops the record it reads. The branch must match: the pointer
|
|
258
|
+
// is keyed on the root, and a lane root outlives the branch a previous run reviewed there.
|
|
259
|
+
//
|
|
260
|
+
// **An attestation alone is not a finished review.** The reviewer may attest and then die before it
|
|
261
|
+
// reports — a timeout, a usage limit — and narrowing on that would call files nobody read "already
|
|
262
|
+
// read". So the findings file, written only once the review has its verdict, must postdate the brief
|
|
263
|
+
// this pointer names; one left from an earlier review is older than that brief and does not count.
|
|
264
|
+
function is_reviewed_on(checkout: ReviewCheckout, findings_path: string, root?: string): boolean {
|
|
265
|
+
const pointer = read_pointer(root)
|
|
266
|
+
|
|
267
|
+
if (pointer === undefined) return false
|
|
268
|
+
|
|
269
|
+
const verdict = verdict_for(pointer.nonce)
|
|
270
|
+
|
|
271
|
+
if (verdict.status !== 'ok' || verdict.expected?.branch !== checkout.branch) return false
|
|
272
|
+
|
|
273
|
+
return is_written_since(findings_path, pointer.taken_at)
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Whether a record written at `written_at` belongs to the brief the pointer names rather than an earlier
|
|
277
|
+
// one (joshuafolkken/kit#2945): that brief writes it right after minting the pointer. ISO timestamps
|
|
278
|
+
// order as strings.
|
|
279
|
+
function is_briefed_since(written_at: string, root?: string): boolean {
|
|
280
|
+
const pointer = read_pointer(root)
|
|
281
|
+
|
|
282
|
+
return pointer !== undefined && written_at >= pointer.taken_at
|
|
283
|
+
}
|
|
284
|
+
|
|
243
285
|
// The pointer is keyed on the repository root rather than on `process.cwd()`, which is what
|
|
244
286
|
// `PROJECT_ROOT` is. `josh review:brief` records against the root it just asked git for, so the check
|
|
245
287
|
// has to ask the same question: run the two from different directories — one from `scripts/`, one
|
|
@@ -303,6 +345,8 @@ const review_attest = {
|
|
|
303
345
|
clear_here,
|
|
304
346
|
EXPECT_PREFIX,
|
|
305
347
|
expect_path,
|
|
348
|
+
is_briefed_since,
|
|
349
|
+
is_reviewed_on,
|
|
306
350
|
MISMATCH_VERDICT,
|
|
307
351
|
MISSING_REASON,
|
|
308
352
|
POINTER_PREFIX,
|