rvlib-shipkit 0.1.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 ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 — 2026-07-22
4
+
5
+ - contracts: manifest data language (`file/png/glob/md/dir/custom` rules,
6
+ `script/fileContains/dirExists` detectors, loud `defineContract`) + engine:
7
+ both-ways drift (declared-incomplete FAILs, undeclared-but-detected FAILs),
8
+ IHDR png dimension checks, `public-surface` gate (WARN before a surface),
9
+ visible `exempt` skips, one fix pointer per contract per severity. Five
10
+ generic venue manifests bundled (itch, steam, social, discord, identity).
11
+ - core: v0 wire protocol (`ok:/WARN/FAIL:/skip:/fix:` — FROZEN), CheckCtx
12
+ (guarded fetch/exec), CheckSuite (TTL cache, secret-rotation busting,
13
+ injectable secret backend), task runner (dep DAG with race-guard edges,
14
+ per-task logs + timeout escalation, kill-on-bail/interrupt, group/only/
15
+ opt-in filters, loud cycle detection).
16
+ - asks: per-repo human queue — list/new/check/archive, numbered files never
17
+ reused, frozen stdout prefixes (`closed + archived` / `archived` / `still`).
18
+ - wiki: md-wiki link-graph server + orphan/dangling lint (folded in from
19
+ rvlib-mdwiki 0.2.0, now frozen on npm).
20
+ - `defineRepo` (repo.config.ts) with auto-registered floor tasks + `shipkit`
21
+ CLI: `ci · check · contracts · asks/ask · wiki · init`.
package/README.md ADDED
@@ -0,0 +1,58 @@
1
+ # rvlib-shipkit
2
+
3
+ Repo toolkit for shipping: one config file gives a repo a commit gate
4
+ (task DAG), release-contract fulfillness checks, a human ask queue, and
5
+ markdown-wiki tooling.
6
+
7
+ ```sh
8
+ bun add -d rvlib-shipkit
9
+ bunx rvlib-shipkit init # scaffolds repo.config.ts + package.json scripts
10
+ ```
11
+
12
+ ```ts
13
+ // repo.config.ts
14
+ import { defineRepo } from 'rvlib-shipkit'
15
+
16
+ export default defineRepo({
17
+ name: 'my-game',
18
+ venues: ['itch'], // which release contracts are REQUIRED
19
+ social: ['x', 'discord'], // .rv-social/<channel>/ post folders (+ .rv-discord/)
20
+ tasks: [{ id: 'typecheck', cmd: ['bun', 'run', 'typecheck'], deps: ['lint'] }],
21
+ })
22
+ ```
23
+
24
+ ## CLI
25
+
26
+ ```
27
+ shipkit ci commit gate: auto-registered floor (lint/typecheck/test/…)
28
+ + contracts + your tasks, parallel DAG [--bail --skip g --only id --json]
29
+ shipkit contracts release-contract fulfillness alone [--venue v --json]
30
+ shipkit check periodic health checks, TTL-cached [--json --fresh]
31
+ shipkit asks human action queue: list · ask "<title>" · asks check (auto-close)
32
+ shipkit wiki serve|lint — live md link-graph + orphan/dangling-ref lint
33
+ shipkit init scaffold config + scripts
34
+ ```
35
+
36
+ Output is a stable line protocol — `ok:` / `WARN` / `FAIL:` / `skip:` with
37
+ indented `fix:` pointers — exit 0/1. Greppable by humans, parseable by
38
+ agents, `--json` where structure helps.
39
+
40
+ ## Release contracts
41
+
42
+ A venue contract is data: required files, exact PNG dimensions (header-parsed,
43
+ no image dependency), glob minimums, required markdown sections. Enforcement
44
+ is both ways — a declared venue with an incomplete folder fails, and an
45
+ undeclared venue whose signals fire (deploy scripts, folders) fails too.
46
+ `exempt: { steam: "reason" }` skips visibly. Wire venue deploys as
47
+ `deps: ['contracts']` and an incomplete folder physically blocks the upload.
48
+
49
+ Ship your own contracts via `contracts: { extra: [defineContract({...})] }`
50
+ — see `rvlib-shipkit/contracts` for the rule vocabulary.
51
+
52
+ ## Library use
53
+
54
+ Everything the CLI does is importable: `runTasks`, `CheckSuite`,
55
+ `evaluateContracts`, the asks queue, `scanMdGraph`/`lintMd`/`serveMdGraph`.
56
+ Ships TypeScript source; Bun ≥ 1.0.
57
+
58
+ MIT
package/package.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "rvlib-shipkit",
3
+ "version": "0.1.0",
4
+ "description": "Repo toolkit: task runner, release-contract fulfillness checker, asks queue, md-wiki tools",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "files": ["src", "README.md", "CHANGELOG.md"],
8
+ "bin": {
9
+ "shipkit": "./src/cli.ts"
10
+ },
11
+ "exports": {
12
+ ".": "./src/shipkit.ts",
13
+ "./contracts": "./src/contracts/contract-types.ts",
14
+ "./src/*": "./src/*"
15
+ },
16
+ "devDependencies": {
17
+ "@types/bun": "^1.3.14"
18
+ }
19
+ }
@@ -0,0 +1,95 @@
1
+ // Persistent human<->agent queue for real-world actions and answers.
2
+ // One md file per ask in <dir>/, git-tracked; <dir>/done/ archive. The agent
3
+ // greps status; the human edits files (or just does the thing — check()
4
+ // auto-closes asks whose `check:` probe passes).
5
+ //
6
+ // Stdout contract (FROZEN — consumers parse the prefixes): `closed +
7
+ // archived <file>` / `archived <file>` / `still <status> <file>` / `N closed`.
8
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from 'fs'
9
+ import { join } from 'path'
10
+
11
+ export type Ask = { file: string; status: string; kind: string; created: string; blocks: string; check: string | null; title: string }
12
+ export type CreateOpts = { kind?: string; blocks?: string; check?: string }
13
+
14
+ const parse = (dir: string, file: string): Ask => {
15
+ const md = readFileSync(join(dir, file), 'utf8')
16
+ const fm = (key: string): string | null => md.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'))?.[1]?.trim() ?? null
17
+ return {
18
+ file,
19
+ status: fm('status') ?? 'open',
20
+ kind: fm('kind') ?? 'action',
21
+ created: fm('created') ?? '?',
22
+ blocks: fm('blocks') ?? '-',
23
+ check: fm('check'),
24
+ title: md.match(/^# (.+)$/m)?.[1] ?? file,
25
+ }
26
+ }
27
+
28
+ export function listAsks(dir: string): Ask[] {
29
+ mkdirSync(dir, { recursive: true })
30
+ return readdirSync(dir).filter((f) => f.endsWith('.md')).sort().map((f) => parse(dir, f))
31
+ }
32
+
33
+ export function openAsks(dir: string): Ask[] {
34
+ return listAsks(dir).filter((a) => a.status !== 'done')
35
+ }
36
+
37
+ export function checkAsks(dir: string, print: (line: string) => void = console.log): { closed: number } {
38
+ const done = join(dir, 'done')
39
+ mkdirSync(done, { recursive: true })
40
+ let closed = 0
41
+ for (const a of listAsks(dir)) {
42
+ if (a.status === 'done') { renameSync(join(dir, a.file), join(done, a.file)); print(`archived ${a.file} → done/`); continue }
43
+ if (a.check == null) { print(`still ${a.status.padEnd(8)} ${a.file} (manual — no probe)`); continue }
44
+ const p = Bun.spawnSync(['bash', '-lc', a.check])
45
+ if (p.exitCode === 0) {
46
+ const md = readFileSync(join(dir, a.file), 'utf8').replace(/^status:.*$/m, `status: done\nclosed: ${new Date().toISOString().slice(0, 10)}`)
47
+ writeFileSync(join(dir, a.file), md)
48
+ renameSync(join(dir, a.file), join(done, a.file))
49
+ print(`closed + archived ${a.file}`)
50
+ closed++
51
+ } else {
52
+ print(`still ${a.status.padEnd(8)} ${a.file}`)
53
+ }
54
+ }
55
+ print(`${closed} closed`)
56
+ return { closed }
57
+ }
58
+
59
+ export function createAsk(dir: string, title: string, opts?: CreateOpts): string {
60
+ mkdirSync(dir, { recursive: true })
61
+ // max numeric prefix across BOTH live and archived asks — numbers are never reused
62
+ const done = join(dir, 'done')
63
+ const archived = existsSync(done) ? readdirSync(done).filter((f) => f.endsWith('.md')) : []
64
+ const n = Math.max(0, ...[...listAsks(dir).map((a) => a.file), ...archived].map((f) => parseInt(f, 10) || 0)) + 1
65
+ const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
66
+ const file = `${String(n).padStart(3, '0')}-${slug}.md`
67
+ if (existsSync(join(dir, file))) throw new Error(`refusing to overwrite existing ${file}`)
68
+ const check = opts?.check ?? null
69
+ writeFileSync(join(dir, file), `---
70
+ status: open
71
+ kind: ${opts?.kind ?? 'action'}
72
+ created: ${new Date().toISOString().slice(0, 10)}
73
+ blocks: ${opts?.blocks ?? '-'}${check != null ? `\ncheck: ${check}` : ''}
74
+ ---
75
+
76
+ # ${title}
77
+
78
+ **Ask**: ${title}
79
+
80
+ **Why**: TODO
81
+
82
+ **Answer**: _(fill in / check off, or just do it — the check probe auto-closes when it passes)_
83
+
84
+ **Done when**: ${check != null ? `\`${check}\` exits 0` : 'status flipped to done'}
85
+ `)
86
+ return file
87
+ }
88
+
89
+ export function renderAsksTable(dir: string, print: (line: string) => void = console.log): number {
90
+ const open = openAsks(dir)
91
+ if (open.length === 0) { print('no open asks — all caught up 🎉'); return 0 }
92
+ for (const a of open) print(`${a.status.padEnd(9)} ${a.kind.padEnd(7)} ${a.file.padEnd(44)} blocks: ${a.blocks}`)
93
+ print(`\n${open.length} open — details: ${dir}/<file>`)
94
+ return open.length
95
+ }
@@ -0,0 +1,26 @@
1
+ // shipkit asks — the per-repo human queue. `asks` lists, `asks check`
2
+ // probes + archives, `ask "<title>"` (dispatch rewrites to `asks new`).
3
+ import { join } from 'path'
4
+ import { checkAsks, createAsk, renderAsksTable } from '../asks/asks-queue.ts'
5
+ import { loadRepoConfig } from '../repo-config.ts'
6
+
7
+ const flagVal = (args: string[], name: string): string | undefined => {
8
+ const i = args.indexOf(`--${name}`)
9
+ return i !== -1 ? args[i + 1] : undefined
10
+ }
11
+
12
+ export async function main(args: string[]): Promise<number> {
13
+ const root = process.cwd()
14
+ const dir = join(root, (await loadRepoConfig(root)).asks?.dir ?? 'asks')
15
+ const sub = args[0]
16
+ if (sub === 'check') { checkAsks(dir); return 0 }
17
+ if (sub === 'new') {
18
+ const title = args[1]
19
+ if (title == null || title === '') { console.error('usage: shipkit ask "<title>" [--kind action|info] [--blocks <what>] [--check "<cmd>"]'); return 2 }
20
+ const file = createAsk(dir, title, { kind: flagVal(args, 'kind'), blocks: flagVal(args, 'blocks'), check: flagVal(args, 'check') })
21
+ console.log(`created ${dir}/${file}`)
22
+ return 0
23
+ }
24
+ renderAsksTable(dir)
25
+ return 0
26
+ }
@@ -0,0 +1,26 @@
1
+ // shipkit check — periodic health: contracts + the repo's TTL'd checks.
2
+ // Clean passes replay from cache; SHIPKIT_FRESH=1 (or --fresh) busts.
3
+ import { join } from 'path'
4
+ import { CheckSuite } from '../core/check-suite.ts'
5
+ import { activeContracts, applyContractsResult, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
6
+ import { evaluateContracts } from '../contracts/contracts-engine.ts'
7
+
8
+ export async function main(args: string[]): Promise<number> {
9
+ const root = process.cwd()
10
+ const c = await loadRepoConfig(root)
11
+ if (args.includes('--fresh')) process.env.SHIPKIT_FRESH = '1'
12
+ const suite = new CheckSuite({ name: c.name, cacheDir: join(root, 'tmp/.shipkit-cache') })
13
+ suite.addCheck({
14
+ name: 'contracts',
15
+ logic: async (t) => {
16
+ applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
17
+ },
18
+ })
19
+ for (const chk of c.checks ?? []) suite.addCheck({ name: chk.id, remainValidFor: chk.remainValidFor, version: chk.version, logic: chk.logic })
20
+ const r = await suite.execute()
21
+ if (args.includes('--json')) { console.log(JSON.stringify(r)); return r.exitCode }
22
+ const { formatLine } = await import('../core/wire.ts')
23
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
24
+ for (const l of r.lines) console.log(formatLine(l, color))
25
+ return r.exitCode
26
+ }
@@ -0,0 +1,30 @@
1
+ // shipkit ci — the commit/deploy gate: floor + contracts + repo tasks
2
+ // through the DAG runner. Exit 0/1; --json prints the structured summary.
3
+ import { join } from 'path'
4
+ import { allTasks, loadRepoConfig } from '../repo-config.ts'
5
+ import { runTasks } from '../core/task-runner.ts'
6
+
7
+ const flagVals = (args: string[], name: string): string[] => {
8
+ const out: string[] = []
9
+ for (let i = 0; i < args.length; i++) if (args[i] === name && args[i + 1] != null) out.push(args[i + 1]!)
10
+ return out
11
+ }
12
+
13
+ export async function main(args: string[]): Promise<number> {
14
+ const root = process.cwd()
15
+ const c = await loadRepoConfig(root)
16
+ const { tasks, disabled } = allTasks(c, root)
17
+ for (const d of disabled) console.log(`skip: ${d} (skipped-by-config)`)
18
+ const summary = await runTasks({
19
+ tasks,
20
+ logDir: c.logDir ?? join(root, 'tmp/shipkit-ci'),
21
+ cwd: root,
22
+ bail: args.includes('--bail'),
23
+ skipGroups: flagVals(args, '--skip'),
24
+ withIds: flagVals(args, '--with'),
25
+ onlyIds: flagVals(args, '--only'),
26
+ })
27
+ if (args.includes('--json'))
28
+ console.log(JSON.stringify({ ok: summary.ok, interrupted: summary.interrupted, disabled, tasks: summary.results.map((r) => ({ ...r, lines: undefined })) }))
29
+ return summary.ok ? 0 : 1
30
+ }
@@ -0,0 +1,26 @@
1
+ // shipkit contracts — fulfillness alone, fast and filesystem-only.
2
+ // Venue deploy scripts call this before any upload.
3
+ import { activeContracts, contractsConfigFrom, loadRepoConfig } from '../repo-config.ts'
4
+ import { evaluateContracts } from '../contracts/contracts-engine.ts'
5
+ import { formatLine } from '../core/wire.ts'
6
+
7
+ export async function main(args: string[]): Promise<number> {
8
+ const root = process.cwd()
9
+ const c = await loadRepoConfig(root)
10
+ const venueIdx = args.indexOf('--venue')
11
+ const only = venueIdx !== -1 ? args[venueIdx + 1] : null
12
+ let manifests = activeContracts(c)
13
+ if (only != null) {
14
+ manifests = manifests.filter((m) => m.venue === only)
15
+ if (manifests.length === 0) { console.error(`FAIL: unknown venue '${only}'`); return 1 }
16
+ }
17
+ const r = await evaluateContracts(manifests, contractsConfigFrom(c, root))
18
+ if (args.includes('--json')) {
19
+ console.log(JSON.stringify({ ok: r.exitCode === 0, outcomes: r.outcomes }))
20
+ return r.exitCode
21
+ }
22
+ const color = process.stdout.isTTY && process.env.NO_COLOR == null
23
+ for (const l of r.lines) console.log(formatLine(l, color))
24
+ if (r.lines.length === 0) console.log('ok: no contracts apply (nothing declared, nothing detected)')
25
+ return r.exitCode
26
+ }
@@ -0,0 +1,40 @@
1
+ // shipkit init — scaffold repo.config.ts + package.json scripts. Refuses to
2
+ // overwrite an existing config; script merge never clobbers existing keys.
3
+ import { existsSync, readFileSync, writeFileSync } from 'fs'
4
+ import { join } from 'path'
5
+
6
+ const CONFIG_TEMPLATE = (name: string): string => `import { defineRepo } from 'rvlib-shipkit'
7
+
8
+ export default defineRepo({
9
+ name: '${name}',
10
+ venues: [], // e.g. ['itch', 'steam'] — selects which .rv-<venue>/ contracts are REQUIRED
11
+ social: [], // e.g. ['x', 'discord'] — .rv-social/<channel>/ post folders (+ .rv-discord/)
12
+ tasks: [], // repo commit-gate tasks; deps double as race guards
13
+ checks: [], // periodic health, TTL-cached
14
+ })
15
+ `
16
+
17
+ export async function main(_args: string[]): Promise<number> {
18
+ const root = process.cwd()
19
+ const configPath = join(root, 'repo.config.ts')
20
+ if (existsSync(configPath)) { console.log(`skip: repo.config.ts already exists`); return 0 }
21
+ const pkgPath = join(root, 'package.json')
22
+ if (!existsSync(pkgPath)) { console.error('FAIL: no package.json here — run from the repo root'); return 1 }
23
+ const pkgRaw = readFileSync(pkgPath, 'utf8')
24
+ const pkg = JSON.parse(pkgRaw) as { name?: string; scripts?: Record<string, string> }
25
+ writeFileSync(configPath, CONFIG_TEMPLATE(pkg.name ?? 'my-repo'))
26
+ console.log('ok: repo.config.ts created')
27
+ const indent = pkgRaw.match(/^(\s+)"/m)?.[1] ?? ' '
28
+ pkg.scripts ??= {}
29
+ let added = 0
30
+ for (const [k, v] of [['ci', 'shipkit ci'], ['check', 'shipkit check'], ['contracts', 'shipkit contracts']] as const) {
31
+ if (pkg.scripts[k] == null) { pkg.scripts[k] = v; added++ }
32
+ else if (pkg.scripts[k] !== v) console.log(`skip: package.json script '${k}' exists — left untouched`)
33
+ }
34
+ if (added > 0) {
35
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + '\n')
36
+ console.log(`ok: ${added} package.json script(s) added`)
37
+ }
38
+ console.log('next: fill venues/social in repo.config.ts, then `bun run ci`')
39
+ return 0
40
+ }
@@ -0,0 +1,74 @@
1
+ // shipkit wiki — md-wiki graph server + link lint (folded in from the
2
+ // standalone mdwiki lib; that npm package is frozen).
3
+ // shipkit wiki serve --root agent --port 4800 [--title t] [--index e.md] [--skip a,b]
4
+ // shipkit wiki lint --root agent [--index e.md]
5
+ import { basename, relative, resolve } from 'path'
6
+ import { parseArgs } from 'util'
7
+ import { lintMd } from '../wiki/lint-md.ts'
8
+ import { scanMdGraph } from '../wiki/scan-md.ts'
9
+ import { serveMdGraph } from '../wiki/serve-graph.ts'
10
+
11
+ const USAGE = `usage: shipkit wiki <serve|lint> [--root <dir>] [--index <entry.md>] [--skip <dir,dir>]
12
+ serve --port <n> (default 6161) --title <t> live link-graph server, reload = rescan
13
+ lint fail on orphans + dangling refs (exit 1)
14
+ --root defaults to cwd; default entrypoints: index.md + README.md (--index <e> = only that one)`
15
+
16
+ export async function main(args: string[]): Promise<number> {
17
+ let parsed: ReturnType<typeof parseArgs<{ options: { root: { type: 'string'; default: string }; port: { type: 'string'; default: string }; title: { type: 'string' }; index: { type: 'string' }; skip: { type: 'string' } }; allowPositionals: true }>>
18
+ try {
19
+ parsed = parseArgs({
20
+ args,
21
+ allowPositionals: true,
22
+ options: {
23
+ root: { type: 'string', default: '.' },
24
+ port: { type: 'string', default: '6161' },
25
+ title: { type: 'string' },
26
+ index: { type: 'string' },
27
+ skip: { type: 'string' },
28
+ },
29
+ })
30
+ } catch (e) {
31
+ console.log(`FAIL ${e instanceof Error ? e.message : e}\n${USAGE}`)
32
+ return 2
33
+ }
34
+ const { values, positionals } = parsed
35
+ const cmd = positionals[0]
36
+ const root = resolve(process.cwd(), values.root)
37
+ const scanCfg = { root, index: values.index, skipDirs: values.skip?.split(',').filter(Boolean) }
38
+
39
+ if (cmd === 'serve') {
40
+ const port = Number(values.port)
41
+ if (!Number.isInteger(port) || port <= 0) { console.log(`FAIL --port '${values.port}' is not a port number`); return 2 }
42
+ const rel = relative(process.cwd(), root)
43
+ const title = values.title ?? (rel === '' ? basename(root) : `${basename(process.cwd())} ${rel}`)
44
+ try {
45
+ serveMdGraph({ port, title, load: () => scanMdGraph(scanCfg) })
46
+ console.log(`wiki graph → http://localhost:${port} (open in browser, or VSCode "Simple Browser: Show")`)
47
+ // keep the server alive — dispatch must not exit
48
+ await new Promise(() => {})
49
+ return 0
50
+ } catch (e) {
51
+ if (e instanceof Error && e.message.includes('in use')) {
52
+ console.log(`wiki graph already served → http://localhost:${port} (another window owns it)`)
53
+ return 0
54
+ }
55
+ throw e
56
+ }
57
+ }
58
+ if (cmd === 'lint') {
59
+ const r = lintMd(scanCfg)
60
+ if (r.index == null) {
61
+ console.log(values.index != null
62
+ ? `FAIL entrypoint '${values.index}' not found in ${root}`
63
+ : `FAIL no entrypoint: neither index.md nor README.md in ${root} — pass --index <entry.md>`)
64
+ return 1
65
+ }
66
+ for (const f of r.orphans) console.log(`FAIL orphan: ${f} — unreachable from ${r.index}; link it or delete it`)
67
+ for (const d of r.dangling) console.log(`FAIL dangling: ${d.from} → ${d.token} — target not found`)
68
+ if (!r.ok) { console.log(`FAIL mdwiki lint: ${r.orphans.length} orphan(s), ${r.dangling.length} dangling ref(s) in ${root}`); return 1 }
69
+ console.log(`ok mdwiki lint: ${r.files} files, all reachable from ${r.index}, no dangling refs`)
70
+ return 0
71
+ }
72
+ console.log(USAGE)
73
+ return 2
74
+ }
package/src/cli.ts ADDED
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bun
2
+ // shipkit — dispatch only; one command per file in cli/.
3
+ const cmd = process.argv[2]
4
+
5
+ const run = async (): Promise<number> => {
6
+ const args = process.argv.slice(3)
7
+ switch (cmd) {
8
+ case 'ci': return (await import('./cli/shipkit-ci.ts')).main(args)
9
+ case 'check': return (await import('./cli/shipkit-check.ts')).main(args)
10
+ case 'contracts': return (await import('./cli/shipkit-contracts.ts')).main(args)
11
+ case 'asks': return (await import('./cli/shipkit-asks.ts')).main(args)
12
+ case 'ask': return (await import('./cli/shipkit-asks.ts')).main(['new', ...args])
13
+ case 'wiki': return (await import('./cli/shipkit-wiki.ts')).main(args)
14
+ case 'init': return (await import('./cli/shipkit-init.ts')).main(args)
15
+ default:
16
+ console.log(`shipkit — repo toolkit
17
+ usage: shipkit <command>
18
+ ci commit gate: floor + contracts + repo tasks [--bail --skip <group> --with <id> --only <id...> --json]
19
+ check periodic health: contracts + repo checks, TTL'd [--json --fresh]
20
+ contracts contract fulfillness alone [--venue <v> --json]
21
+ asks list open asks · asks check · ask "<title>" [--kind --blocks --check "<cmd>"]
22
+ wiki serve|lint — md-wiki graph + link hygiene
23
+ init scaffold repo.config.ts + package.json scripts`)
24
+ return 2
25
+ }
26
+ }
27
+
28
+ process.exit(await run())
@@ -0,0 +1,10 @@
1
+ // GENERATED by contracts:sync — do not edit; sources are the co-located
2
+ // wiki manifests. Parity is lint-enforced.
3
+ import type { ContractManifest } from './contract-types.ts'
4
+ import c0 from './manifests/discord.contract.ts'
5
+ import c1 from './manifests/identity.contract.ts'
6
+ import c2 from './manifests/itch.contract.ts'
7
+ import c3 from './manifests/social.contract.ts'
8
+ import c4 from './manifests/steam.contract.ts'
9
+
10
+ export const allContracts: ContractManifest[] = [c0, c1, c2, c3, c4]
@@ -0,0 +1,77 @@
1
+ // The data language `*.contract.ts` manifest files are written in: a venue
2
+ // contract = folder + artifact rules + detectors. Evaluation engine lands in
3
+ // ./contracts-engine.ts (0.1). All rule paths are REPO-ROOT-relative so a
4
+ // contract can require files outside its own folder (e.g. public/og.png).
5
+ // `warnOnly` demotes a rule to WARN — for mandates gated on a later step.
6
+
7
+ export type RuleOpts = { warnOnly?: boolean }
8
+ export type FileRule = RuleOpts & { kind: 'file'; path: string; nonEmpty: boolean }
9
+ export type PngRule = RuleOpts & { kind: 'png'; path: string; width: number; height: number }
10
+ export type GlobRule = RuleOpts & { kind: 'glob'; pattern: string; min: number; width?: number; height?: number }
11
+ export type MdRule = RuleOpts & { kind: 'md'; path: string; mustContain: string[] }
12
+ export type DirRule = RuleOpts & { kind: 'dir'; path: string }
13
+ // probe returns FAIL messages (empty array = pass) — for rules that need the
14
+ // repo config (declared channels) or logic the closed vocabulary can't say
15
+ export type CustomRule = RuleOpts & { kind: 'custom'; id: string; probe: (env: ContractEnv) => string[] | Promise<string[]> }
16
+ export type ArtifactRule = FileRule | PngRule | GlobRule | MdRule | DirRule | CustomRule
17
+
18
+ // detectors: signals a repo SHIPS on a venue even when config forgot to
19
+ // declare it — both-ways drift (config can't lie, folders can't rot).
20
+ // Patterns are regex SOURCES (strings) so manifests stay serializable data.
21
+ export type Detector =
22
+ | { kind: 'script'; pattern: string }
23
+ | { kind: 'fileContains'; path: string; pattern: string }
24
+ | { kind: 'dirExists'; path: string }
25
+
26
+ export type ContractEnv = {
27
+ root: string
28
+ venues: string[]
29
+ social: string[]
30
+ exists: (relPath: string) => boolean
31
+ }
32
+
33
+ export type ContractManifest = {
34
+ venue: string // kebab id; the contract folder is `.rv-<venue>/`
35
+ dir: string
36
+ owner: string // docs path suffix, rendered after the consumer-configured docsBase in fix: lines
37
+ appliesTo: 'declared' | 'always'
38
+ gate?: 'public-surface' // 'always' contracts: FAIL once a public surface exists, WARN before
39
+ detect: Detector[]
40
+ artifacts: ArtifactRule[]
41
+ }
42
+
43
+ export const file = (path: string, p?: RuleOpts & { nonEmpty?: boolean }): FileRule => ({ kind: 'file', path, nonEmpty: p?.nonEmpty ?? true, warnOnly: p?.warnOnly })
44
+ export const png = (path: string, width: number, height: number, p?: RuleOpts): PngRule => ({ kind: 'png', path, width, height, warnOnly: p?.warnOnly })
45
+ export const glob = (pattern: string, p: RuleOpts & { min: number; width?: number; height?: number }): GlobRule => ({ kind: 'glob', pattern, min: p.min, width: p.width, height: p.height, warnOnly: p.warnOnly })
46
+ export const md = (path: string, p?: RuleOpts & { mustContain?: string[] }): MdRule => ({ kind: 'md', path, mustContain: p?.mustContain ?? [], warnOnly: p?.warnOnly })
47
+ export const dir = (path: string, p?: RuleOpts): DirRule => ({ kind: 'dir', path, warnOnly: p?.warnOnly })
48
+ export const custom = (id: string, probe: CustomRule['probe'], p?: RuleOpts): CustomRule => ({ kind: 'custom', id, probe, warnOnly: p?.warnOnly })
49
+
50
+ export const script = (pattern: string): Detector => ({ kind: 'script', pattern })
51
+ export const fileContains = (path: string, pattern: string): Detector => ({ kind: 'fileContains', path, pattern })
52
+ export const dirExists = (path: string): Detector => ({ kind: 'dirExists', path })
53
+
54
+ export function validateManifest(m: ContractManifest): string[] {
55
+ const errs: string[] = []
56
+ if (!/^[a-z0-9-]+$/.test(m.venue)) errs.push(`venue '${m.venue}' must be kebab-case [a-z0-9-]`)
57
+ if (m.dir !== `.rv-${m.venue}`) errs.push(`dir '${m.dir}' must be '.rv-${m.venue}' (folder naming rule)`)
58
+ if (m.artifacts.length === 0) errs.push('artifacts must not be empty — a contract with nothing to check is prose, not a contract')
59
+ if (m.appliesTo === 'declared' && m.detect.length === 0) errs.push(`appliesTo 'declared' needs >=1 detector — otherwise an undeclared venue can never be caught`)
60
+ if (m.gate != null && m.appliesTo !== 'always') errs.push(`gate '${m.gate}' only applies to 'always' contracts`)
61
+ const keys = m.artifacts.map((a) => ('path' in a ? a.path : 'pattern' in a ? a.pattern : a.id))
62
+ const dupes = keys.filter((k, i) => keys.indexOf(k) !== i)
63
+ if (dupes.length > 0) errs.push(`duplicate artifact rules: ${[...new Set(dupes)].join(', ')}`)
64
+ for (const d of m.detect) {
65
+ if ('pattern' in d) {
66
+ try { new RegExp(d.pattern) } catch { errs.push(`detector pattern '${d.pattern}' is not a valid regex`) }
67
+ }
68
+ }
69
+ return errs
70
+ }
71
+
72
+ // loud gate: a broken manifest must die at import time, never mis-check quietly
73
+ export function defineContract(m: ContractManifest): ContractManifest {
74
+ const errs = validateManifest(m)
75
+ if (errs.length > 0) throw new Error(`invalid contract '${m.venue}':\n ${errs.join('\n ')}`)
76
+ return m
77
+ }