rvlib-shipkit 0.33.0 → 0.35.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/CHANGELOG.md +23 -1
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +15 -2
- package/src/cli/cmd_Asks.ts +2 -1
- package/src/cli/cmd_Ci.ts +3 -0
- package/src/cli/cmd_Keys.ts +113 -0
- package/src/cli.ts +2 -0
- package/src/core/check-suite.ts +54 -47
- package/src/core/discover-checks.ts +30 -7
- package/src/core/floor.ts +34 -6
- package/src/core/locked-write.ts +170 -1
- package/src/core/motherhome.ts +54 -1
- package/src/core/one-way-in-policy.ts +8 -8
- package/src/core/run-check.ts +92 -12
- package/src/core/run-ci.ts +115 -17
- package/src/core/run-fixers.ts +61 -18
- package/src/core/task-runner.ts +62 -6
- package/src/hooks/run-hooks.ts +23 -2
- package/src/ledger/gate-units.ts +84 -0
- package/src/ledger/git-key.ts +381 -0
- package/src/ledger/git-scan.ts +327 -0
- package/src/ledger/ledger.ts +202 -0
- package/src/ledger/store.ts +122 -0
- package/src/ledger/toolchain-identity.ts +45 -8
- package/src/pb/collection-scaffold.ts +4 -4
- package/src/release/run-bump.ts +50 -5
- package/src/release/run-release.ts +5 -0
- package/src/release/run-releases.ts +7 -1
- package/src/release/unit-gate.ts +17 -4
- package/src/repo-config.ts +26 -4
- package/src/rules/SK001-package-json-shape.ts +8 -2
- package/src/rules/SK002-contracts-fulfillness.ts +4 -0
- package/src/rules/SK003-claude-md-shape.ts +7 -2
- package/src/rules/SK004-gitignore-policy.ts +12 -4
- package/src/rules/SK005-rvlib-versions.ts +28 -1
- package/src/rules/SK006-npm-publish-safety.ts +3 -0
- package/src/rules/SK008-reviewer-agent.ts +6 -2
- package/src/rules/SK009-commit-ai-tells.ts +3 -0
- package/src/rules/SK010-undeclared-release-unit.ts +2 -0
- package/src/rules/SK011-sibling-unit-ranges.ts +2 -0
- package/src/rules/SK012-config-barrel-import.ts +6 -2
- package/src/rules/SK013-one-way-in-policy.ts +24 -4
- package/src/rules/rule.ts +22 -1
- package/src/rules/run-fix.ts +5 -3
- package/src/scaffold/files/verify-skill.md +1 -1
- package/src/ts/config.ts +66 -6
- package/src/ts/cycles.ts +7 -92
- package/src/ts/source-graph.ts +158 -0
- package/src/ts/tsconfig-projects.ts +101 -0
- package/src/ts/typecheck-cli.ts +46 -0
|
@@ -56,8 +56,8 @@ function installedNames(nm: string): string[] {
|
|
|
56
56
|
return names.sort()
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
/** the gate binary's own source, content-hashed.
|
|
60
|
-
*
|
|
59
|
+
/** the gate binary's own source, content-hashed. This is what makes editing
|
|
60
|
+
* shipkit invalidate every key in every repo, in-repo copy or dependency. */
|
|
61
61
|
function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
|
|
62
62
|
const srcRoot = resolve(cli, '..')
|
|
63
63
|
const files: string[] = []
|
|
@@ -75,6 +75,36 @@ function hashCliTree(h: ReturnType<typeof createHash>, cli: string): void {
|
|
|
75
75
|
h.update(`clifiles=${files.length}\n`)
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
/**
|
|
79
|
+
* what ONE install holds, as a hash: every top-level package's name, version
|
|
80
|
+
* and link target.
|
|
81
|
+
*
|
|
82
|
+
* Exported because a repo's own install is not the only one a gate can run
|
|
83
|
+
* against. Sub-packages carry their own `node_modules`, gitignored like every
|
|
84
|
+
* other, so no git key can see them and the root toolchain does not describe
|
|
85
|
+
* them either. A unit whose declared inputs COVER such a directory folds this
|
|
86
|
+
* in beside the path, or the toolchain component stops at the repo root while
|
|
87
|
+
* the task it is meant to describe does not.
|
|
88
|
+
*
|
|
89
|
+
* ~2ms per install, and only for a path a scan already reported as ignored.
|
|
90
|
+
*/
|
|
91
|
+
export function installIdentity(nodeModules: string): string {
|
|
92
|
+
if (!existsSync(nodeModules)) return 'install=absent'
|
|
93
|
+
const h = createHash('sha256')
|
|
94
|
+
let names: string[] = []
|
|
95
|
+
try { names = installedNames(nodeModules) } catch { return 'install=unreadable' }
|
|
96
|
+
for (const n of names) {
|
|
97
|
+
const dir = join(nodeModules, n)
|
|
98
|
+
let version = 'MISSING'
|
|
99
|
+
try { version = (JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as { version?: string }).version ?? '?' } catch {}
|
|
100
|
+
let link = ''
|
|
101
|
+
try { if (lstatSync(dir).isSymbolicLink()) link = `->${realpathSync(dir)}` } catch {}
|
|
102
|
+
h.update(`${n}@${version}${link}\n`)
|
|
103
|
+
}
|
|
104
|
+
h.update(`n=${names.length}\n`)
|
|
105
|
+
return `install=${h.digest('hex').slice(0, 16)}`
|
|
106
|
+
}
|
|
107
|
+
|
|
78
108
|
/**
|
|
79
109
|
* the identity of the program about to run, for `repoRoot`, driven by `cliEntry`.
|
|
80
110
|
*
|
|
@@ -94,12 +124,19 @@ export function toolchainIdentity(repoRoot: string, cliEntry: string): Toolchain
|
|
|
94
124
|
// the runtime. Free, and a bun upgrade genuinely changes behaviour
|
|
95
125
|
h.update(`bun=${Bun.version}\n`)
|
|
96
126
|
|
|
97
|
-
// the gate binary
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
127
|
+
// the gate binary, ALWAYS content-hashed, and its path never folded in.
|
|
128
|
+
//
|
|
129
|
+
// The earlier shape wrote `cli=in-repo` for a binary inside the repo and
|
|
130
|
+
// leaned on the git tree key to cover it. Both halves were wrong, and both
|
|
131
|
+
// were measured. In a CONSUMER repo the binary sits in `node_modules`,
|
|
132
|
+
// which is path-inside the root and inside no git tree, so the tree key
|
|
133
|
+
// covered none of it. And between a checkout that HOSTS the binary and a
|
|
134
|
+
// worktree that runs the same one through the global shim, one wrote
|
|
135
|
+
// `in-repo` and the other wrote an absolute path, so two checkouts running
|
|
136
|
+
// one identical program could never agree on a key. Content-hashing costs
|
|
137
|
+
// 3.5 to 9.2ms on a 163-file tree, and it is the only form that is the
|
|
138
|
+
// same value wherever the caller stands.
|
|
139
|
+
hashCliTree(h, realpathSync(cliEntry))
|
|
103
140
|
|
|
104
141
|
// what is installed. The lockfile states the intent; the tree states the fact
|
|
105
142
|
const lock = join(root, 'bun.lock')
|
|
@@ -101,7 +101,7 @@ function columnsFile(col: PbCollection): string {
|
|
|
101
101
|
lines.push('// means the cell is derived and the database cannot order by it.')
|
|
102
102
|
lines.push('// labels are plain text, swap them for i18n.t.<collection>.<field> when the')
|
|
103
103
|
lines.push('// app carries a dict.')
|
|
104
|
-
lines.push(`import type { Column } from "rvlib-pb
|
|
104
|
+
lines.push(`import type { Column } from "rvlib-pb/ui"`)
|
|
105
105
|
lines.push(`import type { ${col.prefix}Response } from "${TYPES}"`)
|
|
106
106
|
lines.push('')
|
|
107
107
|
lines.push(`export const columns: Column<${col.prefix}Response>[] = [`)
|
|
@@ -144,8 +144,8 @@ function gridFile(col: PbCollection): string {
|
|
|
144
144
|
return [
|
|
145
145
|
`// the default grid for \`${col.name}\`. A page that wants the plain table renders`,
|
|
146
146
|
'// this; a page that wants extra columns passes its own to LiveGrid directly.',
|
|
147
|
-
`import type { LiveList } from "rvlib-pb
|
|
148
|
-
`import { LiveGrid } from "rvlib-pb
|
|
147
|
+
`import type { LiveList } from "rvlib-pb"`,
|
|
148
|
+
`import { LiveGrid } from "rvlib-pb/ui"`,
|
|
149
149
|
`import type { ${col.prefix}Response } from "${TYPES}"`,
|
|
150
150
|
`import { columns } from "./columns"`,
|
|
151
151
|
'',
|
|
@@ -247,7 +247,7 @@ function indexFile(col: PbCollection, hasForm: boolean): string {
|
|
|
247
247
|
lines.push('// registration: after this import, a LiveGrid or LiveCards over')
|
|
248
248
|
lines.push(`// \`${col.name}\` with no columns of its own knows what to render, and any`)
|
|
249
249
|
lines.push('// relation cell pointing AT one knows how to name it.')
|
|
250
|
-
lines.push(`import { defineCollectionUI } from "rvlib-pb
|
|
250
|
+
lines.push(`import { defineCollectionUI } from "rvlib-pb/ui"`)
|
|
251
251
|
lines.push(`import { db } from "${PB}"`)
|
|
252
252
|
lines.push(`import { ${col.prefix}Card } from "./card"`)
|
|
253
253
|
lines.push(`import { columns } from "./columns"`)
|
package/src/release/run-bump.ts
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
// every selected unit's package.json and changelog. The publish part is
|
|
27
27
|
// RESUMABLE instead, because an npm publish cannot be undone and a rollback
|
|
28
28
|
// that cannot roll back is a lie.
|
|
29
|
-
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
29
|
+
import { existsSync, readFileSync, realpathSync, writeFileSync } from 'fs'
|
|
30
30
|
import { homedir } from 'os'
|
|
31
31
|
import { join, relative } from 'path'
|
|
32
32
|
import { declaredKind, hasUnreleasedNotes, promoteUnreleased } from './changelog.ts'
|
|
@@ -136,6 +136,26 @@ function mergeIntoReleaseBranch(
|
|
|
136
136
|
}
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
+
/** where a branch is checked out, or null. `git worktree list` is the only
|
|
140
|
+
* source that sees the OTHER checkouts: a linked worktree and its main
|
|
141
|
+
* checkout share one object store and one branch namespace, and git refuses
|
|
142
|
+
* to check a branch out twice. Three agents in one checkout, one of them in a
|
|
143
|
+
* worktree, is the normal case here, so this is asked before any ref moves. */
|
|
144
|
+
function branchCheckedOutAt(root: string, git: Git, branch: string): string | null {
|
|
145
|
+
const r = git(['worktree', 'list', '--porcelain'])
|
|
146
|
+
if (r.code !== 0) return null
|
|
147
|
+
let path: string | null = null
|
|
148
|
+
for (const line of r.out.split('\n')) {
|
|
149
|
+
if (line.startsWith('worktree ')) path = line.slice('worktree '.length).trim()
|
|
150
|
+
else if (line.trim() === `branch refs/heads/${branch}`) {
|
|
151
|
+
if (path == null) continue
|
|
152
|
+
try { if (realpathSync(path) === realpathSync(root)) return null } catch { /* a stale worktree entry: treat it as foreign */ }
|
|
153
|
+
return path
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return null
|
|
157
|
+
}
|
|
158
|
+
|
|
139
159
|
/** the rehearsal's answer to the same two questions, from the object database:
|
|
140
160
|
* has the release branch diverged from its remote, and would the merge
|
|
141
161
|
* conflict. Nothing is checked out and no ref moves, which is the whole point
|
|
@@ -189,6 +209,10 @@ function probeMergeIntoReleaseBranch(
|
|
|
189
209
|
return { refuse: false, inconclusive: stale }
|
|
190
210
|
}
|
|
191
211
|
|
|
212
|
+
/** a rehearsal moves no ref and checks nothing out, so a branch held by
|
|
213
|
+
* another worktree cannot stop it */
|
|
214
|
+
const rehearsalOnly = (o: BumpOpts): boolean => o.dryRun === true
|
|
215
|
+
|
|
192
216
|
type Selected = { unit: ReleaseUnit; version: string; changelogRel: string; kind: BumpKind }
|
|
193
217
|
|
|
194
218
|
export async function runBump(root: string, config: RepoConfig, kind: BumpKind | null, o: BumpOpts = {}): Promise<number> {
|
|
@@ -242,7 +266,11 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
|
|
|
242
266
|
const paths = dirtyPathList(preDirty)
|
|
243
267
|
if (paths.every((p) => machine.has(p))) {
|
|
244
268
|
gitAt(root, ['add', '--', ...paths])
|
|
245
|
-
|
|
269
|
+
// PATHSPEC on the commit too, not just the add. Three agents in
|
|
270
|
+
// one checkout share one index, so a bare `git commit` here takes
|
|
271
|
+
// whatever a neighbour staged a second ago and ships it under a
|
|
272
|
+
// message about machine rows.
|
|
273
|
+
const c = gitAt(root, ['commit', '-m', 'machine rows refresh before release', '--', ...paths])
|
|
246
274
|
if (c.code === 0) print('ok: machine-written rows committed (they re-dirty on every gate run)')
|
|
247
275
|
else print(`WARN machine-row auto-commit failed (exit ${c.code}), the refusal below says what to do:\n${c.out.split('\n').slice(-6).join('\n')}`)
|
|
248
276
|
} else {
|
|
@@ -263,6 +291,20 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
|
|
|
263
291
|
|
|
264
292
|
const wantMerge = cur !== branch && o.noMerge !== true && (config.release?.mergeFromBranch ?? true)
|
|
265
293
|
if (cur !== branch && !wantMerge) { print(`FAIL: releases ship from '${branch}', HEAD is on '${cur}'`); return 1 }
|
|
294
|
+
// the merge CHECKS OUT the release branch here, and git refuses to have
|
|
295
|
+
// one branch checked out in two worktrees. Left to git the run dies on a
|
|
296
|
+
// raw `fatal: 'main' is already used by worktree at …`, which reads like a
|
|
297
|
+
// shipkit bug. Worse, in the other direction, a bump run in the checkout
|
|
298
|
+
// that DOES hold the branch moves the HEAD of an agent working in it.
|
|
299
|
+
// Refuse by name, and say which checkout owns the branch.
|
|
300
|
+
if (wantMerge && !rehearsalOnly(o)) {
|
|
301
|
+
const held = branchCheckedOutAt(root, git, branch)
|
|
302
|
+
if (held != null) {
|
|
303
|
+
print(`FAIL: '${branch}' is checked out at ${held}, so this worktree cannot merge into it`)
|
|
304
|
+
print(` fix: bump from ${held}, or \`shipkit bump --no-merge\` after merging '${cur}' into '${branch}' by hand there`)
|
|
305
|
+
return 1
|
|
306
|
+
}
|
|
307
|
+
}
|
|
266
308
|
const remote = deriveRemote(root)
|
|
267
309
|
if ('error' in remote) { print(`FAIL: ${remote.error}`); return 1 }
|
|
268
310
|
if (wantMerge) print(`note: on '${cur}', so '${cur}' merges into '${branch}' first`)
|
|
@@ -482,12 +524,15 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
|
|
|
482
524
|
}
|
|
483
525
|
}
|
|
484
526
|
|
|
485
|
-
const add = git(['add', ...files])
|
|
527
|
+
const add = git(['add', '--', ...files])
|
|
486
528
|
if (add.code !== 0) return failKeep('git add failed', 'the version files are written but unstaged. git status, then commit them')
|
|
487
529
|
const label = selected.map((s) => (s.unit.id === 'root' ? s.version : `${s.unit.id}@${s.version}`)).join(', ')
|
|
488
530
|
// the hooks already ran through the gate above; --no-verify is NOT used,
|
|
489
|
-
// so the repo's own commit gates still apply to this commit
|
|
490
|
-
|
|
531
|
+
// so the repo's own commit gates still apply to this commit.
|
|
532
|
+
// PATHSPEC, because the index is shared with every other agent in this
|
|
533
|
+
// checkout: a bare commit here tags whatever a neighbour happened to have
|
|
534
|
+
// staged, and the tag then describes a tree nobody gated.
|
|
535
|
+
const commit = git(['commit', '-m', `release ${label}`, '--', ...files])
|
|
491
536
|
if (commit.code !== 0)
|
|
492
537
|
return failKeep(`commit failed — ${commit.out}`, 'the version files are written but uncommitted. fix the gate, commit, then `shipkit release`')
|
|
493
538
|
print(`ok: committed release ${label}`)
|
|
@@ -110,6 +110,11 @@ export async function runReleaseGate(root: string, config: RepoConfig, print: (l
|
|
|
110
110
|
frozen: true,
|
|
111
111
|
status: false,
|
|
112
112
|
config,
|
|
113
|
+
// THE CAP ON EVERY REMAINING HOLE IN THE KEY. Not a flag, not a config
|
|
114
|
+
// field: a release re-measures every unit, so the worst a wrong key
|
|
115
|
+
// can ever produce is an agent looking at a stale local green, never a
|
|
116
|
+
// stale green that authorised a publish (ledger/ledger.ts rule 4).
|
|
117
|
+
ledgerRead: false,
|
|
113
118
|
// the FULL tier, always. A release runs on a clean tree with time to
|
|
114
119
|
// spare, and the suites the working gate skips (the ones that build
|
|
115
120
|
// real repositories) are exactly the ones a publish must not be wrong
|
|
@@ -204,7 +204,13 @@ export function attentionLines(r: UnitReport): string[] {
|
|
|
204
204
|
// shape of a command
|
|
205
205
|
const KIND = '<patch|minor|major>'
|
|
206
206
|
|
|
207
|
-
if (r.
|
|
207
|
+
if (r.npmTarget && r.npmLatest === 'unpublished') {
|
|
208
|
+
// NO version of this name is on the registry, which every drift branch
|
|
209
|
+
// below reads as "a bit behind" and none of them says. Every `bun add
|
|
210
|
+
// <name>` in a doc, a seed or a scaffold answers 404 while this holds,
|
|
211
|
+
// and that is the one state where the docs are not stale but dead
|
|
212
|
+
out.push(`WARN ${r.id}: nothing is published under the name ${r.name} — every "bun add ${r.name}" 404s until ${notesClause}shipkit bump ${KIND} ${r.id}`)
|
|
213
|
+
} else if (r.commitsAfterNotes != null && r.commitsAfterNotes > 0 && !r.tagLocal) {
|
|
208
214
|
// the trap: a version stamped into package.json while work kept
|
|
209
215
|
// landing. Publishing it ships that work under notes describing none
|
|
210
216
|
// of it, so the answer is a NEW version, never this one.
|
package/src/release/unit-gate.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import { existsSync } from 'fs'
|
|
7
7
|
import { join } from 'path'
|
|
8
8
|
import { dirtyPaths } from './git-facts.ts'
|
|
9
|
+
import { resolveTypecheckProjects } from '../ts/tsconfig-projects.ts'
|
|
9
10
|
import type { ReleaseUnit } from './units.ts'
|
|
10
11
|
import type { RepoConfig } from '../repo-config.ts'
|
|
11
12
|
|
|
@@ -56,10 +57,22 @@ export function unitBlockers(root: string, config: RepoConfig, unit: ReleaseUnit
|
|
|
56
57
|
const tsc = resolveTsc(root, unit)
|
|
57
58
|
if (tsc == null) out.push(`unit '${unit.id}': tsc not resolvable — bun add -d typescript at the repo root`)
|
|
58
59
|
else {
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
60
|
+
// a solution-style tsconfig (`files: []` plus `references`) makes
|
|
61
|
+
// tsc compile ZERO files and exit 0, which would publish a package
|
|
62
|
+
// whose only proof of soundness was that nobody looked
|
|
63
|
+
let projects: ReturnType<typeof resolveTypecheckProjects> | null = null
|
|
64
|
+
try { projects = resolveTypecheckProjects(unit.tsconfig) }
|
|
65
|
+
catch (e) { out.push(`unit '${unit.id}': ${String((e as Error)?.message ?? e)}`) }
|
|
66
|
+
for (const m of projects?.missing ?? [])
|
|
67
|
+
out.push(`unit '${unit.id}': its tsconfig references '${m}', which does not exist, so whatever that project covered is unchecked`)
|
|
68
|
+
if (projects != null && projects.vacuous)
|
|
69
|
+
out.push(`unit '${unit.id}': its tsconfig compiles no files of its own and reaches no project that does, so a typecheck over it proves nothing — it cannot be published`)
|
|
70
|
+
for (const p of projects?.projects ?? []) {
|
|
71
|
+
const r = Bun.spawnSync([tsc, '--noEmit', '-p', p], { cwd: root })
|
|
72
|
+
if (r.exitCode !== 0) {
|
|
73
|
+
const lines = `${r.stdout.toString()}${r.stderr.toString()}`.trim().split('\n').slice(0, 8).join('\n')
|
|
74
|
+
out.push(`unit '${unit.id}': does not typecheck under ${p} — it cannot be published\n${lines}`)
|
|
75
|
+
}
|
|
63
76
|
}
|
|
64
77
|
}
|
|
65
78
|
}
|
package/src/repo-config.ts
CHANGED
|
@@ -23,7 +23,7 @@ import type { Rule } from './rules/rule.ts'
|
|
|
23
23
|
import type { HooksConfig } from './hooks/run-hooks.ts'
|
|
24
24
|
import type { ReleaseConfig } from './release/run-release.ts'
|
|
25
25
|
import { UNIT_DECL_KEYS, type ReleaseUnitDecl } from './release/units.ts'
|
|
26
|
-
import type
|
|
26
|
+
import { TASK_DEF_KEYS, type TaskDef } from './core/task-runner.ts'
|
|
27
27
|
import type { TuiPanelDef } from './tui/tui-panel.ts'
|
|
28
28
|
import type { MdGraph } from './wiki/md-graph.ts'
|
|
29
29
|
|
|
@@ -51,8 +51,13 @@ export type RepoConfig = {
|
|
|
51
51
|
social?: string[]
|
|
52
52
|
/** prefix for contract fix: pointers (docs checkout or URL) — written by init, never baked in */
|
|
53
53
|
docsBase?: string
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/** a floor slot: `false` disables it (always visible), a def with a
|
|
55
|
+
* `cmd` or `logic` REPLACES it, and a def with neither DECORATES whatever
|
|
56
|
+
* fills it. The decoration is how a repo declares what its own `test`
|
|
57
|
+
* script reads (`{ test: { inputs: ['tests', 'src'] } }`) without
|
|
58
|
+
* restating the command: the read set is repo knowledge, the command is
|
|
59
|
+
* already in package.json, and restating it is a second source of truth. */
|
|
60
|
+
defaults?: Record<string, Partial<TaskDef> | false>
|
|
56
61
|
/** repo-specific commit-gate tasks; deps double as race guards */
|
|
57
62
|
tasks?: TaskDef[]
|
|
58
63
|
/** emptyText: the `shipkit asks` all-clear line, in the repo's own voice */
|
|
@@ -158,9 +163,19 @@ export function defineRepo(c: RepoConfig): RepoConfig {
|
|
|
158
163
|
if (isAbsolute(d) || rel === '.' || rel === '' || rel === '..' || rel.startsWith('../'))
|
|
159
164
|
throw new Error(`defineRepo: notReleasable '${d}' is the repo root or outside it, and would excuse every package at once — name one package dir per entry`)
|
|
160
165
|
}
|
|
161
|
-
for (const k of Object.
|
|
166
|
+
for (const [k, slot] of Object.entries(c.defaults ?? {})) {
|
|
162
167
|
if (!(FLOOR_IDS as readonly string[]).includes(k))
|
|
163
168
|
throw new Error(`defineRepo: defaults.${k} is not a floor task (floor: ${FLOOR_IDS.join(', ')})`)
|
|
169
|
+
// the slot's own FIELDS, not just its name. A slot with no cmd or logic
|
|
170
|
+
// decorates the resolved task, so a typo there (`input` for `inputs`)
|
|
171
|
+
// is not a type error at any call site and lands as a task that is
|
|
172
|
+
// simply never cached, silently, which is the one direction this file
|
|
173
|
+
// promises a typo cannot go.
|
|
174
|
+
if (slot === false || slot == null) continue
|
|
175
|
+
for (const f of Object.keys(slot))
|
|
176
|
+
if (!(TASK_DEF_KEYS as readonly string[]).includes(f))
|
|
177
|
+
throw new Error(`defineRepo: defaults.${k}.${f} is not a task field (fields: ${TASK_DEF_KEYS.join(', ')})`)
|
|
178
|
+
}
|
|
164
179
|
for (const n of c.oneWayIn?.exempt ?? [])
|
|
165
180
|
if (!(ROUTED_SCRIPTS as readonly string[]).includes(n))
|
|
166
181
|
throw new Error(`defineRepo: oneWayIn.exempt '${n}' is not a governed script name (governed: ${ROUTED_SCRIPTS.join(', ')})`)
|
|
@@ -181,6 +196,13 @@ export async function loadRepoConfig(root: string): Promise<RepoConfig> {
|
|
|
181
196
|
// the contracts commit-gate task — venue deploy tasks should dep on it.
|
|
182
197
|
// Same SK002-tagged rendering as `shipkit check` and `shipkit contracts`
|
|
183
198
|
// (contracts/apply-contracts.ts is the one bridge).
|
|
199
|
+
//
|
|
200
|
+
// DELIBERATELY UNDECLARED for the ledger, and this is where that decision
|
|
201
|
+
// lives rather than in a commit message. It runs in process, shares one
|
|
202
|
+
// evaluation with rule SK002 through `withContractsScope`, and measures 0.0s,
|
|
203
|
+
// so a key would be risk against no reward. It is not `never:` either: the
|
|
204
|
+
// contract dirs describe it perfectly well, so claiming nothing can is false,
|
|
205
|
+
// and `never` has to keep meaning "somebody looked and no key exists".
|
|
184
206
|
export function contractsTask(c: RepoConfig, root: string): TaskDef {
|
|
185
207
|
return {
|
|
186
208
|
id: 'contracts',
|
|
@@ -8,13 +8,14 @@
|
|
|
8
8
|
// tool body they used to hold sits at `<id>:raw`. That is what makes a cache
|
|
9
9
|
// worth having: `bun run test` is the reflex, and the reflex has to land in
|
|
10
10
|
// shipkit rather than beside it.
|
|
11
|
-
import { existsSync, readdirSync, readFileSync
|
|
11
|
+
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
12
12
|
import { join } from 'path'
|
|
13
13
|
import { canonicalBodies, conditionalBodies, inferBin, ROUTED_SCRIPTS, rawName, routedBody, routesToShipkit } from '../core/canonical-scripts.ts'
|
|
14
14
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
15
15
|
import { resolveFloor } from '../core/floor.ts'
|
|
16
16
|
import type { RepoConfig } from '../repo-config.ts'
|
|
17
17
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
18
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
18
19
|
|
|
19
20
|
type Pkg = { name?: string; private?: boolean; license?: string; scripts?: Record<string, string> }
|
|
20
21
|
|
|
@@ -166,6 +167,11 @@ export const packageJsonShape: Rule = {
|
|
|
166
167
|
blocking: true,
|
|
167
168
|
title: 'package.json',
|
|
168
169
|
owner: OWNER,
|
|
170
|
+
// the whole tree: package.json is most of it, but the baseline-script
|
|
171
|
+
// check also walks the repo for a `*.test.ts` and probes `.rv-comfy`
|
|
172
|
+
// and `deploy.sh`, so naming three files would key it on less than it
|
|
173
|
+
// reads
|
|
174
|
+
inputs: ['.'],
|
|
169
175
|
lint: (t: CheckCtx, env: RuleEnv) => evaluatePackageJson(t, env.root, env.config),
|
|
170
176
|
// mechanical repairs only: field flips + scripts whose canonical body the
|
|
171
177
|
// vocabulary page states. Bodies it does NOT state (assets, comfy:up) are
|
|
@@ -230,7 +236,7 @@ export const packageJsonShape: Rule = {
|
|
|
230
236
|
}
|
|
231
237
|
if (changes.length > 0) {
|
|
232
238
|
const indent = raw.match(/^(\s+)"/m)?.[1] ?? ' '
|
|
233
|
-
|
|
239
|
+
atomicWrite(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
|
|
234
240
|
}
|
|
235
241
|
const summary = [
|
|
236
242
|
changes.length > 0 ? changes.join(', ') : 'nothing mechanical to change',
|
|
@@ -14,6 +14,10 @@ export const contractsFulfillness: Rule = {
|
|
|
14
14
|
blocking: true,
|
|
15
15
|
title: 'contracts',
|
|
16
16
|
owner: 'meta/contracts.md',
|
|
17
|
+
// the contract engine is pure filesystem (no fetch, no spawn anywhere in
|
|
18
|
+
// contracts/), and WHICH folders it reads depends on the venues declared,
|
|
19
|
+
// so the honest input is the work tree
|
|
20
|
+
inputs: ['.'],
|
|
17
21
|
lint: async (t, env) => {
|
|
18
22
|
applyContractsResult(t, await evaluateContractsOnce(activeContracts(env.config), contractsConfigFrom(env.config, env.root)), contractsFulfillness)
|
|
19
23
|
},
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
// start --include` flags own which base include a machine wires in. A repo
|
|
8
8
|
// with neither CLAUDE.md nor agent/ is not a two-line-contract repo and
|
|
9
9
|
// passes.
|
|
10
|
-
import { existsSync, readFileSync
|
|
10
|
+
import { existsSync, readFileSync } from 'fs'
|
|
11
11
|
import { join } from 'path'
|
|
12
12
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
13
13
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
14
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
14
15
|
|
|
15
16
|
const AGENT_LINE = '@./agent/index.md'
|
|
16
17
|
const LOCAL_LINE = '@./CLAUDE.local.md'
|
|
@@ -20,6 +21,10 @@ export const claudeMdShape: Rule = {
|
|
|
20
21
|
blocking: true,
|
|
21
22
|
title: 'CLAUDE.md',
|
|
22
23
|
owner: 'meta/agent-folder.md',
|
|
24
|
+
// CLAUDE.md plus whether agent/index.md exists — and a repo that has
|
|
25
|
+
// neither is the case this rule exists for, so the tree rather than two
|
|
26
|
+
// paths that may not be there
|
|
27
|
+
inputs: ['.'],
|
|
23
28
|
lint: async (t, env) => {
|
|
24
29
|
const claudePath = join(env.root, 'CLAUDE.md')
|
|
25
30
|
const hasAgent = existsSync(join(env.root, 'agent/index.md'))
|
|
@@ -59,7 +64,7 @@ export const claudeMdShape: Rule = {
|
|
|
59
64
|
if (!existsSync(claudePath)) return { changed: false, summary: 'CLAUDE.md missing — creation needs the base include (shipkit start --include), not autofixable' }
|
|
60
65
|
const cur = readFileSync(claudePath, 'utf8')
|
|
61
66
|
if (cur.split('\n').includes(AGENT_LINE)) return { changed: false, summary: `${AGENT_LINE} already routed — remaining findings need a human` }
|
|
62
|
-
|
|
67
|
+
atomicWrite(claudePath, `${cur.endsWith('\n') || cur === '' ? cur : `${cur}\n`}${AGENT_LINE}\n`)
|
|
63
68
|
return { changed: true, summary: `${AGENT_LINE} appended as the last line` }
|
|
64
69
|
},
|
|
65
70
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
// SK004 — the .gitignore policy block (meta/contracts.md git policy; tmp/
|
|
2
|
-
// included because shipkit
|
|
3
|
-
// repo). A marker-keyed managed block so the always-gitignored contract
|
|
2
|
+
// included because shipkit writes its per-run logs there in every repo). A marker-keyed managed block so the always-gitignored contract
|
|
4
3
|
// surfaces can never silently rot per repo. Ecosystem-specific names the
|
|
5
4
|
// published lib may not carry ride as repo-contributed lines:
|
|
6
5
|
// `defineRepo({ gitignore: [...] })` renders them INSIDE the block, so the
|
|
@@ -8,11 +7,12 @@
|
|
|
8
7
|
// PLACE — lines below it (e.g. a live venue's `!.rv-itch/` override, where
|
|
9
8
|
// last-match-wins) keep their position; everything outside the block is
|
|
10
9
|
// never touched. `shipkit init` writes the same block through this fixer.
|
|
11
|
-
import { existsSync, readFileSync
|
|
10
|
+
import { existsSync, readFileSync } from 'fs'
|
|
12
11
|
import { join } from 'path'
|
|
13
12
|
import type { RepoConfig } from '../repo-config.ts'
|
|
14
13
|
import { GENERATED_IGNORE_LINES, trackedGeneratedPaths, untrackGenerated } from './untrack-generated.ts'
|
|
15
14
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
15
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
16
16
|
|
|
17
17
|
const BEGIN = '# >>> shipkit gitignore policy (SK004) — managed block, edit outside only'
|
|
18
18
|
const END = '# <<< shipkit gitignore policy'
|
|
@@ -33,6 +33,11 @@ const POLICY_LINES = [
|
|
|
33
33
|
'.rv-discord/',
|
|
34
34
|
'CLAUDE.local.md',
|
|
35
35
|
'tmp/',
|
|
36
|
+
// atomicWrite lands every file via `.<name>.tmp-<pid>` next to its target,
|
|
37
|
+
// so a run killed mid write leaves one behind ANYWHERE in the tree, and an
|
|
38
|
+
// agent finds untracked debris it did not create. The ignore row ships in
|
|
39
|
+
// the same policy that started writing them.
|
|
40
|
+
'.*.tmp-*',
|
|
36
41
|
// comfy-ts state (schema dumps, drafts) and per-workflow lora tags name a
|
|
37
42
|
// private model collection; shipkit itself writes .comfy-ts/ at hub boot,
|
|
38
43
|
// so every consumer repo needs both ignored before its first commit
|
|
@@ -92,7 +97,7 @@ function writeBlock(root: string, config: RuleEnv['config']): void {
|
|
|
92
97
|
out = kept.length > 0 ? [...kept, '', ...block] : block
|
|
93
98
|
}
|
|
94
99
|
const eol = f?.crlf === true ? '\r\n' : '\n'
|
|
95
|
-
|
|
100
|
+
atomicWrite(p, `${out.join(eol)}${out.at(-1) === '' ? '' : eol}`)
|
|
96
101
|
}
|
|
97
102
|
|
|
98
103
|
export const gitignorePolicy: Rule = {
|
|
@@ -100,6 +105,9 @@ export const gitignorePolicy: Rule = {
|
|
|
100
105
|
blocking: true,
|
|
101
106
|
title: '.gitignore',
|
|
102
107
|
owner: 'meta/contracts.md',
|
|
108
|
+
// .gitignore plus what git currently TRACKS, which the tree carries and a
|
|
109
|
+
// single path does not
|
|
110
|
+
inputs: ['.'],
|
|
103
111
|
lint: (t, env) => {
|
|
104
112
|
// a generated path still in the index is the half-applied state the
|
|
105
113
|
// managed block cannot fix on its own
|
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
// never touch
|
|
17
17
|
// - package.json/lockfile git-dirty (or no git) → WARN deferred,
|
|
18
18
|
// a tool must not mix its writes into human-staged work
|
|
19
|
+
// - another run already holds the repo's mutate lease → the rule
|
|
20
|
+
// WAITS for it, re-reads the dirty probe inside the lease, and
|
|
21
|
+
// defers with a WARN if that run already did the work. Two
|
|
22
|
+
// `bun update --latest` calls against one node_modules is how a
|
|
23
|
+
// tree ends up half installed, and demoting the rule to report
|
|
24
|
+
// only instead would break "machine clearable red clears ITSELF"
|
|
19
25
|
// - the update itself failing splits by what it LEFT: a half-
|
|
20
26
|
// written package.json/lockfile is this repo and FAILs, an
|
|
21
27
|
// untouched tree is the registry and only WARNs
|
|
@@ -31,6 +37,7 @@
|
|
|
31
37
|
import { existsSync, readFileSync } from 'fs'
|
|
32
38
|
import { join } from 'path'
|
|
33
39
|
import type { CheckCtx } from '../core/check-ctx.ts'
|
|
40
|
+
import { mutateLease, withMutateLease } from '../core/locked-write.ts'
|
|
34
41
|
import type { Rule, RuleEnv } from './rule.ts'
|
|
35
42
|
|
|
36
43
|
type Pkg = { version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
@@ -105,7 +112,27 @@ export const rvlibVersions: Rule = {
|
|
|
105
112
|
return
|
|
106
113
|
}
|
|
107
114
|
const names = stale.map((s) => s.name)
|
|
108
|
-
|
|
115
|
+
// this rule WRITES, and three agents in one checkout is the normal
|
|
116
|
+
// case: two `bun update --latest` runs against one node_modules and
|
|
117
|
+
// one lockfile is how a tree ends up half installed. The lease is what
|
|
118
|
+
// lets the rule keep repairing itself instead of being demoted to
|
|
119
|
+
// report only, which would break "machine clearable red clears ITSELF".
|
|
120
|
+
// The dirty probe above is re-run INSIDE the lease, because the holder
|
|
121
|
+
// we waited for may have just done this exact update.
|
|
122
|
+
const leased = await withMutateLease(mutateLease(env.root, 'fix'), { label: 'self-update', print: () => {}, escape: 'SHIPKIT_FROZEN=1' }, async () => {
|
|
123
|
+
const again = await t.exec(['git', 'status', '--porcelain', '--', 'package.json', 'bun.lock', 'bun.lockb'], { cwd: env.root })
|
|
124
|
+
if (again.code === 0 && again.out.trim() !== '') return null
|
|
125
|
+
return await t.exec(['bun', 'update', '--latest', ...names], { cwd: env.root, timeoutMs: 120_000 })
|
|
126
|
+
})
|
|
127
|
+
if (!leased.ok) {
|
|
128
|
+
t.warn(`newer rvlib live: ${drift} — self-update deferred (${leased.warn}); re-run check once that run finishes`)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
if (leased.value == null) {
|
|
132
|
+
t.warn(`newer rvlib live: ${drift} — self-update deferred (package.json/lockfile went dirty while waiting for the write lease); commit first, then re-run check`)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
const up = leased.value
|
|
109
136
|
if (up.code !== 0) {
|
|
110
137
|
// which axis the failure sits on is measurable, so measure it: the
|
|
111
138
|
// tree is either half-written (this repo, blocks) or untouched, and
|
|
@@ -37,6 +37,9 @@ export const npmPublishSafety: Rule = {
|
|
|
37
37
|
blocking: true,
|
|
38
38
|
title: 'npm publish safety',
|
|
39
39
|
owner: 'meta/rv-conventions/package-json.md',
|
|
40
|
+
// pure filesystem: the root package.json plus every declared unit's, at
|
|
41
|
+
// dirs the config picks, so the tree rather than a list of paths
|
|
42
|
+
inputs: ['.'],
|
|
40
43
|
lint: (t, env) => {
|
|
41
44
|
const p = join(env.root, 'package.json')
|
|
42
45
|
if (!existsSync(p)) return t.ok('no package.json — nothing publishable')
|
|
@@ -7,11 +7,12 @@
|
|
|
7
7
|
// that edits by proxy. An absent `tools:` line is the worst case of all: the
|
|
8
8
|
// harness then inherits every tool. The brief itself must exist. No
|
|
9
9
|
// declaration = not a reviewer repo, and the rule passes silently.
|
|
10
|
-
import { existsSync, mkdirSync, readFileSync
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync } from 'fs'
|
|
11
11
|
import { homedir } from 'os'
|
|
12
12
|
import { dirname, isAbsolute, join } from 'path'
|
|
13
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
14
14
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
15
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
15
16
|
|
|
16
17
|
/** default read-only toolset — an ALLOWLIST, not a denylist: a tool that can
|
|
17
18
|
* edit by proxy (spawning a subagent, an MCP writer) is exactly what a
|
|
@@ -72,6 +73,9 @@ export const reviewerAgent: Rule = {
|
|
|
72
73
|
blocking: true,
|
|
73
74
|
title: 'reviewer agent',
|
|
74
75
|
owner: 'identity/how-we-work.md',
|
|
76
|
+
// the brief is a CONFIG path, and a `~/...` or absolute one puts it
|
|
77
|
+
// outside the repo, where no tree id can see whether it still exists
|
|
78
|
+
never: 'the reviewer brief may live outside the repo, where no tree id reaches it',
|
|
75
79
|
lint: (t, env) => {
|
|
76
80
|
const r = env.config.reviewer
|
|
77
81
|
if (r == null) return t.ok('no reviewer agent declared (repo.config reviewer:) — skipped')
|
|
@@ -93,7 +97,7 @@ export const reviewerAgent: Rule = {
|
|
|
93
97
|
const p = join(env.root, rel)
|
|
94
98
|
if (existsSync(p)) return { changed: false, summary: `${rel} exists — remaining findings are about its content, fix them by hand` }
|
|
95
99
|
mkdirSync(dirname(p), { recursive: true })
|
|
96
|
-
|
|
100
|
+
atomicWrite(p, canonicalAgent(r))
|
|
97
101
|
return { changed: true, summary: `${rel} written — points at ${r.brief}, no edit tools` }
|
|
98
102
|
},
|
|
99
103
|
}
|
|
@@ -29,6 +29,9 @@ export const commitAiTells: Rule = {
|
|
|
29
29
|
blocking: true,
|
|
30
30
|
title: 'commit ai tells',
|
|
31
31
|
owner: 'identity/how-we-commit.md',
|
|
32
|
+
// it scans the full `git log` and every ref name. History and refs are not
|
|
33
|
+
// work-tree content, so nothing a scan can produce describes this answer
|
|
34
|
+
never: 'reads the full commit history and every ref name, neither of which is work-tree content',
|
|
32
35
|
lint: async (t, env) => {
|
|
33
36
|
const declared = env.config.visibility ?? 'private'
|
|
34
37
|
if (declared !== 'public')
|
|
@@ -65,6 +65,8 @@ export const undeclaredReleaseUnit: Rule = {
|
|
|
65
65
|
blocking: true,
|
|
66
66
|
title: 'release units declared',
|
|
67
67
|
owner: 'build/shipkit.md',
|
|
68
|
+
// `git ls-files` plus a walk for every package.json in the repo
|
|
69
|
+
inputs: ['.'],
|
|
68
70
|
lint: (t, env) => {
|
|
69
71
|
const { units, errors } = resolveUnits(env.root, env.config)
|
|
70
72
|
// a DECLARED unit that does not resolve is a declaration bug and
|
|
@@ -59,6 +59,8 @@ export const siblingUnitRanges: Rule = {
|
|
|
59
59
|
blocking: true,
|
|
60
60
|
title: 'sibling unit ranges',
|
|
61
61
|
owner: 'build/shipkit.md',
|
|
62
|
+
// every declared unit's package.json, at dirs the config picks
|
|
63
|
+
inputs: ['.'],
|
|
62
64
|
lint: (t, env) => {
|
|
63
65
|
const { units } = resolveUnits(env.root, env.config)
|
|
64
66
|
const version = new Map(units.map((u) => [u.name, u.version]))
|
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
// A barrel is a fine PUBLIC surface and a terrible one to import from a config
|
|
11
11
|
// file that exists to be read before anything else happens. This rule is
|
|
12
12
|
// narrow on purpose: it is about repo.config.ts, not about barrels in general.
|
|
13
|
-
import { existsSync, readFileSync
|
|
13
|
+
import { existsSync, readFileSync } from 'fs'
|
|
14
14
|
import { join } from 'path'
|
|
15
15
|
import type { FixResult, Rule, RuleEnv } from './rule.ts'
|
|
16
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
16
17
|
|
|
17
18
|
const CONFIG = 'repo.config.ts'
|
|
18
19
|
const BARREL = /(^|\n)(\s*import\s+(?:type\s+)?\{[^}]*\}\s+from\s+)['"]rvlib-shipkit['"]/
|
|
@@ -31,6 +32,9 @@ export const configBarrelImport: Rule = {
|
|
|
31
32
|
blocking: true,
|
|
32
33
|
title: 'config import path',
|
|
33
34
|
owner: 'build/shipkit.md',
|
|
35
|
+
// ONE file, and the one file shipkit guarantees exists: loadRepoConfig
|
|
36
|
+
// refuses a repo without it
|
|
37
|
+
inputs: ['repo.config.ts'],
|
|
34
38
|
lint: (t, env) => {
|
|
35
39
|
const p = join(env.root, CONFIG)
|
|
36
40
|
if (!existsSync(p)) return void t.ok(`${CONFIG} absent — nothing to narrow`)
|
|
@@ -54,7 +58,7 @@ export const configBarrelImport: Rule = {
|
|
|
54
58
|
// only rewrite what the narrow module actually exports: silently
|
|
55
59
|
// repointing an import it cannot serve turns a slow gate into a broken one
|
|
56
60
|
if (importedSymbols(line).some((s) => !CONFIG_SYMBOLS.has(s))) return { changed: false, summary: 'imports symbols the narrow module does not export — move them by hand' }
|
|
57
|
-
|
|
61
|
+
atomicWrite(p, src.replace(BARREL, `$1$2'${NARROW}'`))
|
|
58
62
|
return { changed: true, summary: `${CONFIG} now imports ${NARROW} (~400ms saved per run)` }
|
|
59
63
|
},
|
|
60
64
|
}
|