rvlib-shipkit 0.11.0 → 0.14.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 +200 -0
- package/README.md +17 -3
- package/package.json +6 -3
- package/src/asks/asks-queue.ts +8 -0
- package/src/assets/kit-config.ts +2 -3
- package/src/assets/roll-workspace.ts +2 -1
- package/src/assets/run-assets.ts +0 -11
- package/src/assets/server/studio-server.ts +7 -24
- package/src/cli/cmd_Assets.ts +3 -14
- package/src/cli/cmd_Check.ts +22 -4
- package/src/cli/cmd_Reflect.ts +116 -0
- package/src/cli/cmd_Rules.ts +32 -0
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli.ts +5 -2
- package/src/collections/actions.ts +19 -0
- package/src/collections/collection-types.ts +13 -0
- package/src/collections/graphs.ts +34 -0
- package/src/collections/pings.ts +5 -1
- package/src/collections/posts.ts +20 -1
- package/src/collections/providers.ts +163 -50
- package/src/contracts/apply-contracts.ts +47 -0
- package/src/contracts/contract-types.ts +5 -2
- package/src/contracts/contracts-engine.ts +13 -3
- package/src/contracts/manifests/discord.contract.ts +1 -1
- package/src/contracts/manifests/identity.contract.ts +1 -1
- package/src/contracts/manifests/itch.contract.ts +19 -2
- package/src/contracts/run-contracts.ts +8 -3
- package/src/core/canonical-scripts.ts +46 -0
- package/src/core/check-suite.ts +6 -3
- package/src/core/locked-write.ts +39 -0
- package/src/core/motherhome.ts +87 -0
- package/src/core/run-check.ts +28 -15
- package/src/core/run-ci.ts +4 -0
- package/src/core/write-status.ts +7 -26
- package/src/init-repo.ts +27 -11
- package/src/reflect/prompt-corpus.ts +305 -0
- package/src/repo-config.ts +30 -38
- package/src/rules/SK001-package-json-shape.ts +156 -0
- package/src/rules/SK002-contracts-fulfillness.ts +19 -0
- package/src/rules/SK003-claude-md-shape.ts +64 -0
- package/src/rules/SK004-gitignore-policy.ts +105 -0
- package/src/rules/SK005-rvlib-versions.ts +99 -0
- package/src/rules/SK006-npm-publish-safety.ts +42 -0
- package/src/rules/SK007-github-visibility.ts +63 -0
- package/src/rules/SK008-reviewer-agent.ts +98 -0
- package/src/rules/rule-registry.ts +34 -0
- package/src/rules/rule.ts +62 -0
- package/src/rules/run-fix.ts +57 -0
- package/src/serve/hub-app/col-card.tsx +192 -0
- package/src/serve/hub-app/cols-svc.ts +136 -0
- package/src/serve/hub-app/geometry-view.tsx +155 -0
- package/src/serve/hub-app/health-svc.ts +49 -0
- package/src/serve/hub-app/hub-app.tsx +236 -215
- package/src/serve/hub-app/hub-st.ts +169 -0
- package/src/serve/hub-app/hub-types.ts +72 -0
- package/src/serve/hub-app/hub.css +286 -59
- package/src/serve/hub-app/index.html +1 -1
- package/src/serve/hub-app/md.ts +24 -0
- package/src/serve/hub-app/media-svc.ts +429 -0
- package/src/serve/hub-app/media-view.tsx +299 -0
- package/src/serve/run-serve.ts +23 -9
- package/src/serve/serve-hub.ts +62 -47
- package/src/shipkit.ts +9 -5
- package/src/assets/server/studio-page.ts +0 -687
- package/src/core/package-json-check.ts +0 -88
- package/src/serve/hub-pages.ts +0 -38
|
@@ -13,7 +13,7 @@ export default defineContract({
|
|
|
13
13
|
artifacts: [
|
|
14
14
|
file('.rv-identity/brand.ts'),
|
|
15
15
|
png('public/favicon.png', 512, 512),
|
|
16
|
-
png('public/og.png', 1200, 630),
|
|
16
|
+
png('public/og.png', 1200, 630, { atLeast: true }),
|
|
17
17
|
glob('public/logo.*', { min: 1 }),
|
|
18
18
|
],
|
|
19
19
|
})
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// normative WHAT for .rv-itch/ — the WHY + workflow live in the sibling page.
|
|
2
|
-
import { defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
2
|
+
import { custom, defineContract, dirExists, fileContains, glob, md, png, script } from 'rvlib-shipkit/src/contracts/contract-types.ts'
|
|
3
|
+
import { existsSync, readFileSync } from 'fs'
|
|
4
|
+
import { join } from 'path'
|
|
3
5
|
|
|
4
6
|
export default defineContract({
|
|
5
7
|
venue: 'itch',
|
|
@@ -10,7 +12,22 @@ export default defineContract({
|
|
|
10
12
|
artifacts: [
|
|
11
13
|
// one AI-disclosure wording everywhere — page.md is its canonical home
|
|
12
14
|
md('.rv-itch/page.md', { mustContain: ['## AI disclosure'] }),
|
|
13
|
-
|
|
15
|
+
// every field of itch's create-project form, answered and ready to
|
|
16
|
+
// paste — the listing is a browser-only act, so the artifact IS the
|
|
17
|
+
// handoff (references/listing.template.md is the fill-in copy)
|
|
18
|
+
md('.rv-itch/listing.md', { mustContain: ['## Title', '## Short description or tagline', '## Classification', '## Genre', '## Tags', '## Visibility'] }),
|
|
19
|
+
// headings alone would pass the TEMPLATE copied in verbatim, and this
|
|
20
|
+
// file exists to be pasted with no decision left in it (field
|
|
21
|
+
// 2026-07-27: a reviewer proved `cp template listing.md` went green)
|
|
22
|
+
custom('.rv-itch/listing.md answered', (env) => {
|
|
23
|
+
const p = join(env.root, '.rv-itch/listing.md')
|
|
24
|
+
if (!existsSync(p)) return []
|
|
25
|
+
const left = [...readFileSync(p, 'utf8').matchAll(/<[a-z][^<>\n]{2,60}>/g)].map((m) => m[0])
|
|
26
|
+
return left.length === 0 ? [] : [`.rv-itch/listing.md still holds ${left.length} unanswered placeholder(s): ${[...new Set(left)].slice(0, 3).join(' ')} — it is pasted into the store form as-is`]
|
|
27
|
+
}),
|
|
28
|
+
// itch DISPLAYS 630x500 and accepts larger — an exact-size rule would
|
|
29
|
+
// block a release over a better cover (audit 2026-07-27)
|
|
30
|
+
png('.rv-itch/cover.png', 630, 500, { atLeast: true }),
|
|
14
31
|
glob('.rv-itch/screenshots/*.png', { min: 3 }),
|
|
15
32
|
],
|
|
16
33
|
})
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// contract fulfillness alone, fast and filesystem-only. Plain module
|
|
2
2
|
// behind cmd_Contracts; venue deploy scripts run it before any upload.
|
|
3
|
-
import {
|
|
3
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
4
|
+
import { CheckCtx } from '../core/check-ctx.ts'
|
|
5
|
+
import { contractsFulfillness } from '../rules/SK002-contracts-fulfillness.ts'
|
|
6
|
+
import { activeContracts, applyContractsResult, contractsConfigFrom } from './apply-contracts.ts'
|
|
4
7
|
import { evaluateContracts } from './contracts-engine.ts'
|
|
5
8
|
import { formatLine } from '../core/wire.ts'
|
|
6
9
|
|
|
@@ -27,7 +30,9 @@ export async function runContracts(root: string, o: ContractsOpts = {}): Promise
|
|
|
27
30
|
return r.exitCode
|
|
28
31
|
}
|
|
29
32
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
// same SK002-tagged rendering as `shipkit check` and the ci task
|
|
34
|
+
const t = new CheckCtx(new Map())
|
|
35
|
+
applyContractsResult(t, r, contractsFulfillness)
|
|
36
|
+
for (const l of t.lines) print(formatLine(l, color))
|
|
32
37
|
return r.exitCode
|
|
33
38
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The canonical package.json script bodies for a shipkit repo — vocabulary
|
|
2
|
+
// owned by meta/rv-conventions/package-json.md. ONE source: `shipkit init`
|
|
3
|
+
// scaffolds from it and rule SK001's autofix repairs from it.
|
|
4
|
+
export type ScriptStyle = {
|
|
5
|
+
/** in-repo-source consumers: path to shipkit's cli.ts — bodies become `bun <path> <cmd>` */
|
|
6
|
+
bin?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function canonicalBodies(opts: ScriptStyle = {}): Record<string, string> {
|
|
10
|
+
const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
|
|
11
|
+
return {
|
|
12
|
+
ci: run('ci'),
|
|
13
|
+
check: run('check'),
|
|
14
|
+
contracts: run('contracts'),
|
|
15
|
+
tui: run('tui'),
|
|
16
|
+
mv: run('mv'),
|
|
17
|
+
// hub runs in WATCH mode (house default 2026-07-23): lib/config edits
|
|
18
|
+
// restart the server live; the npm shim can't watch, the source path can
|
|
19
|
+
hub: `bun --watch ${opts.bin ?? 'node_modules/rvlib-shipkit/src/cli.ts'} serve`,
|
|
20
|
+
// baseline `test` is universal — lint/fmt bodies are stack-specific,
|
|
21
|
+
// the scaffold pages own those
|
|
22
|
+
test: 'bun test',
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// conditional canon (bodies stated by the vocabulary page) — keyed by the
|
|
27
|
+
// SK001 requirement name; requirements with no canonical body (assets,
|
|
28
|
+
// comfy:up) are deliberately absent: a fixer must not invent one
|
|
29
|
+
export function conditionalBodies(opts: ScriptStyle = {}): Record<string, string> {
|
|
30
|
+
const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
|
|
31
|
+
return {
|
|
32
|
+
'up': './deploy.sh',
|
|
33
|
+
'asks': run('asks'),
|
|
34
|
+
'assets:identity': run('assets identity'),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// recover the consumption style from existing scripts: any shipkit command
|
|
39
|
+
// already running `bun <path>/cli.ts <cmd>` fixes the style for new bodies
|
|
40
|
+
export function inferBin(scripts: Record<string, string>): string | undefined {
|
|
41
|
+
for (const body of Object.values(scripts)) {
|
|
42
|
+
const m = body.match(/^bun (?:--watch )?(\S+cli\.ts) /)
|
|
43
|
+
if (m != null && m[1] !== 'node_modules/rvlib-shipkit/src/cli.ts') return m[1]
|
|
44
|
+
}
|
|
45
|
+
return undefined
|
|
46
|
+
}
|
package/src/core/check-suite.ts
CHANGED
|
@@ -61,8 +61,8 @@ export class CheckSuite {
|
|
|
61
61
|
this.onResult = opts.onResult
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
-
addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; logic: Logic }): void {
|
|
65
|
-
this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, logic: spec.logic })
|
|
64
|
+
addCheck(spec: { name: string; remainValidFor?: string | number; version?: number; cacheExtra?: () => string; logic: Logic }): void {
|
|
65
|
+
this.runners.push({ name: spec.name, ttlMs: spec.remainValidFor != null ? parseDuration(spec.remainValidFor) : 0, version: spec.version ?? 1, prep: spec.cacheExtra != null ? () => ({ cacheExtra: spec.cacheExtra!() }) : undefined, logic: spec.logic })
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
defineRequiredTool(name: string, opts: { candidates?: string[]; probe?: { args: string[]; expect?: RegExp }; fix: string }): void {
|
|
@@ -161,7 +161,10 @@ export class CheckSuite {
|
|
|
161
161
|
this.onResult?.(r.name, { lines: t.lines, status: t.failed ? 'fail' : t.warned ? 'warn' : 'pass', cached: false })
|
|
162
162
|
if (t.failed) anyFail = true
|
|
163
163
|
if (r.ttlMs > 0) {
|
|
164
|
-
|
|
164
|
+
// a skip line is an UNKNOWN, not a clean pass — replaying
|
|
165
|
+
// "registry unreachable" for a TTL asserts a dead network as
|
|
166
|
+
// fact long after it recovered (review find 2026-07-26)
|
|
167
|
+
if (!t.failed && !t.warned && !t.lines.some((l) => l.kind === 'skip')) { cache[r.name] = { version: r.version, key, at: this.now(), lines: t.lines }; cacheDirty = true }
|
|
165
168
|
// a WARN/FAIL evicts the stale pass — a later run must re-probe,
|
|
166
169
|
// not replay an ok recorded before the breakage
|
|
167
170
|
else if (entry != null) { delete cache[r.name]; cacheDirty = true }
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// ONE file-lock + atomic-write concern (extracted from write-status when
|
|
2
|
+
// motherhome became the second multi-writer): the lock spans a caller's
|
|
3
|
+
// whole read-modify-write, the write lands via tmp + rename so a killed
|
|
4
|
+
// process can never leave a torn file, and the loser of a race SKIPS —
|
|
5
|
+
// never corrupts, never blocks.
|
|
6
|
+
import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
7
|
+
import { basename, dirname, join } from 'path'
|
|
8
|
+
|
|
9
|
+
// a writer that died without unlocking must not wedge the file forever
|
|
10
|
+
const LOCK_STALE_MS = 120_000
|
|
11
|
+
|
|
12
|
+
export type LockOutcome<T> = { ok: true; value: T } | { ok: false; warn: string }
|
|
13
|
+
|
|
14
|
+
export function withFileLock<T>(path: string, fn: () => T): LockOutcome<T> {
|
|
15
|
+
const lockPath = `${path}.lock`
|
|
16
|
+
const grab = (): boolean => {
|
|
17
|
+
try { writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); return true } catch { return false }
|
|
18
|
+
}
|
|
19
|
+
if (!grab()) {
|
|
20
|
+
let stale = false
|
|
21
|
+
try { stale = Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {
|
|
22
|
+
// lock vanished between grab and stat — the racing writer just
|
|
23
|
+
// finished; still skip this round rather than fight over it
|
|
24
|
+
}
|
|
25
|
+
if (stale) rmSync(lockPath, { force: true })
|
|
26
|
+
if (!stale || !grab()) return { ok: false, warn: `${path} locked by another writer — skipped this round` }
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
return { ok: true, value: fn() }
|
|
30
|
+
} finally {
|
|
31
|
+
rmSync(lockPath, { force: true })
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function atomicWrite(path: string, content: string): void {
|
|
36
|
+
const tmpPath = join(dirname(path), `.${basename(path)}.tmp-${process.pid}`)
|
|
37
|
+
writeFileSync(tmpPath, content)
|
|
38
|
+
renameSync(tmpPath, path)
|
|
39
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// Report to the motherhome — upsert this repo's row into the docsBase
|
|
2
|
+
// wiki's `build/shipkit/local-projects.md`, when that page ALREADY EXISTS
|
|
3
|
+
// on this disk. The lib NEVER creates the page and never writes into a
|
|
4
|
+
// relative or URL docsBase: the page is a tracked wiki file, so a real
|
|
5
|
+
// docs checkout has it, and a consumer that merely set docsBase as a
|
|
6
|
+
// fix-pointer prefix must never find surprise files in its docs tree.
|
|
7
|
+
// The whole read-modify-write runs under the shared file lock
|
|
8
|
+
// (core/locked-write) — concurrent reporters never lose rows or tear the
|
|
9
|
+
// file; the loser skips and self-heals on its next run. Rows whose repo
|
|
10
|
+
// path no longer exists are pruned on every write. Churn control: a row
|
|
11
|
+
// rewrites only when its DATA changes or its last-seen day is older than
|
|
12
|
+
// 7 days. Never fails the calling command.
|
|
13
|
+
import { existsSync, readFileSync } from 'fs'
|
|
14
|
+
import { homedir } from 'os'
|
|
15
|
+
import { join, resolve } from 'path'
|
|
16
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
17
|
+
import { atomicWrite, withFileLock } from './locked-write.ts'
|
|
18
|
+
|
|
19
|
+
const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
|
|
20
|
+
const END = '<!-- <<< shipkit local-projects -->'
|
|
21
|
+
const STALE_DAYS = 7
|
|
22
|
+
|
|
23
|
+
type Row = { name: string; root: string; version: string; mode: string; seen: string }
|
|
24
|
+
|
|
25
|
+
const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
|
|
26
|
+
|
|
27
|
+
const parseRow = (line: string): Row | null => {
|
|
28
|
+
const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
|
|
29
|
+
return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function libVersion(): string {
|
|
33
|
+
try { return (JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8')) as { version?: string }).version ?? '?' } catch { return '?' }
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// how this repo consumes the lib — `?` on an unreadable package.json (a
|
|
37
|
+
// read failure must never be rendered as the fact 'path')
|
|
38
|
+
function consumptionMode(root: string): string {
|
|
39
|
+
try {
|
|
40
|
+
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
41
|
+
const spec = { ...pkg.dependencies, ...pkg.devDependencies }['rvlib-shipkit']
|
|
42
|
+
if (spec == null) return 'path'
|
|
43
|
+
if (spec.startsWith('link:')) return 'link'
|
|
44
|
+
if (spec.startsWith('file:')) return 'file'
|
|
45
|
+
return 'npm'
|
|
46
|
+
} catch { return '?' }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type MotherhomeOutcome = { wrote: boolean; note?: string }
|
|
50
|
+
|
|
51
|
+
export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
|
|
52
|
+
try {
|
|
53
|
+
if (config.docsBase == null) return { wrote: false }
|
|
54
|
+
const base = config.docsBase.startsWith('~/') ? join(homedir(), config.docsBase.slice(2)) : config.docsBase
|
|
55
|
+
// absolute local paths only — a relative docsBase would resolve
|
|
56
|
+
// against CWD, a URL is a display prefix
|
|
57
|
+
if (!base.startsWith('/')) return { wrote: false }
|
|
58
|
+
const page = join(base, 'build/shipkit/local-projects.md')
|
|
59
|
+
if (!existsSync(page)) return { wrote: false }
|
|
60
|
+
|
|
61
|
+
const mode = consumptionMode(root)
|
|
62
|
+
// source-consumed code is not the published release its version
|
|
63
|
+
// string names — disclose it
|
|
64
|
+
const version = mode === 'npm' ? libVersion() : `${libVersion()}+src`
|
|
65
|
+
const me: Row = { name: config.name, root: resolve(root), version, mode, seen: today }
|
|
66
|
+
|
|
67
|
+
const r = withFileLock(page, (): MotherhomeOutcome => {
|
|
68
|
+
const lines = readFileSync(page, 'utf8').split('\n')
|
|
69
|
+
const b = lines.indexOf(BEGIN)
|
|
70
|
+
const e = lines.indexOf(END)
|
|
71
|
+
if (b === -1 || e === -1 || e < b) return { wrote: false, note: `WARN motherhome upsert skipped — markers missing/corrupted in ${page}` }
|
|
72
|
+
const rows = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
|
|
73
|
+
const cur = rows.find((row) => row.root === me.root)
|
|
74
|
+
const dataSame = cur != null && cur.name === me.name && cur.version === me.version && cur.mode === me.mode
|
|
75
|
+
const fresh = cur != null && (Date.parse(today) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
|
|
76
|
+
if (dataSame && fresh) return { wrote: false }
|
|
77
|
+
// prune rows for repos that no longer exist at their recorded path
|
|
78
|
+
const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
|
|
79
|
+
atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
|
|
80
|
+
return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})` }
|
|
81
|
+
})
|
|
82
|
+
// lock lost to a racing reporter — self-heals on the next run
|
|
83
|
+
return r.ok ? r.value : { wrote: false }
|
|
84
|
+
} catch (err) {
|
|
85
|
+
return { wrote: false, note: `WARN motherhome upsert failed (${String(err).slice(0, 120)}) — registry may be stale` }
|
|
86
|
+
}
|
|
87
|
+
}
|
package/src/core/run-check.ts
CHANGED
|
@@ -8,9 +8,10 @@ import { join, relative } from 'path'
|
|
|
8
8
|
import { CheckSuite, type CheckOutcome } from './check-suite.ts'
|
|
9
9
|
import { discoverChecks } from './discover-checks.ts'
|
|
10
10
|
import { spawnWithTimeout } from './spawn-timeout.ts'
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
11
|
+
import { loadRepoConfig } from '../repo-config.ts'
|
|
12
|
+
import { reportToMotherhome } from './motherhome.ts'
|
|
13
|
+
import { RULES } from '../rules/rule-registry.ts'
|
|
14
|
+
import { runRuleLint } from '../rules/rule.ts'
|
|
14
15
|
import { formatLine, parseWireOutput } from './wire.ts'
|
|
15
16
|
import { writeStatus } from './write-status.ts'
|
|
16
17
|
|
|
@@ -22,6 +23,8 @@ const CHECK_TIMEOUT_MS = 120_000
|
|
|
22
23
|
export type CheckOpts = {
|
|
23
24
|
json?: boolean
|
|
24
25
|
fresh?: boolean
|
|
26
|
+
/** report-only for self-maintaining rules (SK005 never touches package.json) */
|
|
27
|
+
frozen?: boolean
|
|
25
28
|
/** refresh STATUS.md after the run (default true — O4: every repo gets it) */
|
|
26
29
|
status?: boolean
|
|
27
30
|
/** line sink — commands pass their context stream; defaults to console */
|
|
@@ -33,20 +36,24 @@ export type CheckOpts = {
|
|
|
33
36
|
export async function runCheck(root: string, o: CheckOpts = {}): Promise<number> {
|
|
34
37
|
const print = o.print ?? ((l: string) => console.log(l))
|
|
35
38
|
const c = await loadRepoConfig(root)
|
|
39
|
+
// env carries the flags to child checks — restored after the run so a
|
|
40
|
+
// long-lived caller (doctor, TUI, hub) is never poisoned by one
|
|
41
|
+
// fresh/frozen invocation
|
|
42
|
+
const prevFresh = process.env.SHIPKIT_FRESH
|
|
43
|
+
const prevFrozen = process.env.SHIPKIT_FROZEN
|
|
36
44
|
if (o.fresh === true) process.env.SHIPKIT_FRESH = '1'
|
|
45
|
+
if (o.frozen === true) process.env.SHIPKIT_FROZEN = '1'
|
|
37
46
|
const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache'), onResult: o.onResult })
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
logic: (t) => evaluatePackageJson(t, root, c),
|
|
49
|
-
})
|
|
47
|
+
// built-in SK rules (rules/rule-registry.ts) — id-tagged, crash-safe,
|
|
48
|
+
// fix-pointer floor (rules/rule.ts runRuleLint owns the semantics);
|
|
49
|
+
// doctor surface only, deliberately absent from the ci gate
|
|
50
|
+
for (const rule of RULES)
|
|
51
|
+
suite.addCheck({
|
|
52
|
+
name: rule.title,
|
|
53
|
+
remainValidFor: rule.ttl,
|
|
54
|
+
cacheExtra: rule.cacheKey != null ? () => rule.cacheKey!({ root, config: c }) : undefined,
|
|
55
|
+
logic: (t) => runRuleLint(rule, t, { root, config: c }),
|
|
56
|
+
})
|
|
50
57
|
const childEnv: Record<string, string | undefined> = {
|
|
51
58
|
...process.env,
|
|
52
59
|
...(o.fresh === true ? { SHIPKIT_FRESH: '1', RV_CHECK_FRESH: '1' } : {}),
|
|
@@ -69,12 +76,18 @@ export async function runCheck(root: string, o: CheckOpts = {}): Promise<number>
|
|
|
69
76
|
})
|
|
70
77
|
}
|
|
71
78
|
const r = await suite.execute()
|
|
79
|
+
if (prevFresh == null) delete process.env.SHIPKIT_FRESH; else process.env.SHIPKIT_FRESH = prevFresh
|
|
80
|
+
if (prevFrozen == null) delete process.env.SHIPKIT_FROZEN; else process.env.SHIPKIT_FROZEN = prevFrozen
|
|
81
|
+
// report to the motherhome (docsBase wiki on this disk) — never fails
|
|
82
|
+
// the check, notes only when it wrote or broke
|
|
83
|
+
const mh = reportToMotherhome(root, c)
|
|
72
84
|
const statusOutcome = o.status !== false
|
|
73
85
|
? writeStatus(root, { name: c.name, command: 'shipkit check', lines: r.lines.map((l) => formatLine(l, false)), exitCode: r.exitCode })
|
|
74
86
|
: null
|
|
75
87
|
if (o.json === true) { print(JSON.stringify(r)); return r.exitCode }
|
|
76
88
|
const color = process.stdout.isTTY && process.env.NO_COLOR == null
|
|
77
89
|
for (const l of r.lines) print(formatLine(l, color))
|
|
90
|
+
if (mh.note != null) print(mh.note)
|
|
78
91
|
if (statusOutcome != null && !statusOutcome.written) print(`WARN ${statusOutcome.warn}`)
|
|
79
92
|
return r.exitCode
|
|
80
93
|
}
|
package/src/core/run-ci.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// atomic + lock) unless status:false.
|
|
6
6
|
import { join } from 'path'
|
|
7
7
|
import { allTasks, loadRepoConfig } from '../repo-config.ts'
|
|
8
|
+
import { reportToMotherhome } from './motherhome.ts'
|
|
8
9
|
import { runTasks, type TaskResult } from './task-runner.ts'
|
|
9
10
|
import { writeStatus } from './write-status.ts'
|
|
10
11
|
|
|
@@ -44,6 +45,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
44
45
|
onTaskFinish: o.onTaskFinish,
|
|
45
46
|
})
|
|
46
47
|
const code = summary.ok ? 0 : 1
|
|
48
|
+
// motherhome row (docsBase wiki on this disk) — never affects the gate
|
|
49
|
+
const mh = reportToMotherhome(root, c)
|
|
50
|
+
if (mh.note != null) print(mh.note)
|
|
47
51
|
if (o.status !== false) {
|
|
48
52
|
const st = writeStatus(root, { name: c.name, command: 'shipkit ci', lines: collected, exitCode: code })
|
|
49
53
|
if (!st.written) print(`WARN ${st.warn}`)
|
package/src/core/write-status.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
// THE STATUS.md writer — every repo gets one (O4, one-runner GO
|
|
2
2
|
// 2026-07-22): timestamp header + verdict + fenced compact render, the
|
|
3
|
-
// shape rv-doctor pioneered. Atomic
|
|
3
|
+
// shape rv-doctor pioneered. Atomic + lock-guarded via core/locked-write:
|
|
4
4
|
// concurrent runners never interleave — the loser skips with a WARN
|
|
5
5
|
// outcome, never corrupts the file.
|
|
6
6
|
|
|
7
|
-
import { renameSync, rmSync, statSync, writeFileSync } from 'fs'
|
|
8
7
|
import { join } from 'path'
|
|
8
|
+
import { atomicWrite, withFileLock } from './locked-write.ts'
|
|
9
9
|
|
|
10
10
|
export type StatusInput = {
|
|
11
11
|
/** repo name — headline */
|
|
@@ -20,25 +20,10 @@ export type StatusInput = {
|
|
|
20
20
|
export type StatusOutcome = { written: boolean; warn?: string }
|
|
21
21
|
|
|
22
22
|
const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '')
|
|
23
|
-
// a runner that died without unlocking must not wedge STATUS.md forever
|
|
24
|
-
const LOCK_STALE_MS = 120_000
|
|
25
23
|
|
|
26
24
|
export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
try { writeFileSync(lockPath, String(process.pid), { flag: 'wx' }); return true } catch { return false }
|
|
30
|
-
}
|
|
31
|
-
if (!grab()) {
|
|
32
|
-
let stale = false
|
|
33
|
-
try { stale = Date.now() - statSync(lockPath).mtimeMs > LOCK_STALE_MS } catch {
|
|
34
|
-
// lock vanished between grab and stat — the racing writer just
|
|
35
|
-
// finished; still skip this round rather than fight over it
|
|
36
|
-
}
|
|
37
|
-
if (stale) rmSync(lockPath, { force: true })
|
|
38
|
-
if (!stale || !grab())
|
|
39
|
-
return { written: false, warn: `STATUS.md locked by another runner (${lockPath}) — skipped this write` }
|
|
40
|
-
}
|
|
41
|
-
try {
|
|
25
|
+
const target = join(root, 'STATUS.md')
|
|
26
|
+
const r = withFileLock(target, () => {
|
|
42
27
|
const clean = s.lines.map(stripAnsi)
|
|
43
28
|
const fails = clean.filter((l) => l.startsWith('FAIL')).length
|
|
44
29
|
const warns = clean.filter((l) => l.startsWith('WARN')).length
|
|
@@ -60,11 +45,7 @@ export function writeStatus(root: string, s: StatusInput): StatusOutcome {
|
|
|
60
45
|
${clean.join('\n').trim()}
|
|
61
46
|
\`\`\`
|
|
62
47
|
`
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
return { written: true }
|
|
67
|
-
} finally {
|
|
68
|
-
rmSync(lockPath, { force: true })
|
|
69
|
-
}
|
|
48
|
+
atomicWrite(target, body)
|
|
49
|
+
})
|
|
50
|
+
return r.ok ? { written: true } : { written: false, warn: `STATUS.md locked by another runner — skipped this write` }
|
|
70
51
|
}
|
package/src/init-repo.ts
CHANGED
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
// by path instead of from node_modules.
|
|
8
8
|
import { existsSync, readFileSync, writeFileSync } from 'fs'
|
|
9
9
|
import { join } from 'path'
|
|
10
|
-
import {
|
|
10
|
+
import { canonicalBodies } from './core/canonical-scripts.ts'
|
|
11
|
+
import { reportToMotherhome } from './core/motherhome.ts'
|
|
12
|
+
import { defineRepo, loadRepoConfig, resolveFloor } from './repo-config.ts'
|
|
13
|
+
import { gitignorePolicy } from './rules/SK004-gitignore-policy.ts'
|
|
11
14
|
|
|
12
15
|
const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'rvlib-shipkit'
|
|
13
16
|
|
|
@@ -16,6 +19,9 @@ export default defineRepo({
|
|
|
16
19
|
venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
|
|
17
20
|
social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
|
|
18
21
|
tasks: [], // repo commit-gate tasks; deps double as race guards
|
|
22
|
+
// the mandated reviewer agent type (rule SK008) — uncomment with YOUR
|
|
23
|
+
// standing brief, then \`shipkit check --fix SK008\` writes the agent file:
|
|
24
|
+
// reviewer: { name: 'my-reviewer', brief: '~/path/to/reviewer-brief.md' },
|
|
19
25
|
// health checks: no config — drop a <name>.check.ts file anywhere,
|
|
20
26
|
// \`shipkit check\` auto-discovers and runs it (wire output, exit code)
|
|
21
27
|
})
|
|
@@ -28,7 +34,18 @@ export type InitOpts = {
|
|
|
28
34
|
|
|
29
35
|
export async function initRepo(root: string, opts: InitOpts = {}): Promise<number> {
|
|
30
36
|
const configPath = join(root, 'repo.config.ts')
|
|
31
|
-
if (existsSync(configPath)) {
|
|
37
|
+
if (existsSync(configPath)) {
|
|
38
|
+
console.log(`skip: repo.config.ts already exists`)
|
|
39
|
+
// adoption path: re-running init on an existing repo still converges
|
|
40
|
+
// its .gitignore policy block (same fixer as `check --fix SK004`),
|
|
41
|
+
// with the repo's own contributed lines honored
|
|
42
|
+
const cfg = await loadRepoConfig(root)
|
|
43
|
+
const g = await gitignorePolicy.fix!({ root, config: cfg })
|
|
44
|
+
console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
45
|
+
const mh = reportToMotherhome(root, cfg)
|
|
46
|
+
if (mh.note != null) console.log(mh.note)
|
|
47
|
+
return 0
|
|
48
|
+
}
|
|
32
49
|
const pkgPath = join(root, 'package.json')
|
|
33
50
|
if (!existsSync(pkgPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
|
|
34
51
|
const pkgRaw = readFileSync(pkgPath, 'utf8')
|
|
@@ -38,23 +55,22 @@ export async function initRepo(root: string, opts: InitOpts = {}): Promise<numbe
|
|
|
38
55
|
console.log('ok: repo.config.ts created')
|
|
39
56
|
const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
|
|
40
57
|
pkg.scripts ??= {}
|
|
41
|
-
const run = (cmd: string): string => (opts.bin != null ? `bun ${opts.bin} ${cmd}` : `shipkit ${cmd}`)
|
|
42
58
|
let added = 0
|
|
43
|
-
// canonical script
|
|
44
|
-
// vocabulary owned by
|
|
45
|
-
|
|
46
|
-
for (const [k,
|
|
47
|
-
const v = run(cmd)
|
|
59
|
+
// canonical script bodies — ONE source (core/canonical-scripts.ts),
|
|
60
|
+
// shared with rule SK001's autofix; vocabulary owned by
|
|
61
|
+
// meta/rv-conventions/package-json.md
|
|
62
|
+
for (const [k, v] of Object.entries(canonicalBodies({ bin: opts.bin }))) {
|
|
48
63
|
if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
|
|
49
64
|
else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
|
|
50
65
|
}
|
|
51
|
-
// baseline `test` is universal (bun test) — lint/fmt bodies are
|
|
52
|
-
// stack-specific, the scaffold pages own those
|
|
53
|
-
if (pkg.scripts['test'] == null) { pkg.scripts['test'] = 'bun test'; added++ }
|
|
54
66
|
if (added > 0) {
|
|
55
67
|
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
|
|
56
68
|
console.log(`ok: ${added} package.json script(s) added`)
|
|
57
69
|
}
|
|
70
|
+
// the .gitignore policy block (contract git policy) — same fixer as
|
|
71
|
+
// `shipkit check --fix SK004`, one code path
|
|
72
|
+
const g = await gitignorePolicy.fix!({ root, config: defineRepo({ name }) })
|
|
73
|
+
console.log(`ok: .gitignore policy block ${g.changed ? 'written' : 'already current'}`)
|
|
58
74
|
// show what the gate actually is — a thin floor must be seen at init
|
|
59
75
|
// time, not discovered after a bad commit slipped through
|
|
60
76
|
const floor = resolveFloor(defineRepo({ name }), root)
|