rvlib-shipkit 0.22.2 → 0.24.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 +35 -2
- package/package.json +7 -4
- package/src/cli/cmd_Asks.ts +1 -1
- package/src/cli/cmd_Mv.ts +7 -10
- package/src/cli/cmd_Start.ts +2 -2
- package/src/collections/actions.ts +3 -3
- package/src/collections/collection-types.ts +1 -1
- package/src/collections/providers.ts +2 -2
- package/src/core/check-suite.ts +3 -3
- package/src/core/run-fixers.ts +3 -4
- package/src/git-bootstrap.ts +29 -4
- package/src/hooks/leak-guard.ts +6 -0
- package/src/hooks/run-hooks.ts +3 -3
- package/src/reflect/prompt-corpus.ts +1 -1
- package/src/release/run-bump.ts +29 -0
- package/src/release/run-release.ts +22 -0
- package/src/release/run-releases.ts +5 -1
- package/src/release/units.ts +48 -2
- package/src/repo-config.ts +5 -4
- package/src/rules/SK005-rvlib-versions.ts +13 -1
- package/src/rules/SK006-npm-publish-safety.ts +37 -3
- package/src/rules/SK011-sibling-unit-ranges.ts +91 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/serve/hub-app/cols-svc.ts +1 -1
- package/src/serve/hub-app/hub-app.tsx +1 -1
- package/src/serve/hub-app/hub.css +9 -3
- package/src/serve/hub-app/media-svc.ts +19 -4
- package/src/serve/hub-app/media-view.tsx +22 -9
- package/src/serve/run-serve.ts +10 -7
- package/src/serve/serve-hub.ts +2 -2
- package/src/start-repo.ts +50 -8
- package/src/ts/config.ts +185 -0
- package/src/ts/cycles.ts +205 -0
- package/src/ts/imports.ts +255 -0
- package/src/ts/rename.ts +238 -0
- package/src/ts/source-graph-cli.ts +19 -0
- package/src/ts/source-mask.ts +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,38 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.24.0
|
|
4
|
+
|
|
5
|
+
- **Custom panels lead the studio drawer, and the prompt folds.** A repo's `customPanel` tabs (the tint-mask painter, a legs editor) are the working surfaces, yet they rendered LAST in the drawer tab row, behind two json diagnostics. They come first now, `asset` then `roll meta` after, and a kind with exactly one custom panel gets it as the drawer's default tab until the user picks another. The prompt textarea folds to one ellipsized line by default (click to expand, a chevron folds it back, state holds for the session), so the stage and the rolls get the height back. The drawer toggle now reads `drawer ▾`, because with a custom panel as default content a button labelled JSON opened a paint surface.
|
|
6
|
+
|
|
7
|
+
- **`shipkit start` on a brand-new dir bootstraps a WORKING repo, end to end.** It used to stop at git + the github repo and hand back two `next:` steps, because init needs a package.json. Now the dir being created THIS RUN is the signal to scaffold: a minimal package.json (private, type module), a first `src/index.ts` so the ci gate is green from commit one instead of failing an imports scan over zero files, the baseline .gitignore before anything installs, `bun add -d rvlib-shipkit` so the wired scripts actually run, then init, first commit, push. The guards stay strict: a pre-existing folder without a project gets git only, `--github` on it forces the github half but never writes a project into someone's files, a subdirectory another work tree owns gets nothing, a temp dir or `SHIPKIT_NO_INSTALL=1` skips the install, and an install that cannot run (offline, no bun) is a WARN, never a stop.
|
|
8
|
+
|
|
9
|
+
- **SK011 checks the FLOOR, not just resolution.** `^0.1.1` resolves 0.1.2, so a range left behind by a bump passed the rule while a consumer tree already holding 0.1.1 kept it and never saw the release. A sibling range must now name exactly the version that sibling carries, which is what `shipkit bump` has always written. It found one real stale floor the moment it tightened.
|
|
10
|
+
|
|
11
|
+
## 0.23.0
|
|
12
|
+
|
|
13
|
+
- **Release order is a TOPOSORT of the units' own dependency graph.** It was "sub-units first, root last", so a `--all` run published in declaration order and a dependent could ship naming a version the registry did not have yet. Kahn's algorithm now, stable (ties keep declaration order, root always last), and a cycle degrades to declaration order rather than dropping a unit: a release that silently skips a package is worse than one in a bad order.
|
|
14
|
+
|
|
15
|
+
- **The source-graph toolbelt lives IN shipkit** (`src/ts/`): rename, imports, cycles, and the shared config. `rvlib-ts-tools` is gone as a dependency. A gate that REQUIRES a tool must never be a published version behind the repo it gates, which is exactly how `shipkit mv` renamed a file and rewrote zero references while reporting success.
|
|
16
|
+
- **Fix: `shipkit mv` was blind to whole trees.** The default skip list matched a bare name against ANY path segment, so a package under a dir named `build` (or `dist`, `out`, `cjs`) was invisible to every scan. Those names now match only where output actually lands: the repo root, or a package root. `node_modules` and `.git` still match at any depth.
|
|
17
|
+
- **Fix: `shipkit mv` derives the package-qualified specifier by walking UP from the file**, not from the configured roots. The content scan was always repo-wide, so the specifier derivation had to be too: a repo whose packages sit outside `roots` renamed the file and rewrote nothing.
|
|
18
|
+
- **`shipkit mv` reports what it CANNOT rewrite.** A file naming the moved file's stem without a rewritable path (a regex-escaped path in a test, a doc line, a specifier built from parts) is listed as a near-miss instead of being silently skipped. It is never rewritten, because guessing there is how a rename corrupts a repo.
|
|
19
|
+
|
|
20
|
+
- **A bump moves its dependents' ranges forward, in the same commit**, including between units bumped in the SAME run: skipping selected units left every sibling range naming the version its dependency had just left. Bumping a unit used to leave every sibling naming the OLD version, so the tree published packages that install the wrong code or nothing at all. `shipkit bump` now rewrites the caret/tilde/exact range in every other unit that names a bumped one, and stages it with the version commit. Local protocols are left alone.
|
|
21
|
+
- **New rule SK011: a sibling range must be satisfied by the version that sibling carries.** The complement of the above, for a hand-edited range or an interrupted bump. It found real drift the day it landed: a peer range of `^0.1.2` on a lib that had moved to `0.2.0`, which a caret on `0.x` genuinely excludes, so consumers were installing the wrong major of it.
|
|
22
|
+
|
|
23
|
+
- **SK006 refuses a local dependency protocol in anything publishable.** `link:`, `file:`, `portal:` or `workspace:` in `dependencies`, `peerDependencies` or `optionalDependencies` cannot resolve on a consumer's machine: publishing that ships a package which fails at INSTALL, for a stranger, never for you. It scans every release UNIT, not just the repo root, because the root is usually private and the units are what publish. devDependencies are exempt (npm never installs them for a consumer), and the working shape is the future version range in the dependency plus the local wiring in `overrides`.
|
|
24
|
+
|
|
25
|
+
- **A leak hit reports a mask, never the matched string** (`kre…v1 (23 chars)`). The pattern is private by construction, and a gate that prints it verbatim leaks it into the terminal, the CI log and any paste of either.
|
|
26
|
+
|
|
27
|
+
- **A temp dir never mints a github repository.** A root under any system temp path (`TMPDIR`, `/tmp`, `/var/tmp`, either spelling of each) skips the github half with a line naming `--github` to force it. An agent rehearsing `shipkit init`/`start` in a scratch dir created three real repositories on the account, and deleting one needs a token scope the machine does not carry. Only the real account is guarded: an injected `gh` runner is a test with an account of its own.
|
|
28
|
+
|
|
29
|
+
- **MobX 7 + mobx-react-lite 5** for the TUI. It is the ONE lib still on `mobx-react-lite`, so the one still pinned to a single major: consuming rvlib-mobx's seam by `link:` makes the TUI resolve React from that lib's own node_modules, two React copies, invalid hook call. That is a `link:`-only artifact, gone once the seam is published and installed as a tarball, so the swap rides the release that picks it up. RELEASE ORDER MATTERS: this version must not ship before `comfy-ts` accepts MobX 7, and its dep range must move to that version in the same release. A comfy-ts that declares `mobx@^6` alone gets a nested copy beside our 7, the observables go invisible across the boundary, and a bare `import 'rvlib-shipkit'` throws "There are multiple, different versions of MobX active" in a consumer's tree. The `overrides` block in this package.json fixes the LOCAL tree only: overrides are honored in the install root, never through a published dependency.
|
|
30
|
+
- **SK005's clean-pass cache is keyed on the INSTALLED rvlib versions.** Keyed on the repo alone, a manual `bun add -d rvlib-x@N` left every run replaying `ok: rvlib-x 0.16.0 (latest) (cached 3h ago)` for 24h, naming a version the tree no longer carried and calling it latest.
|
|
31
|
+
- **`shipkit releases` stops calling a clean root unit edited.** `treeAt` compared what `git rev-parse <ref>` returns, the COMMIT for `HEAD` and the TAG OBJECT for an annotated tag, so a tag sitting exactly on HEAD with an empty diff warned "names contents this branch does not carry". It compares `<ref>^{tree}` now.
|
|
32
|
+
- **`release.after`: the step that makes a release LIVE.** `repo.config.ts release: { after: ['bun', 'run', 'up'] }` runs after the tag, the push and the GitHub release, for the ROOT unit only. Prepublish scripts cannot fill this slot: they run BEFORE the version commit, so they are build steps. It runs LAST, so a failed deploy unwinds nothing already published. It is not resumable by re-running `release` (a fully released unit refuses), so re-run the command it prints.
|
|
33
|
+
|
|
34
|
+
- **The hub banner reads like a banner.** No wire prefixes, no route-per-line checklist: one clickable line (`<repo> hub → http://localhost:<port>`), an indented `also:` line listing the real routes, and the `phone:` line when a public host is configured. Wire prefixes stay reserved for problems.
|
|
35
|
+
|
|
3
36
|
## 0.22.2
|
|
4
37
|
|
|
5
38
|
- **Frozen gates never write the motherhome row.** The bump preflight ate the machine dirt, then the gate INSIDE the bump re-wrote the local-projects row and the post-gate cleanliness check refused, the same wart's second head. A frozen run is release-shaped: it reports nothing to the motherhome, so the tree it proves clean stays clean. Interactive ci/check keep reporting.
|
|
@@ -7,12 +40,12 @@
|
|
|
7
40
|
|
|
8
41
|
## 0.22.1
|
|
9
42
|
|
|
10
|
-
- **Fix: the cache stamp is baked in at RUN time, replays print it verbatim.** 0.22.0 annotated only the REPLAY, so a fresh run and a cache hit rendered different strings and STATUS.md still diffed on every alternation
|
|
43
|
+
- **Fix: the cache stamp is baked in at RUN time, replays print it verbatim.** 0.22.0 annotated only the REPLAY, so a fresh run and a cache hit rendered different strings and STATUS.md still diffed on every alternation. A TTL clean pass now writes "(cached at <stamp> until <stamp>)" into the line once, live output and every replay are byte-identical, and the line changes only when a re-validation happens. Proven: fresh-then-cached runs produce a zero-diff STATUS body.
|
|
11
44
|
|
|
12
45
|
## 0.22.0
|
|
13
46
|
|
|
14
47
|
- **Fix: the machine-row auto-commit could not see STATUS.md.** The git helper trims its whole output, so the FIRST porcelain line loses its leading space and the old column-3 slice read "TATUS.md", making the preflight silently decline the exact dirt it was built to eat. `dirtyPathList` parses status prefixes robustly now (renames and quoted paths included, regression-tested on the trimmed-first-line case), and a decline is never silent: the preflight names the non-machine paths it refused.
|
|
15
|
-
- **Cached check lines carry an absolute stamp** (`cached 2026-08-05 09:12:34`), never a relative age: "cached 34m ago" re-rendered on every run and turned committed STATUS.md checkpoints into noise diffs
|
|
48
|
+
- **Cached check lines carry an absolute stamp** (`cached 2026-08-05 09:12:34`), never a relative age: "cached 34m ago" re-rendered on every run and turned committed STATUS.md checkpoints into noise diffs. The line now changes only when the cache actually refreshes.
|
|
16
49
|
|
|
17
50
|
- **The pre-commit gate times itself.** Past ~1.5s it WARNs naming the fix (move slow preCommit commands to ci tasks), because "a commit takes ~1s" was a stated rule with no tripwire, and a repo drifting back to 40-second commits would have told nobody.
|
|
18
51
|
- **The bootstrap is a CHAIN, and it ends at a push.** `shipkit start` and `shipkit init` now run git init → **first commit** → PRIVATE github repo → **push**, in that order. A repo wired but never pushed had backed up nothing, which was the whole point. The chain runs in two phases around whatever the verb scaffolds, so `CLAUDE.md`, `repo.config.ts` and the package.json scripts ride INSIDE the first commit instead of trailing it as dirt.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rvlib-shipkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -24,17 +24,20 @@
|
|
|
24
24
|
"@types/react-dom": "^19.2.3",
|
|
25
25
|
"@types/sanitize-html": "^2.16.1"
|
|
26
26
|
},
|
|
27
|
+
"overrides": {
|
|
28
|
+
"mobx": "^7.0.0",
|
|
29
|
+
"mobx-react-lite": "^5.0.0"
|
|
30
|
+
},
|
|
27
31
|
"dependencies": {
|
|
28
32
|
"@napi-rs/canvas": "^1.0.2",
|
|
29
33
|
"clipanion": "^4.0.0-rc.4",
|
|
30
34
|
"comfy-ts": "^2.4.2",
|
|
31
35
|
"ink": "^7.1.1",
|
|
32
36
|
"marked": "^18.0.7",
|
|
33
|
-
"mobx": "^
|
|
34
|
-
"mobx-react-lite": "^
|
|
37
|
+
"mobx": "^7.0.0",
|
|
38
|
+
"mobx-react-lite": "^5.0.0",
|
|
35
39
|
"react": "^19.2.7",
|
|
36
40
|
"react-dom": "^19.2.7",
|
|
37
|
-
"rvlib-ts-tools": "^0.3.0",
|
|
38
41
|
"sanitize-html": "^2.17.6",
|
|
39
42
|
"sharp": "^0.35.3"
|
|
40
43
|
}
|
package/src/cli/cmd_Asks.ts
CHANGED
|
@@ -67,7 +67,7 @@ export class AskNewCommand extends Command {
|
|
|
67
67
|
kind = Option.String('--kind', { required: false, description: 'ask kind: action | credential | decision | purchase' })
|
|
68
68
|
blocks = Option.String('--blocks', { required: false, description: 'what this ask blocks (task id, page, plain words)' })
|
|
69
69
|
check = Option.String('--check', { required: false, description: 'shell probe that exits 0 once the ask is done (enables auto-close)' })
|
|
70
|
-
why = Option.String('--why', { required: false, description: 'one line of context
|
|
70
|
+
why = Option.String('--why', { required: false, description: 'one line of context: why this is worth a human interrupt' })
|
|
71
71
|
|
|
72
72
|
async execute(): Promise<number> {
|
|
73
73
|
try {
|
package/src/cli/cmd_Mv.ts
CHANGED
|
@@ -1,12 +1,11 @@
|
|
|
1
1
|
// shipkit mv, ONE rename command for the whole repo. An md page routes to
|
|
2
2
|
// mv/mv-md.ts (page + sibling asset dir + check.ts + every tracked ref);
|
|
3
|
-
// anything else
|
|
3
|
+
// anything else runs shipkit's own rename, which rewrites every path
|
|
4
4
|
// reference in the code. Renaming used to mean knowing WHICH tool applied,
|
|
5
5
|
// and knowing that is exactly the kind of doubt the toolkit exists to remove.
|
|
6
6
|
import { Command, Option } from 'clipanion'
|
|
7
|
-
import { fileURLToPath } from 'url'
|
|
8
7
|
import { movePage } from '../mv/mv-md.ts'
|
|
9
|
-
import {
|
|
8
|
+
import { renameCli } from '../ts/rename.ts'
|
|
10
9
|
|
|
11
10
|
export class MvCommand extends Command {
|
|
12
11
|
static override paths = [['mv']]
|
|
@@ -37,13 +36,11 @@ export class MvCommand extends Command {
|
|
|
37
36
|
this.context.stderr.write('FAIL: --commit is md-only for now — run with --write, then commit\n')
|
|
38
37
|
return 1
|
|
39
38
|
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (!write && r.code === 0) this.context.stdout.write('note: dry run — pass --write to apply\n')
|
|
46
|
-
return r.code
|
|
39
|
+
// in-process: the rename lives HERE now, so `shipkit mv` can
|
|
40
|
+
// never be a version behind the tool it delegates to
|
|
41
|
+
const code = renameCli([this.oldPath, this.newPath, ...(write ? ['--apply'] : [])])
|
|
42
|
+
if (!write && code === 0) this.context.stdout.write('note: dry run — pass --write to apply\n')
|
|
43
|
+
return code
|
|
47
44
|
}
|
|
48
45
|
return movePage(process.cwd(), this.oldPath, this.newPath, {
|
|
49
46
|
print: (l) => this.context.stdout.write(`${l}\n`),
|
package/src/cli/cmd_Start.ts
CHANGED
|
@@ -9,9 +9,9 @@ export class StartCommand extends Command {
|
|
|
9
9
|
|
|
10
10
|
static override usage = Command.Usage({
|
|
11
11
|
category: 'Setup',
|
|
12
|
-
description: 'create (or enter) ~/dev/<name> from ANY cwd: ensures the dir, seeds CLAUDE.md include lines,
|
|
12
|
+
description: 'create (or enter) ~/dev/<name> from ANY cwd, READY to work in: ensures the dir, seeds CLAUDE.md include lines, and on a BRAND-NEW dir scaffolds a minimal package.json + src/index.ts + the rvlib-shipkit dev dep, wires shipkit (repo.config.ts via init), then runs the whole bootstrap chain: git init, first commit, PRIVATE github repo, push. A pre-existing folder with no project gets git only, never a scaffold (--github forces only the github half for it). Prints the dir on stdout so a shell alias can cd there. cwd-only sibling: `shipkit init`',
|
|
13
13
|
examples: [
|
|
14
|
-
['create or enter ~/dev/pong: dir, wiring, git, first commit, private github repo, push', 'shipkit start pong'],
|
|
14
|
+
['create or enter ~/dev/pong: dir, package.json, shipkit wiring, git, first commit, private github repo, push', 'shipkit start pong'],
|
|
15
15
|
['seed CLAUDE.md includes', "shipkit start pong --include '@~/wiki/index.md' --include '@./agent/index.md'"],
|
|
16
16
|
['adopt a pre-existing repo from anywhere', 'shipkit start my-old-project'],
|
|
17
17
|
['force the private github repo for a folder that was already there', 'shipkit start old-notes --github'],
|
|
@@ -55,16 +55,16 @@ export function appendPostFeedback(root: string, channel: string, file: string,
|
|
|
55
55
|
writeFileSync(p, /^## Feedback\s*$/m.test(md) ? `${md.trimEnd()}\n${line}\n` : `${md.trimEnd()}\n\n## Feedback\n${line}\n`)
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
-
/**
|
|
58
|
+
/** an asset remark → `.rv-assets/notes.md` (git-tracked; the studio
|
|
59
59
|
* workspace is gitignored, so notes live beside kit.ts) */
|
|
60
60
|
export function appendAssetNote(root: string, assetId: string, text: string): void {
|
|
61
61
|
if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(assetId)) throw new Error('bad asset id')
|
|
62
62
|
const p = join(root, '.rv-assets', 'notes.md')
|
|
63
63
|
const line = `- ${today()} [${assetId}]: ${text.replace(/\n+/g, ' ').trim()}`
|
|
64
|
-
writeFileSync(p, existsSync(p) ? `${readFileSync(p, 'utf8').trimEnd()}\n${line}\n` : `# asset notes
|
|
64
|
+
writeFileSync(p, existsSync(p) ? `${readFileSync(p, 'utf8').trimEnd()}\n${line}\n` : `# asset notes, read by agents at boot\n\n${line}\n`)
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
-
/**
|
|
67
|
+
/** a remark on an ask → appended to the ask file, dated, non-destructive.
|
|
68
68
|
* Accepts exactly one optional `done/` prefix (archived asks stay
|
|
69
69
|
* annotatable); any other slash or traversal is a bad path. */
|
|
70
70
|
export function appendAskRemark(root: string, asksDir: string, file: string, text: string): void {
|
|
@@ -28,7 +28,7 @@ export type Instance = {
|
|
|
28
28
|
file?: string
|
|
29
29
|
/** thumbnail URL for media tiles */
|
|
30
30
|
img?: string
|
|
31
|
-
/**
|
|
31
|
+
/** the latest remarks from the item's own file, agents boot on these */
|
|
32
32
|
remarks?: string[]
|
|
33
33
|
/** the truth file's markdown BODY (frontmatter stripped) — clamped card
|
|
34
34
|
* in the grid, full in the item view; capped server-side, never a dump */
|
|
@@ -92,7 +92,7 @@ const askMd = (path: string): string | undefined => {
|
|
|
92
92
|
return body.length > MD_CAP ? `${[...body].slice(0, MD_CAP).join('')}…` : body
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
/**
|
|
95
|
+
/** the latest dated remark lines from a file's tail, the boot briefing */
|
|
96
96
|
const lastRemarks = (path: string, max = 3): string[] | undefined => {
|
|
97
97
|
if (!existsSync(path)) return undefined
|
|
98
98
|
const lines = readFileSync(path, 'utf8').split('\n').filter((l) => /^- \d{4}-\d{2}-\d{2}: /.test(l))
|
|
@@ -212,7 +212,7 @@ export function mediaKitCollection(root: string, kit: ResolvedKit | null, heal:
|
|
|
212
212
|
}
|
|
213
213
|
}
|
|
214
214
|
|
|
215
|
-
/** `.rv-assets/notes.md
|
|
215
|
+
/** `.rv-assets/notes.md`, dated asset remarks in one git-tracked file:
|
|
216
216
|
* `- YYYY-MM-DD [asset-id]: text` */
|
|
217
217
|
const assetNotes = (root: string): Map<string, string[]> => {
|
|
218
218
|
const p = join(root, '.rv-assets', 'notes.md')
|
package/src/core/check-suite.ts
CHANGED
|
@@ -146,9 +146,9 @@ export class CheckSuite {
|
|
|
146
146
|
// the stamp was baked in when the check RAN (below): a replay
|
|
147
147
|
// prints the stored lines VERBATIM, so the live run and every
|
|
148
148
|
// later replay render the same string, and the line changes
|
|
149
|
-
// only when a re-validation happens
|
|
150
|
-
//
|
|
151
|
-
//
|
|
149
|
+
// only when a re-validation happens. Annotating replays but
|
|
150
|
+
// not live runs still diffed on every fresh-vs-cached
|
|
151
|
+
// alternation, so neither side annotates
|
|
152
152
|
out.push(...entry.lines)
|
|
153
153
|
this.onResult?.(r.name, { lines: entry.lines, status: 'pass', cached: true }) // only clean passes are cached
|
|
154
154
|
continue
|
package/src/core/run-fixers.ts
CHANGED
|
@@ -21,7 +21,7 @@ const FIXER_TIMEOUT_MS = 120_000
|
|
|
21
21
|
export type FixerSlot = {
|
|
22
22
|
id: string
|
|
23
23
|
cmd: string[]
|
|
24
|
-
source: 'package.json script' | 'built-in (
|
|
24
|
+
source: 'package.json script' | 'built-in (shipkit)'
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
export type FixerResolution = {
|
|
@@ -68,9 +68,8 @@ export function resolveFixers(root: string, config?: RepoConfig): FixerResolutio
|
|
|
68
68
|
else if (scripts['imports:fix'] != null) slots.push({ id: 'imports:fix', cmd: ['bun', 'run', 'imports:fix'], source: 'package.json script' })
|
|
69
69
|
else {
|
|
70
70
|
let cli: string | null = null
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
else slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (rvlib-ts-tools)' })
|
|
71
|
+
cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
|
|
72
|
+
slots.push({ id: 'imports:fix', cmd: ['bun', cli, 'imports', '--fix'], source: 'built-in (shipkit)' })
|
|
74
73
|
}
|
|
75
74
|
return { slots, unfilled, disabled }
|
|
76
75
|
}
|
package/src/git-bootstrap.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
// unverifiable path (no gh, not authenticated, offline) is a WARN that
|
|
16
16
|
// leaves the local repo wired and names the next move.
|
|
17
17
|
import { existsSync, realpathSync } from 'fs'
|
|
18
|
+
import { tmpdir } from 'os'
|
|
18
19
|
import { basename, join } from 'path'
|
|
19
20
|
import { spawnWithTimeout } from './core/spawn-timeout.ts'
|
|
20
21
|
|
|
@@ -47,8 +48,10 @@ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
|
47
48
|
/** what a repo with NO .gitignore at all gets before its first commit. Not a
|
|
48
49
|
* policy (rule SK004 owns that one), just the three things nobody ever meant
|
|
49
50
|
* to publish. A repo that already has a .gitignore keeps its own answer, and
|
|
50
|
-
* the commit refuses instead when that answer misses these
|
|
51
|
-
|
|
51
|
+
* the commit refuses instead when that answer misses these. Two writers:
|
|
52
|
+
* firstCommit below, and the start scaffold (BEFORE its dep install, so
|
|
53
|
+
* node_modules never reaches the first commit) */
|
|
54
|
+
export const IGNORE_BASELINE = ['node_modules/', '.env', '.env.*', '!.env.example', '.DS_Store']
|
|
52
55
|
|
|
53
56
|
/** the one line of gh or git output worth reporting — both merge stderr into
|
|
54
57
|
* stdout, so the first non-empty line is the message and the rest is chatter */
|
|
@@ -67,7 +70,7 @@ export function carveGhJson<T>(out: string): T | null {
|
|
|
67
70
|
}
|
|
68
71
|
}
|
|
69
72
|
|
|
70
|
-
const runner = (bin: string, root: string, timeoutMs: number): Run => async (cmd) => {
|
|
73
|
+
export const runner = (bin: string, root: string, timeoutMs: number): Run => async (cmd) => {
|
|
71
74
|
// a credential prompt would hang a bootstrap forever: git fails instead
|
|
72
75
|
const env = { ...process.env, GIT_TERMINAL_PROMPT: '0' }
|
|
73
76
|
const r = await spawnWithTimeout([bin, ...cmd], { cwd: root, timeoutMs, env })
|
|
@@ -76,6 +79,20 @@ const runner = (bin: string, root: string, timeoutMs: number): Run => async (cmd
|
|
|
76
79
|
|
|
77
80
|
const gitIn = (root: string): Run => runner('git', root, 120_000)
|
|
78
81
|
|
|
82
|
+
/** the system temp tree, resolved through the symlink macOS puts in front of
|
|
83
|
+
* it. A repo under here is a rehearsal by construction */
|
|
84
|
+
export const isScratch = (root: string): boolean => {
|
|
85
|
+
const real = (p: string): string => { try { return realpathSync(p) } catch { return p } }
|
|
86
|
+
// every temp root, not just the one TMPDIR names: macOS answers
|
|
87
|
+
// /var/folders there while agents and harnesses scratch under /tmp, and a
|
|
88
|
+
// repo minted from /private/tmp is exactly as unwanted. Both spellings of
|
|
89
|
+
// both sides too, since /var resolves to /private/var and a path that
|
|
90
|
+
// does not exist yet cannot be resolved at all
|
|
91
|
+
const tmps = [tmpdir(), real(tmpdir()), '/tmp', '/private/tmp', '/var/tmp', '/private/var/tmp']
|
|
92
|
+
const roots = [root, real(root)]
|
|
93
|
+
return tmps.some((t) => roots.some((r) => `${r}/`.startsWith(`${t}/`)))
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
const samePath = (a: string, b: string): boolean => {
|
|
80
97
|
try {
|
|
81
98
|
return realpathSync(a) === realpathSync(b)
|
|
@@ -152,7 +169,15 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
|
|
|
152
169
|
let gh: Run | null
|
|
153
170
|
if (opts.gh !== undefined) gh = opts.gh
|
|
154
171
|
else if ((process.env.SHIPKIT_NO_GITHUB ?? '') !== '') return void p('skip: github — SHIPKIT_NO_GITHUB is set')
|
|
155
|
-
else
|
|
172
|
+
else if (opts.github !== true && isScratch(root)) {
|
|
173
|
+
// a scratch dir is somebody TRYING the verb, never starting a project.
|
|
174
|
+
// Two repositories were minted exactly that way, and deleting one
|
|
175
|
+
// needs a token scope this machine does not carry, so the cost of
|
|
176
|
+
// guessing wrong is not symmetric. Only the REAL account is guarded:
|
|
177
|
+
// an injected runner is a test with an account of its own
|
|
178
|
+
p(`skip: github — ${root} is a temp dir, not a project`)
|
|
179
|
+
return void p(' fix: pass --github if you really want a repo for it')
|
|
180
|
+
} else gh = Bun.which('gh') == null ? null : runner('gh', root, 60_000)
|
|
156
181
|
if (gh == null) {
|
|
157
182
|
p('WARN no remote wired: gh cli not found')
|
|
158
183
|
return void p(' fix: install gh, `gh auth login`, then re-run this command')
|
package/src/hooks/leak-guard.ts
CHANGED
|
@@ -9,6 +9,12 @@ import { join } from 'path'
|
|
|
9
9
|
|
|
10
10
|
export type LeakHit = { where: string; pattern: string }
|
|
11
11
|
|
|
12
|
+
/** what a hit REPORTS. The matched string is private by construction, and a
|
|
13
|
+
* gate that prints it verbatim puts it in a terminal, a CI log and any paste
|
|
14
|
+
* of either. Enough to recognize, not enough to be quotable. */
|
|
15
|
+
export const maskPattern = (p: string): string =>
|
|
16
|
+
p.startsWith('re:') || p.length <= 6 ? p : `${p.slice(0, 3)}…${p.slice(-2)} (${p.length} chars)`
|
|
17
|
+
|
|
12
18
|
export type LeakGuardResult =
|
|
13
19
|
| { status: 'skipped'; reason: string }
|
|
14
20
|
| { status: 'clean'; patterns: number }
|
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
|
|
|
7
7
|
import { dirname, join } from 'path'
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
9
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
10
|
-
import { globalKeywordFile, runLeakGuard } from './leak-guard.ts'
|
|
10
|
+
import { globalKeywordFile, maskPattern, runLeakGuard } from './leak-guard.ts'
|
|
11
11
|
import { runRootIdentity } from './root-identity.ts'
|
|
12
12
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
13
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -143,7 +143,7 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
143
143
|
ok: false,
|
|
144
144
|
lines: [
|
|
145
145
|
`FAIL: leak-guard matched ${r.hits.length} banned pattern(s) — this commit would leak private content`,
|
|
146
|
-
...r.hits.map((x) => ` ${x.where}: ${x.pattern}`),
|
|
146
|
+
...r.hits.map((x) => ` ${x.where}: ${maskPattern(x.pattern)}`),
|
|
147
147
|
` fix: remove the content, or edit the keyword file if the pattern is wrong`,
|
|
148
148
|
],
|
|
149
149
|
})
|
|
@@ -223,7 +223,7 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
223
223
|
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
224
224
|
const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, text: [{ label: 'commit message', content: message }] })
|
|
225
225
|
if (r.status === 'hit')
|
|
226
|
-
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map((x) => ` ${x.where}: ${x.pattern}`)] })
|
|
226
|
+
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map((x) => ` ${x.where}: ${maskPattern(x.pattern)}`)] })
|
|
227
227
|
}
|
|
228
228
|
for (const c of h.commitMsg ?? []) {
|
|
229
229
|
const r = await spawnWithTimeout([...c.cmd, messageFile], { cwd: root, timeoutMs: 60_000 })
|
|
@@ -90,7 +90,7 @@ type RawEntry = {
|
|
|
90
90
|
message?: { role?: unknown; content?: unknown }
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
/**
|
|
93
|
+
/** the typed prompt out of one transcript line, or null when the line is not one
|
|
94
94
|
* he typed. isSidechain = a subagent prompt the AGENT wrote; isMeta and
|
|
95
95
|
* isCompactSummary = harness-generated. Content is a string for a plain prompt,
|
|
96
96
|
* an array of blocks when the message carried pastes or attachments (the text
|
package/src/release/run-bump.ts
CHANGED
|
@@ -430,6 +430,35 @@ export async function runBump(root: string, config: RepoConfig, kind: BumpKind |
|
|
|
430
430
|
print(`ok: ${pkgRel} ${s.version}, ${s.changelogRel} section promoted`)
|
|
431
431
|
}
|
|
432
432
|
|
|
433
|
+
// every OTHER unit that depends on a bumped one moves its range forward, in
|
|
434
|
+
// the SAME commit. A sibling left naming the old version is a package that
|
|
435
|
+
// installs the wrong code, or nothing at all, and rule SK011 fails on it.
|
|
436
|
+
const bumpedNames = new Map(selected.map((s) => [s.unit.name, s.version]))
|
|
437
|
+
for (const u of all) {
|
|
438
|
+
// every unit, INCLUDING the bumped ones: with --all they are all
|
|
439
|
+
// selected, and skipping them left each sibling range naming the
|
|
440
|
+
// version its dependency just left. Only the self-reference is skipped
|
|
441
|
+
if (u.name != null && bumpedNames.has(u.name) && bumpedNames.size === 1) continue
|
|
442
|
+
const rel = u.rel === '' ? 'package.json' : `${u.rel}/package.json`
|
|
443
|
+
const before = readFileSync(`${root}/${rel}`, 'utf8')
|
|
444
|
+
if (!/"rvlib-[^"]*"\s*:\s*"[\^~]?\d/.test(before) && !selected.some((x) => x.unit.id === u.id)) continue
|
|
445
|
+
let after = before
|
|
446
|
+
const moved: string[] = []
|
|
447
|
+
for (const [name, version] of bumpedNames) {
|
|
448
|
+
if (name === u.name) continue
|
|
449
|
+
// the RANGE VALUE only, and only a caret/tilde/exact one: a local
|
|
450
|
+
// protocol is a working-copy choice (SK006) and stays untouched
|
|
451
|
+
const re = new RegExp(`("${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}"\\s*:\\s*")([\\^~]?)\\d+\\.\\d+\\.\\d+(")`, 'g')
|
|
452
|
+
const next = after.replace(re, (_m, head: string, op: string, tail: string) => `${head}${op === '' ? '' : op}${version}${tail}`)
|
|
453
|
+
if (next !== after) { moved.push(`${name} → ${version}`); after = next }
|
|
454
|
+
}
|
|
455
|
+
if (after !== before) {
|
|
456
|
+
writeFileSync(`${root}/${rel}`, after)
|
|
457
|
+
files.push(rel)
|
|
458
|
+
print(`ok: ${rel} follows the bump (${moved.join(', ')})`)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
433
462
|
const add = git(['add', ...files])
|
|
434
463
|
if (add.code !== 0) return failKeep('git add failed', 'the version files are written but unstaged. git status, then commit them')
|
|
435
464
|
const label = selected.map((s) => (s.unit.id === 'root' ? s.version : `${s.unit.id}@${s.version}`)).join(', ')
|
|
@@ -50,6 +50,14 @@ export type ReleaseConfig = {
|
|
|
50
50
|
* branch first, with a merge commit. Default true. A branch is a GATE, so
|
|
51
51
|
* this is deliberately reachable ONLY by running bump by hand. */
|
|
52
52
|
mergeFromBranch?: boolean
|
|
53
|
+
/** what makes the release LIVE, run after the tag, the push and the
|
|
54
|
+
* GitHub release, for the ROOT unit only (`['bun', 'run', 'up']` for a
|
|
55
|
+
* repo that deploys). A released web app that never deployed is not
|
|
56
|
+
* shipped, and prepublish scripts cannot do this: they run BEFORE the
|
|
57
|
+
* version commit, so they are build steps, not deploys. It is the last
|
|
58
|
+
* thing that happens, so a failure here loses nothing that was already
|
|
59
|
+
* published: it prints how to re-run the deploy alone. */
|
|
60
|
+
after?: string[]
|
|
53
61
|
}
|
|
54
62
|
|
|
55
63
|
/** the parts of the release preflight that do not depend on the new version
|
|
@@ -198,6 +206,7 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
|
|
|
198
206
|
...(tagExists() ? [] : [`git tag ${tag}`]),
|
|
199
207
|
`git push + push tag ${tag}`,
|
|
200
208
|
...(wantGh ? [`gh release create ${tag}`] : []),
|
|
209
|
+
...(unit.rel === '' && (rc.after?.length ?? 0) > 0 ? [`post-release: ${rc.after!.join(' ')}`] : []),
|
|
201
210
|
]
|
|
202
211
|
|
|
203
212
|
print(`note: ${unit.id} · ${name}@${version} → tag ${tag} · ${wantNpm ? 'npm' : unit.isPrivate ? 'PRIVATE (no npm)' : 'no npm target'} · ${notes === '' ? 'no notes' : `${notes.split('\n').length} line(s) of notes`}`)
|
|
@@ -273,6 +282,19 @@ export async function releaseUnit(root: string, config: RepoConfig, unit: Releas
|
|
|
273
282
|
print(`ok: GitHub release ${tag} live`)
|
|
274
283
|
}
|
|
275
284
|
print(`ok: ${name}@${version} released${wantNpm ? ' (npm + tag' : ' (tag'}${wantGh ? ' + github release)' : ')'}`)
|
|
285
|
+
// LAST, and root-unit only: everything above is already published, so
|
|
286
|
+
// a failed deploy is reported without unwinding any of it
|
|
287
|
+
const after = unit.rel === '' ? config.release?.after : undefined
|
|
288
|
+
if (after != null && after.length > 0) {
|
|
289
|
+
print(`ok: running the post-release step — ${after.join(' ')}`)
|
|
290
|
+
const a = Bun.spawnSync(after, { cwd: root, stdout: 'inherit', stderr: 'inherit' })
|
|
291
|
+
if (a.exitCode !== 0) {
|
|
292
|
+
print(`FAIL: post-release step failed (exit ${a.exitCode}) — ${name}@${version} IS released, it is not live`)
|
|
293
|
+
print(` fix: ${after.join(' ')}`)
|
|
294
|
+
return 1
|
|
295
|
+
}
|
|
296
|
+
print(`ok: post-release step done — ${name}@${version} is live`)
|
|
297
|
+
}
|
|
276
298
|
return 0
|
|
277
299
|
} finally {
|
|
278
300
|
rmSync(work, { recursive: true, force: true })
|
|
@@ -61,9 +61,13 @@ export function notesAnchor(root: string, changelogRel: string, version: string)
|
|
|
61
61
|
* counts: a folder rename must not read as a content change. A tag can
|
|
62
62
|
* predate the move, so a miss falls back to finding the directory by name
|
|
63
63
|
* inside that ref's tree, or drift goes silently unassessed. */
|
|
64
|
+
/** the TREE a ref names for this unit, which is what "same contents" means.
|
|
65
|
+
* `^{tree}` is load-bearing on the root unit: a bare rev-parse yields the
|
|
66
|
+
* COMMIT for HEAD and the TAG OBJECT for an annotated tag, two oids that
|
|
67
|
+
* never match, so a tag sitting exactly on HEAD reported as edited. */
|
|
64
68
|
function treeAt(root: string, u: ReleaseUnit, ref: string): string | null {
|
|
65
69
|
const rev = (path: string): string | null => {
|
|
66
|
-
const r = git(root, ['rev-parse', path === '' ? ref : `${ref}:${path}`])
|
|
70
|
+
const r = git(root, ['rev-parse', path === '' ? `${ref}^{tree}` : `${ref}:${path}`])
|
|
67
71
|
return r.code === 0 ? r.out : null
|
|
68
72
|
}
|
|
69
73
|
const direct = rev(u.rel)
|
package/src/release/units.ts
CHANGED
|
@@ -79,7 +79,15 @@ const SUB_TAG = '{name}@{version}'
|
|
|
79
79
|
const render = (template: string, p: { name: string; version: string }): string =>
|
|
80
80
|
template.replaceAll('{name}', p.name).replaceAll('{version}', p.version)
|
|
81
81
|
|
|
82
|
-
type Pkg = {
|
|
82
|
+
type Pkg = {
|
|
83
|
+
name?: string
|
|
84
|
+
version?: string
|
|
85
|
+
private?: boolean
|
|
86
|
+
scripts?: Record<string, string>
|
|
87
|
+
dependencies?: Record<string, string>
|
|
88
|
+
peerDependencies?: Record<string, string>
|
|
89
|
+
optionalDependencies?: Record<string, string>
|
|
90
|
+
}
|
|
83
91
|
|
|
84
92
|
function readPkg(dir: string): { pkg: Pkg } | { error: string } {
|
|
85
93
|
const p = join(dir, 'package.json')
|
|
@@ -216,8 +224,46 @@ function build(p: {
|
|
|
216
224
|
/** publish order: sub-units first, the root last. The root depends on what
|
|
217
225
|
* ships inside it, so a consumer that sees the root tag can already resolve
|
|
218
226
|
* every sub-unit it names. */
|
|
227
|
+
/** publish order: a unit ships AFTER every sibling it depends on, because a
|
|
228
|
+
* dependent published first names a version the registry does not have yet.
|
|
229
|
+
* Declaration order is not that order, and when it happens to be, it is luck.
|
|
230
|
+
* Kahn's algorithm, stable: ties keep declaration order, the root is always
|
|
231
|
+
* last, and a CYCLE degrades to declaration order rather than dropping units
|
|
232
|
+
* (a release that silently skips a package is worse than one in a bad order). */
|
|
219
233
|
export function releaseOrder(units: ReleaseUnit[]): ReleaseUnit[] {
|
|
220
|
-
|
|
234
|
+
const subs = units.filter((u) => u.id !== 'root')
|
|
235
|
+
const root = units.filter((u) => u.id === 'root')
|
|
236
|
+
const byName = new Map(subs.filter((u) => u.name != null).map((u) => [u.name, u]))
|
|
237
|
+
const deps = new Map<string, Set<string>>()
|
|
238
|
+
for (const u of subs) {
|
|
239
|
+
const need = new Set<string>()
|
|
240
|
+
const read = readPkg(u.dir)
|
|
241
|
+
const manifest: Pkg = 'error' in read ? {} : read.pkg
|
|
242
|
+
for (const block of ['dependencies', 'peerDependencies', 'optionalDependencies'] as const)
|
|
243
|
+
for (const dep of Object.keys(manifest[block] ?? {})) {
|
|
244
|
+
const sib = byName.get(dep)
|
|
245
|
+
if (sib != null && sib.id !== u.id) need.add(sib.id)
|
|
246
|
+
}
|
|
247
|
+
deps.set(u.id, need)
|
|
248
|
+
}
|
|
249
|
+
const out: ReleaseUnit[] = []
|
|
250
|
+
const placed = new Set<string>()
|
|
251
|
+
let progress = true
|
|
252
|
+
while (progress && out.length < subs.length) {
|
|
253
|
+
progress = false
|
|
254
|
+
for (const u of subs) {
|
|
255
|
+
if (placed.has(u.id)) continue
|
|
256
|
+
const need = deps.get(u.id) ?? new Set()
|
|
257
|
+
if ([...need].every((d) => placed.has(d))) {
|
|
258
|
+
out.push(u)
|
|
259
|
+
placed.add(u.id)
|
|
260
|
+
progress = true
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// a dependency cycle between units: keep every unit, in declaration order
|
|
265
|
+
for (const u of subs) if (!placed.has(u.id)) out.push(u)
|
|
266
|
+
return [...out, ...root]
|
|
221
267
|
}
|
|
222
268
|
|
|
223
269
|
export const unitById = (units: ReleaseUnit[], id: string): ReleaseUnit | undefined => units.find((u) => u.id === id)
|
package/src/repo-config.ts
CHANGED
|
@@ -173,7 +173,7 @@ export function contractsTask(c: RepoConfig, root: string): TaskDef {
|
|
|
173
173
|
|
|
174
174
|
const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
|
|
175
175
|
|
|
176
|
-
export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (
|
|
176
|
+
export type FloorSource = 'package.json script' | 'repo.config override' | 'built-in (shipkit)' | 'built-in (tsc)'
|
|
177
177
|
export type FloorResolution = {
|
|
178
178
|
tasks: TaskDef[]
|
|
179
179
|
/** slots disabled by `defaults: { x: false }` — always printed */
|
|
@@ -190,11 +190,12 @@ export type FloorResolution = {
|
|
|
190
190
|
function builtinFloorTask(id: string, root: string): { task: TaskDef; source: FloorSource } | string {
|
|
191
191
|
if (id === 'imports:check' || id === 'cycles') {
|
|
192
192
|
let cli: string
|
|
193
|
-
|
|
194
|
-
|
|
193
|
+
// shipkit's OWN module, resolved from this file: no dependency to be
|
|
194
|
+
// out of date with, and no resolution that can fail in a consumer tree
|
|
195
|
+
cli = fileURLToPath(import.meta.resolve('rvlib-shipkit/src/ts/source-graph-cli.ts'))
|
|
195
196
|
return {
|
|
196
197
|
task: { id, cmd: id === 'cycles' ? ['bun', cli, 'cycles'] : ['bun', cli, 'imports', '--check'] },
|
|
197
|
-
source: 'built-in (
|
|
198
|
+
source: 'built-in (shipkit)',
|
|
198
199
|
}
|
|
199
200
|
}
|
|
200
201
|
if (id === 'typecheck') {
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
// registry unreachable → loud skip, never red (offline is not a defect)
|
|
23
23
|
// Committing the bump stays with the working agent — shipkit never commits.
|
|
24
24
|
// Clean passes cache 24h (rule ttl), so the registry is fetched at most
|
|
25
|
-
// daily per repo; skip/WARN/FAIL runs are never cached.
|
|
25
|
+
// daily per repo; skip/WARN/FAIL runs are never cached. The key carries the
|
|
26
|
+
// INSTALLED versions, so a manual `bun add -d rvlib-x@N` busts it: keyed on
|
|
27
|
+
// the repo alone, the replayed ok: line named a version the tree no longer
|
|
28
|
+
// carried and called it latest. peerDependencies
|
|
26
29
|
// are NOT scanned: keeping a published lib's peer ranges consistent after
|
|
27
30
|
// a bump stays the agent's job.
|
|
28
31
|
import { existsSync, readFileSync } from 'fs'
|
|
@@ -52,6 +55,15 @@ export const rvlibVersions: Rule = {
|
|
|
52
55
|
title: 'rvlib versions',
|
|
53
56
|
owner: 'build/shipkit.md',
|
|
54
57
|
ttl: '24h',
|
|
58
|
+
cacheKey: (env) => {
|
|
59
|
+
let pkg: Pkg
|
|
60
|
+
try { pkg = JSON.parse(readFileSync(join(env.root, 'package.json'), 'utf8')) as Pkg } catch { return 'unreadable' }
|
|
61
|
+
return Object.keys({ ...pkg.dependencies, ...pkg.devDependencies })
|
|
62
|
+
.filter((n) => n.startsWith('rvlib-'))
|
|
63
|
+
.sort()
|
|
64
|
+
.map((n) => `${n}@${installedVersion(env.root, n) ?? 'absent'}`)
|
|
65
|
+
.join(',')
|
|
66
|
+
},
|
|
55
67
|
lint: async (t, env: RuleEnv) => {
|
|
56
68
|
let pkg: Pkg
|
|
57
69
|
try { pkg = JSON.parse(readFileSync(join(env.root, 'package.json'), 'utf8')) as Pkg } catch { return t.note('no readable package.json — rvlib versions not applicable') }
|