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.
@@ -0,0 +1,274 @@
1
+ // Task runner — dep-DAG scheduler with wire-protocol output (wire.ts).
2
+ // Plain mode only by design: one `ok:`/`FAIL:` line per finished task +
3
+ // final summary; greppable by humans and agents alike. Semantics:
4
+ // - a dep must PASS to unblock; a filtered-out dep counts as satisfied;
5
+ // a failed/skipped dep skips dependents transitively (note says why).
6
+ // Deps double as serialization guards (document racy edges at the def).
7
+ // - subprocess tasks: full log streamed to <logDir>/<id>.log, last non-empty
8
+ // line kept for the FAIL summary; SIGTERM at timeoutMs, SIGKILL +2s.
9
+ // - logic tasks run in-process against a CheckCtx: WARN never flips the
10
+ // exit code, FAIL does. Their lines print under the task line.
11
+ // - bail/SIGINT KILL in-flight children (SIGTERM, SIGKILL +2s); a killed
12
+ // task reports 'skipped (killed …)', never a fake fail.
13
+ // - sharp edge, by design: `onlyIds` on a task whose race-guard dep is
14
+ // optIn/skipped-group drops that dep (filtered = satisfied) — explicit
15
+ // filters win over guards.
16
+ import { createWriteStream, existsSync, mkdirSync, rmSync } from 'fs'
17
+ import { join } from 'path'
18
+ import { CheckCtx } from './check-ctx.ts'
19
+ import { formatLine, type Line } from './wire.ts'
20
+
21
+ export type TaskDef = {
22
+ id: string
23
+ cmd?: string[]
24
+ logic?: (t: CheckCtx) => unknown
25
+ deps?: string[]
26
+ env?: Record<string, string>
27
+ cwd?: string
28
+ group?: string
29
+ /** excluded unless listed in RunOpts.withIds */
30
+ optIn?: boolean
31
+ timeoutMs?: number
32
+ }
33
+
34
+ export type TaskResult = {
35
+ id: string
36
+ status: 'pass' | 'fail' | 'skipped'
37
+ exitCode: number | null
38
+ durationMs: number
39
+ logPath: string | null
40
+ lastLine: string
41
+ note: string
42
+ lines: Line[]
43
+ }
44
+
45
+ export type RunOpts = {
46
+ tasks: TaskDef[]
47
+ logDir: string
48
+ cwd?: string
49
+ maxParallel?: number
50
+ bail?: boolean
51
+ skipGroups?: string[]
52
+ onlyIds?: string[]
53
+ withIds?: string[]
54
+ /** print sink — tests capture, production defaults to console.log */
55
+ print?: (line: string) => void
56
+ color?: boolean
57
+ }
58
+
59
+ export type RunSummary = { ok: boolean; results: TaskResult[]; interrupted: boolean }
60
+
61
+ type Live = TaskDef & TaskResult & { startedAt: number }
62
+
63
+ const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '').replace(/\r/g, '')
64
+
65
+ // only-mode keeps the transitive dep closure — running `--only typecheck`
66
+ // with a lint race-guard dep must still run lint first
67
+ function closure(tasks: TaskDef[], ids: string[]): Set<string> {
68
+ const byId = new Map(tasks.map((t) => [t.id, t]))
69
+ const keep = new Set<string>()
70
+ const visit = (id: string): void => {
71
+ if (keep.has(id)) return
72
+ keep.add(id)
73
+ for (const d of byId.get(id)?.deps ?? []) visit(d)
74
+ }
75
+ for (const id of ids) visit(id)
76
+ return keep
77
+ }
78
+
79
+ export function selectTasks(o: RunOpts): { selected: TaskDef[]; errors: string[] } {
80
+ const errors: string[] = []
81
+ const ids = new Set(o.tasks.map((t) => t.id))
82
+ if (ids.size !== o.tasks.length) errors.push('duplicate task ids')
83
+ for (const t of o.tasks) {
84
+ if ((t.cmd == null) === (t.logic == null)) errors.push(`task ${t.id}: exactly one of cmd/logic required`)
85
+ for (const d of t.deps ?? []) if (!ids.has(d)) errors.push(`task ${t.id}: unknown dep '${d}'`)
86
+ }
87
+ for (const id of [...(o.onlyIds ?? []), ...(o.withIds ?? [])]) if (!ids.has(id)) errors.push(`unknown task id '${id}'`)
88
+ // cycle check — a dep cycle must die loud at select time, not hang the loop
89
+ const state = new Map<string, 0 | 1>()
90
+ const byId = new Map(o.tasks.map((t) => [t.id, t]))
91
+ const walk = (id: string, trail: string[]): void => {
92
+ if (state.get(id) === 1) return
93
+ if (state.get(id) === 0) { errors.push(`dep cycle: ${[...trail, id].join(' → ')}`); return }
94
+ state.set(id, 0)
95
+ for (const d of byId.get(id)?.deps ?? []) walk(d, [...trail, id])
96
+ state.set(id, 1)
97
+ }
98
+ for (const t of o.tasks) walk(t.id, [])
99
+ if (errors.length > 0) return { selected: [], errors }
100
+
101
+ const withSet = new Set(o.withIds ?? [])
102
+ const skipGroups = new Set(o.skipGroups ?? [])
103
+ let selected = o.tasks.filter((t) => !(t.optIn === true && !withSet.has(t.id)) && !(t.group != null && skipGroups.has(t.group)))
104
+ if (o.onlyIds != null && o.onlyIds.length > 0) {
105
+ const keep = closure(o.tasks, o.onlyIds)
106
+ selected = selected.filter((t) => keep.has(t.id))
107
+ }
108
+ return { selected, errors }
109
+ }
110
+
111
+ export async function runTasks(o: RunOpts): Promise<RunSummary> {
112
+ const print = o.print ?? ((l: string) => console.log(l))
113
+ const color = o.color ?? (process.stdout.isTTY && process.env.NO_COLOR == null)
114
+ const sel = selectTasks(o)
115
+ if (sel.errors.length > 0) {
116
+ for (const e of sel.errors) print(formatLine({ kind: 'fail', text: e }, color))
117
+ return { ok: false, results: [], interrupted: false }
118
+ }
119
+ const selectedIds = new Set(sel.selected.map((t) => t.id))
120
+ rmSync(o.logDir, { recursive: true, force: true })
121
+ mkdirSync(o.logDir, { recursive: true })
122
+
123
+ const tasks: Live[] = sel.selected.map((t) => ({
124
+ ...t,
125
+ status: 'skipped', exitCode: null, durationMs: 0, startedAt: 0,
126
+ logPath: t.cmd != null ? join(o.logDir, `${t.id.replace(/[^a-zA-Z0-9-]/g, '_')}.log`) : null,
127
+ lastLine: '', note: '', lines: [],
128
+ }))
129
+ const byId = new Map(tasks.map((t) => [t.id, t]))
130
+ const queued = new Set(tasks.map((t) => t.id))
131
+ const done = new Map<string, Live>()
132
+ const running = new Map<string, Promise<void>>()
133
+ const cap = o.maxParallel ?? Math.max(2, Math.floor((navigator.hardwareConcurrency || 4) / 2))
134
+ let interrupted = false
135
+ let bailed = false
136
+ const procs = new Map<string, ReturnType<typeof Bun.spawn>>()
137
+ const killed = new Map<string, string>() // task id → reason
138
+ const killAll = (reason: string): void => {
139
+ for (const [id, proc] of procs) {
140
+ killed.set(id, reason)
141
+ try { proc.kill() } catch {}
142
+ setTimeout(() => { try { proc.kill('SIGKILL') } catch {} }, 2000)
143
+ }
144
+ }
145
+
146
+ const depState = (t: Live): 'ready' | 'blocked' | 'dead' => {
147
+ for (const d of t.deps ?? []) {
148
+ if (!selectedIds.has(d)) continue // filtered dep = satisfied
149
+ const dep = done.get(d)
150
+ if (dep == null) return 'blocked'
151
+ if (dep.status !== 'pass') { t.note = `dep ${d} ${dep.status}`; return 'dead' }
152
+ }
153
+ return 'ready'
154
+ }
155
+
156
+ const finish = (t: Live, status: Live['status'], code: number | null): void => {
157
+ t.status = status
158
+ t.exitCode = code
159
+ t.durationMs = t.startedAt > 0 ? Date.now() - t.startedAt : 0
160
+ done.set(t.id, t)
161
+ const dur = ` (${(t.durationMs / 1000).toFixed(1)}s)`
162
+ if (status === 'pass') {
163
+ // WARNs nag and skips stay visible (exempt contracts) even on pass
164
+ const surfaced = t.lines.filter((l) => l.kind === 'warn' || l.kind === 'fix-warn' || l.kind === 'skip')
165
+ print(formatLine({ kind: 'ok', text: `${t.id}${dur}` }, color))
166
+ for (const l of surfaced) print(formatLine(l, color))
167
+ } else if (status === 'fail') {
168
+ print(formatLine({ kind: 'fail', text: `${t.id}${dur}${t.lastLine !== '' ? ` — ${t.lastLine}` : ''}` }, color))
169
+ for (const l of t.lines.filter((l) => l.kind !== 'ok' && l.kind !== 'note')) print(formatLine(l, color))
170
+ if (t.logPath != null) print(formatLine({ kind: 'fix-fail', text: `full log: ${t.logPath}` }, color))
171
+ if (o.bail && !bailed) { bailed = true; killAll('bail') }
172
+ }
173
+ // skipped tasks report only in the summary — dep noise stays low
174
+ }
175
+
176
+ const startTask = (t: Live): void => {
177
+ queued.delete(t.id)
178
+ t.startedAt = Date.now()
179
+ const p = (async (): Promise<void> => {
180
+ if (t.logic != null) {
181
+ const ctx = new CheckCtx(new Map())
182
+ try { await t.logic(ctx) } catch (e) { ctx.fail(`${t.id} threw: ${String(e).slice(0, 200)}`) }
183
+ t.lines = ctx.lines
184
+ // headline prefers the last FAIL — an exempt/skip line landing
185
+ // last must not masquerade as the failure
186
+ const last = ctx.lines.filter((l) => l.kind === 'fail').at(-1) ?? ctx.lines.at(-1)
187
+ t.lastLine = last != null ? last.text.slice(0, 200) : ''
188
+ finish(t, ctx.failed ? 'fail' : 'pass', ctx.failed ? 1 : 0)
189
+ return
190
+ }
191
+ const cmd = t.cmd!
192
+ const head = cmd[0]
193
+ const bin = head == null ? null : head.includes('/') ? head : Bun.which(head)
194
+ // path-style heads verified too — Bun.spawn on a missing path
195
+ // throws, which must surface as a wire FAIL, never a crash
196
+ if (bin == null || !existsSync(bin)) { t.lastLine = `${head}: not found`; finish(t, 'fail', -1); return }
197
+ try {
198
+ const out = createWriteStream(t.logPath!, { flags: 'a' })
199
+ const proc = Bun.spawn([bin, ...cmd.slice(1)], {
200
+ stdout: 'pipe', stderr: 'pipe',
201
+ env: { ...process.env, ...t.env },
202
+ cwd: t.cwd ?? o.cwd,
203
+ })
204
+ procs.set(t.id, proc)
205
+ let timedOut = false
206
+ const ms = t.timeoutMs ?? 600_000
207
+ const timer = setTimeout(() => { timedOut = true; try { proc.kill() } catch {} }, ms)
208
+ const killer = setTimeout(() => { try { proc.kill('SIGKILL') } catch {} }, ms + 2000)
209
+ const eat = async (stream: ReadableStream<Uint8Array>): Promise<void> => {
210
+ const dec = new TextDecoder()
211
+ for await (const chunk of stream) {
212
+ const text = dec.decode(chunk)
213
+ out.write(text)
214
+ for (const raw of text.split(/\r?\n/)) {
215
+ const s = stripAnsi(raw).trim()
216
+ if (s !== '') t.lastLine = s.slice(0, 200)
217
+ }
218
+ }
219
+ }
220
+ await Promise.all([eat(proc.stdout), eat(proc.stderr)])
221
+ const code = await proc.exited
222
+ clearTimeout(timer)
223
+ clearTimeout(killer)
224
+ procs.delete(t.id)
225
+ out.end()
226
+ const killReason = killed.get(t.id)
227
+ if (killReason != null) { t.note = `killed (${killReason})`; finish(t, 'skipped', code); return }
228
+ if (timedOut) t.lastLine = `timeout after ${ms}ms — ${t.lastLine}`
229
+ finish(t, code === 0 ? 'pass' : 'fail', code)
230
+ } catch (e) {
231
+ procs.delete(t.id)
232
+ t.lastLine = `spawn error: ${String(e).slice(0, 200)}`
233
+ finish(t, 'fail', -1)
234
+ }
235
+ })().finally(() => running.delete(t.id))
236
+ running.set(t.id, p)
237
+ }
238
+
239
+ const onSigint = (): void => { interrupted = true; killAll('interrupt') }
240
+ process.on('SIGINT', onSigint)
241
+ try {
242
+ while (queued.size > 0 || running.size > 0) {
243
+ if (interrupted || bailed) {
244
+ for (const id of [...queued]) { const t = byId.get(id)!; t.note = bailed ? 'bailed' : 'interrupted'; queued.delete(id); done.set(id, t) }
245
+ await Promise.all(running.values())
246
+ break
247
+ }
248
+ let started = false
249
+ for (const id of [...queued]) {
250
+ if (running.size >= cap) break
251
+ const t = byId.get(id)!
252
+ const st = depState(t)
253
+ if (st === 'dead') { queued.delete(id); finish(t, 'skipped', null); started = true }
254
+ else if (st === 'ready') { startTask(t); started = true }
255
+ }
256
+ if (running.size > 0) await Promise.race(running.values())
257
+ else if (!started && queued.size > 0) {
258
+ // nothing running, nothing startable — impossible unless a bug: die loud
259
+ for (const id of [...queued]) print(formatLine({ kind: 'fail', text: `${id}: scheduler stuck (unsatisfiable deps?)` }, color))
260
+ break
261
+ }
262
+ }
263
+ } finally {
264
+ process.off('SIGINT', onSigint)
265
+ }
266
+
267
+ const results = tasks.map((t): TaskResult => ({ id: t.id, status: t.status, exitCode: t.exitCode, durationMs: t.durationMs, logPath: t.logPath, lastLine: t.lastLine, note: t.note, lines: t.lines }))
268
+ const failed = results.filter((r) => r.status === 'fail')
269
+ const skipped = results.filter((r) => r.status === 'skipped')
270
+ if (failed.length > 0) print(formatLine({ kind: 'fail', text: `${failed.length}/${results.length} failed: ${failed.map((r) => r.id).join(', ')}` }, color))
271
+ if (skipped.length > 0) print(formatLine({ kind: 'note', text: `skip: ${skipped.map((r) => `${r.id}${r.note !== '' ? ` (${r.note})` : ''}`).join(', ')}` }, color))
272
+ if (failed.length === 0 && !interrupted) print(formatLine({ kind: 'ok', text: `all ${results.filter((r) => r.status === 'pass').length} tasks green` }, color))
273
+ return { ok: failed.length === 0 && !interrupted, results, interrupted }
274
+ }
@@ -0,0 +1,36 @@
1
+ // v0 wire contract — the line protocol every shipkit surface speaks:
2
+ // `ok:` / `WARN` / `FAIL:` / `skip:` prefixes + indented `fix:` pointers,
3
+ // exit 0/1. FROZEN: consumers grep startsWith('FAIL'/'WARN'/'skip:') — a
4
+ // prefix change is a breaking change of the whole toolkit.
5
+
6
+ export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string }
7
+
8
+ const green = (s: string): string => `\x1b[32m${s}\x1b[0m`
9
+ const red = (s: string): string => `\x1b[31m${s}\x1b[0m`
10
+ const yellow = (s: string): string => `\x1b[33m${s}\x1b[0m`
11
+
12
+ export function formatLine(l: Line, color: boolean): string {
13
+ const c = (f: (s: string) => string, s: string) => (color ? f(s) : s)
14
+ switch (l.kind) {
15
+ case 'ok': return `${c(green, 'ok:')} ${l.text}`
16
+ case 'warn': return c(yellow, `WARN ${l.text}`)
17
+ case 'fail': return c(red, `FAIL: ${l.text}`)
18
+ case 'fix-warn': return ` ${c(yellow, `fix: ${l.text}`)}`
19
+ case 'fix-fail': return ` ${c(red, `fix: ${l.text}`)}`
20
+ case 'skip': return `skip: ${l.text}`
21
+ case 'note': return l.text
22
+ }
23
+ }
24
+
25
+ export function parseDuration(d: string | number): number {
26
+ if (typeof d === 'number') return d
27
+ const m = d.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/)
28
+ if (m == null) throw new Error(`bad duration '${d}' — use e.g. '90s', '30m', '6h', '1d'`)
29
+ return Number(m[1]) * { ms: 1, s: 1000, m: 60_000, h: 3600_000, d: 86400_000 }[m[2] as 'ms' | 's' | 'm' | 'h' | 'd']
30
+ }
31
+
32
+ export const fmtAge = (ms: number): string =>
33
+ ms < 60_000 ? `${Math.round(ms / 1000)}s`
34
+ : ms < 3600_000 ? `${Math.round(ms / 60_000)}m`
35
+ : ms < 86400_000 ? `${Math.round(ms / 3600_000)}h`
36
+ : `${Math.round(ms / 86400_000)}d`
@@ -0,0 +1,119 @@
1
+ // defineRepo — the ONE import a repo needs: repo.config.ts at the root
2
+ // declares venues/social/tasks/checks, and the toolkit derives everything
3
+ // else. The FLOOR (lint/typecheck/test/imports:check/cycles) auto-registers
4
+ // iff the matching package.json script exists; disabling via
5
+ // `defaults: { x: false }` always prints a skipped-by-config line, never
6
+ // silence. The `contracts` task is always present when any contract applies.
7
+ import { existsSync, readFileSync } from 'fs'
8
+ import { join } from 'path'
9
+ import { allContracts } from './contracts/contract-manifests.ts'
10
+ import { evaluateContracts, type ContractsConfig, type ContractsResult } from './contracts/contracts-engine.ts'
11
+ import type { ContractManifest } from './contracts/contract-types.ts'
12
+ import type { TaskDef } from './core/task-runner.ts'
13
+ import type { CheckCtx } from './core/check-ctx.ts'
14
+
15
+ export type RepoCheck = { id: string; logic: (t: CheckCtx) => unknown; remainValidFor?: string | number; version?: number }
16
+
17
+ export type RepoConfig = {
18
+ name: string
19
+ /** venue ids this repo ships on — selects which contracts are REQUIRED */
20
+ venues?: string[]
21
+ /** social channels with a .rv-social/<channel>/ post folder */
22
+ social?: string[]
23
+ /** prefix for contract fix: pointers (docs checkout or URL) — written by init, never baked in */
24
+ docsBase?: string
25
+ /** override a floor task's def, or `false` to disable (always visible) */
26
+ defaults?: Record<string, TaskDef | false>
27
+ /** repo-specific commit-gate tasks; deps double as race guards */
28
+ tasks?: TaskDef[]
29
+ /** periodic health checks, TTL-cached via the check suite */
30
+ checks?: RepoCheck[]
31
+ asks?: { dir?: string }
32
+ contracts?: { exempt?: Record<string, string>; extra?: ContractManifest[] }
33
+ logDir?: string
34
+ }
35
+
36
+ export function defineRepo(c: RepoConfig): RepoConfig {
37
+ if (typeof c.name !== 'string' || c.name === '') throw new Error('defineRepo: name is required')
38
+ for (const v of c.venues ?? []) if (!/^[a-z0-9-]+$/.test(v)) throw new Error(`defineRepo: venue '${v}' must be kebab-case`)
39
+ return c
40
+ }
41
+
42
+ export async function loadRepoConfig(root: string): Promise<RepoConfig> {
43
+ const path = join(root, 'repo.config.ts')
44
+ if (!existsSync(path)) throw new Error(`no repo.config.ts in ${root} — run: shipkit init`)
45
+ const mod = (await import(path)) as { default?: RepoConfig }
46
+ if (mod.default == null) throw new Error(`${path}: must default-export defineRepo({...})`)
47
+ return mod.default
48
+ }
49
+
50
+ export function contractsConfigFrom(c: RepoConfig, root: string): ContractsConfig {
51
+ const venues = c.venues ?? []
52
+ const social = c.social ?? []
53
+ const declared = [
54
+ ...venues,
55
+ ...(social.length > 0 ? ['social'] : []),
56
+ ...(social.includes('discord') ? ['discord'] : []),
57
+ ]
58
+ return {
59
+ root,
60
+ declared,
61
+ social,
62
+ publicSurface: venues.length > 0 || existsSync(join(root, 'deploy.sh')),
63
+ exempt: c.contracts?.exempt,
64
+ docsBase: c.docsBase,
65
+ }
66
+ }
67
+
68
+ export function activeContracts(c: RepoConfig): ContractManifest[] {
69
+ return [...allContracts, ...(c.contracts?.extra ?? [])]
70
+ }
71
+
72
+ // ONE mapping from an engine result into a CheckCtx — used by BOTH the ci
73
+ // task and `shipkit check`; kinds pass through raw so fix pointers and
74
+ // visible exempts survive every printer
75
+ export function applyContractsResult(t: CheckCtx, r: ContractsResult): void {
76
+ for (const l of r.lines) t.lines.push(l)
77
+ if (r.exitCode === 1) t.failed = true
78
+ if (r.lines.some((l) => l.kind === 'warn')) t.warned = true
79
+ if (r.lines.length === 0) t.ok('no contracts apply (nothing declared, nothing detected)')
80
+ }
81
+
82
+ // the contracts commit-gate task — venue deploy tasks should dep on it
83
+ export function contractsTask(c: RepoConfig, root: string): TaskDef {
84
+ return {
85
+ id: 'contracts',
86
+ logic: async (t) => {
87
+ applyContractsResult(t, await evaluateContracts(activeContracts(c), contractsConfigFrom(c, root)))
88
+ },
89
+ }
90
+ }
91
+
92
+ const FLOOR_IDS = ['lint', 'typecheck', 'test', 'imports:check', 'cycles'] as const
93
+
94
+ export type FloorResolution = { tasks: TaskDef[]; disabled: string[] }
95
+
96
+ export function resolveFloor(c: RepoConfig, root: string): FloorResolution {
97
+ const pkgPath = join(root, 'package.json')
98
+ let scripts: Record<string, string> = {}
99
+ if (existsSync(pkgPath)) {
100
+ // unparseable package.json must NOT silently empty the floor — the
101
+ // gate would run only `contracts` and report green
102
+ try { scripts = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { scripts?: Record<string, string> }).scripts ?? {} }
103
+ catch (e) { throw new Error(`package.json unparseable — floor tasks cannot register: ${String(e).slice(0, 120)}`) }
104
+ }
105
+ const tasks: TaskDef[] = []
106
+ const disabled: string[] = []
107
+ for (const id of FLOOR_IDS) {
108
+ const override = c.defaults?.[id]
109
+ if (override === false) { disabled.push(id); continue }
110
+ if (override != null) { tasks.push(override); continue }
111
+ if (scripts[id] != null) tasks.push({ id, cmd: ['bun', 'run', id] })
112
+ }
113
+ return { tasks, disabled }
114
+ }
115
+
116
+ export function allTasks(c: RepoConfig, root: string): { tasks: TaskDef[]; disabled: string[] } {
117
+ const floor = resolveFloor(c, root)
118
+ return { tasks: [...floor.tasks, contractsTask(c, root), ...(c.tasks ?? [])], disabled: floor.disabled }
119
+ }
package/src/shipkit.ts ADDED
@@ -0,0 +1,23 @@
1
+ // rvlib-shipkit — barrel. One import per repo: defineRepo in repo.config.ts;
2
+ // everything else is the machinery behind the shipkit CLI.
3
+ export { defineRepo, loadRepoConfig, resolveFloor, allTasks, contractsTask, contractsConfigFrom, activeContracts } from './repo-config.ts'
4
+ export type { RepoConfig, RepoCheck } from './repo-config.ts'
5
+ export { runTasks, selectTasks } from './core/task-runner.ts'
6
+ export type { TaskDef, TaskResult, RunOpts, RunSummary } from './core/task-runner.ts'
7
+ export { CheckSuite } from './core/check-suite.ts'
8
+ export type { CheckSuiteOpts, GetSecret, SecretSpec } from './core/check-suite.ts'
9
+ export { CheckCtx, RateLimited, ToolUnresolved } from './core/check-ctx.ts'
10
+ export { formatLine, parseDuration, fmtAge } from './core/wire.ts'
11
+ export type { Line } from './core/wire.ts'
12
+ export { evaluateContracts, pngDims } from './contracts/contracts-engine.ts'
13
+ export type { ContractsConfig, ContractsResult, ContractOutcome } from './contracts/contracts-engine.ts'
14
+ export { defineContract, validateManifest, file, png, glob, md, dir, custom, script, fileContains, dirExists } from './contracts/contract-types.ts'
15
+ export type { ContractManifest, ArtifactRule, Detector, ContractEnv } from './contracts/contract-types.ts'
16
+ export { allContracts } from './contracts/contract-manifests.ts'
17
+ export { listAsks, openAsks, checkAsks, createAsk, renderAsksTable } from './asks/asks-queue.ts'
18
+ export type { Ask, CreateOpts } from './asks/asks-queue.ts'
19
+ export { scanMdGraph } from './wiki/scan-md.ts'
20
+ export { lintMd } from './wiki/lint-md.ts'
21
+ export { serveMdGraph } from './wiki/serve-graph.ts'
22
+ export { graphHtml } from './wiki/graph-page.ts'
23
+ export type { MdGraph, MdNode, MdEdge } from './wiki/md-graph.ts'
@@ -0,0 +1,169 @@
1
+ // Self-contained HTML: a force-directed canvas graph (no deps, no CDN).
2
+ // Small balls (group-colored, eager layer highlighted), directed edges with
3
+ // arrowheads — solid = eager auto-include, dashed = lazy ref. Drag balls,
4
+ // drag background to pan, wheel to zoom, hover to focus a node's links.
5
+ import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
6
+
7
+ const PALETTE = ['#e05252', '#e0a552', '#c6d34f', '#5ec95e', '#52c9b0', '#529fe0', '#8f6fe8', '#d46fd4', '#9a8a72']
8
+
9
+ export function graphHtml(g: MdGraph, opts?: { title?: string }): string {
10
+ // arbitrary wikis can hold hostile-looking titles/labels — keep the page unbreakable
11
+ const title = (opts?.title ?? 'wiki graph').replace(/&/g, '&amp;').replace(/</g, '&lt;')
12
+ const groups = [...new Set(g.nodes.map((n) => n.group ?? ''))].sort()
13
+ const color = (grp: string | null) => PALETTE[groups.indexOf(grp ?? '') % PALETTE.length]!
14
+ const data = {
15
+ nodes: g.nodes.map((n) => ({ ...n, color: n.eager ? '#8b5cf6' : color(n.group) })),
16
+ edges: g.edges,
17
+ }
18
+ const dataJson = JSON.stringify(data).replaceAll('<', '\\u003c')
19
+ return `<!doctype html>
20
+ <html>
21
+ <head>
22
+ <meta charset="utf-8">
23
+ <title>${title}</title>
24
+ <style>
25
+ :root { color-scheme: light dark; }
26
+ html, body { margin: 0; height: 100%; overflow: hidden;
27
+ font: 12px/1.4 -apple-system, system-ui, sans-serif;
28
+ background: light-dark(#fafafa, #101014); color: light-dark(#333, #ccc); }
29
+ header { position: fixed; inset: 0 auto auto 0; z-index: 1; padding: .5em .9em; opacity: .85;
30
+ display: flex; gap: 1.2em; align-items: baseline; pointer-events: none; }
31
+ header b { font-size: 13px; }
32
+ canvas { display: block; cursor: grab; }
33
+ </style>
34
+ </head>
35
+ <body>
36
+ <header><b>${title}</b><span>solid → @ co-load · dashed → lazy ref · purple = eager layer · number = hops from index · drag / pan / zoom · reload = regenerate</span></header>
37
+ <canvas id="c"></canvas>
38
+ <script>
39
+ const DATA = ${dataJson}
40
+ const canvas = document.getElementById('c')
41
+ const ctx = canvas.getContext('2d')
42
+ const dark = matchMedia('(prefers-color-scheme: dark)').matches
43
+ const N = DATA.nodes, byId = new Map(N.map(n => [n.id, n]))
44
+ const E = DATA.edges.filter(e => byId.has(e.from) && byId.has(e.to))
45
+ const deg = new Map()
46
+ for (const e of E) { deg.set(e.from, (deg.get(e.from) ?? 0) + 1); deg.set(e.to, (deg.get(e.to) ?? 0) + 1) }
47
+ for (const n of N) {
48
+ const a = Math.random() * Math.PI * 2, d = 120 + Math.random() * 240
49
+ n.x = Math.cos(a) * d; n.y = Math.sin(a) * d; n.vx = 0; n.vy = 0
50
+ n.r = 4 + Math.min(6, Math.sqrt(deg.get(n.id) ?? 0) * 1.6)
51
+ }
52
+ let scale = 1, ox = 0, oy = 0, alpha = 1, hover = null, drag = null, panning = null
53
+ function resize() {
54
+ const dpr = devicePixelRatio || 1
55
+ canvas.width = innerWidth * dpr; canvas.height = innerHeight * dpr
56
+ canvas.style.width = innerWidth + 'px'; canvas.style.height = innerHeight + 'px'
57
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0)
58
+ ox = ox || innerWidth / 2; oy = oy || innerHeight / 2
59
+ }
60
+ resize(); addEventListener('resize', resize)
61
+
62
+ function step() {
63
+ // repulsion between every pair, springs on edges (eager pulls tighter),
64
+ // light gravity to the origin — classic Fruchterman-style, 60fps for ~100s of nodes
65
+ for (let i = 0; i < N.length; i++) for (let j = i + 1; j < N.length; j++) {
66
+ const a = N[i], b = N[j]
67
+ let dx = a.x - b.x, dy = a.y - b.y
68
+ let d2 = dx * dx + dy * dy; if (d2 < 1) { dx = Math.random() - .5; dy = Math.random() - .5; d2 = 1 }
69
+ const f = 1200 / d2
70
+ const d = Math.sqrt(d2)
71
+ dx /= d; dy /= d
72
+ a.vx += dx * f; a.vy += dy * f; b.vx -= dx * f; b.vy -= dy * f
73
+ }
74
+ for (const e of E) {
75
+ const a = byId.get(e.from), b = byId.get(e.to)
76
+ const dx = b.x - a.x, dy = b.y - a.y
77
+ const d = Math.sqrt(dx * dx + dy * dy) || 1
78
+ const rest = e.eager ? 60 : 110
79
+ const f = (d - rest) * (e.eager ? 0.02 : 0.008)
80
+ a.vx += dx / d * f; a.vy += dy / d * f; b.vx -= dx / d * f; b.vy -= dy / d * f
81
+ }
82
+ for (const n of N) {
83
+ n.vx -= n.x * 0.0015; n.vy -= n.y * 0.0015
84
+ if (n === drag) { n.vx = 0; n.vy = 0; continue }
85
+ n.x += n.vx * alpha; n.y += n.vy * alpha
86
+ n.vx *= 0.85; n.vy *= 0.85
87
+ }
88
+ if (!drag) alpha = Math.max(0.05, alpha * 0.998)
89
+ }
90
+
91
+ function draw() {
92
+ ctx.clearRect(0, 0, innerWidth, innerHeight)
93
+ ctx.save(); ctx.translate(ox, oy); ctx.scale(scale, scale)
94
+ const focus = hover ? new Set([hover.id, ...E.flatMap(e => e.from === hover.id ? [e.to] : e.to === hover.id ? [e.from] : [])]) : null
95
+ for (const e of E) {
96
+ const a = byId.get(e.from), b = byId.get(e.to)
97
+ const lit = focus == null || e.from === hover.id || e.to === hover.id
98
+ ctx.globalAlpha = lit ? (e.eager ? 1 : 0.55) : 0.08
99
+ ctx.strokeStyle = e.eager ? '#8b5cf6' : (dark ? '#8a8a95' : '#777')
100
+ ctx.lineWidth = (e.eager ? 3.5 : 1) / scale
101
+ ctx.setLineDash(e.eager ? [] : [4 / scale, 4 / scale])
102
+ const dx = b.x - a.x, dy = b.y - a.y, d = Math.sqrt(dx * dx + dy * dy) || 1
103
+ const tx = b.x - dx / d * (b.r + 3), ty = b.y - dy / d * (b.r + 3)
104
+ ctx.beginPath(); ctx.moveTo(a.x + dx / d * a.r, a.y + dy / d * a.r); ctx.lineTo(tx, ty); ctx.stroke()
105
+ ctx.setLineDash([])
106
+ const ah = 5 / scale
107
+ ctx.fillStyle = ctx.strokeStyle
108
+ ctx.beginPath()
109
+ ctx.moveTo(tx, ty)
110
+ ctx.lineTo(tx - dx / d * ah * 2 - dy / d * ah, ty - dy / d * ah * 2 + dx / d * ah)
111
+ ctx.lineTo(tx - dx / d * ah * 2 + dy / d * ah, ty - dy / d * ah * 2 - dx / d * ah)
112
+ ctx.fill()
113
+ }
114
+ for (const n of N) {
115
+ const lit = focus == null || focus.has(n.id)
116
+ ctx.globalAlpha = lit ? 1 : 0.15
117
+ ctx.fillStyle = n.color
118
+ ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, 7); ctx.fill()
119
+ if (n.eager) { ctx.strokeStyle = '#8b5cf6'; ctx.lineWidth = 2 / scale; ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 2.5 / scale, 0, 7); ctx.stroke() }
120
+ if (n.depth != null && scale * n.r > 3) {
121
+ ctx.fillStyle = '#fff'
122
+ ctx.font = '600 ' + n.r * 1.1 + 'px system-ui'
123
+ ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
124
+ ctx.fillText(n.depth, n.x, n.y + n.r * 0.08)
125
+ ctx.textBaseline = 'alphabetic'
126
+ }
127
+ const showLabel = n === hover || scale > 0.8 || lit && focus != null
128
+ if (showLabel) {
129
+ ctx.globalAlpha = n === hover ? 1 : lit ? 0.8 : 0.1
130
+ ctx.fillStyle = dark ? '#ddd' : '#333'
131
+ ctx.font = (n === hover ? 13 : 11) / scale + 'px system-ui'
132
+ ctx.textAlign = 'center'
133
+ ctx.fillText(n.group ? n.group + '/' + n.label : n.label, n.x, n.y + n.r + 13 / scale)
134
+ }
135
+ }
136
+ ctx.restore()
137
+ }
138
+ function loop() { step(); draw(); requestAnimationFrame(loop) }
139
+ loop()
140
+
141
+ const world = (px, py) => [(px - ox) / scale, (py - oy) / scale]
142
+ const nodeAt = (px, py) => {
143
+ const [x, y] = world(px, py)
144
+ return N.find(n => (n.x - x) ** 2 + (n.y - y) ** 2 < (n.r + 4 / scale) ** 2) ?? null
145
+ }
146
+ canvas.addEventListener('pointerdown', ev => {
147
+ const n = nodeAt(ev.clientX, ev.clientY)
148
+ if (n) { drag = n; alpha = 1 } else panning = { x: ev.clientX - ox, y: ev.clientY - oy }
149
+ canvas.setPointerCapture(ev.pointerId)
150
+ canvas.style.cursor = 'grabbing'
151
+ })
152
+ canvas.addEventListener('pointermove', ev => {
153
+ if (drag) { const [x, y] = world(ev.clientX, ev.clientY); drag.x = x; drag.y = y; alpha = 1 }
154
+ else if (panning) { ox = ev.clientX - panning.x; oy = ev.clientY - panning.y }
155
+ else hover = nodeAt(ev.clientX, ev.clientY)
156
+ })
157
+ addEventListener('pointerup', () => { drag = null; panning = null; canvas.style.cursor = 'grab' })
158
+ canvas.addEventListener('wheel', ev => {
159
+ ev.preventDefault()
160
+ const z = Math.exp(-ev.deltaY * 0.0015)
161
+ const [wx, wy] = world(ev.clientX, ev.clientY)
162
+ scale = Math.min(6, Math.max(0.15, scale * z))
163
+ ox = ev.clientX - wx * scale; oy = ev.clientY - wy * scale
164
+ }, { passive: false })
165
+ </script>
166
+ </body>
167
+ </html>
168
+ `
169
+ }
@@ -0,0 +1,24 @@
1
+ // Markdown-wiki lint: every page must be reachable from the entrypoint, and
2
+ // every deliberate ref (@ / → / [](…)) must resolve to a real file. Pure fs,
3
+ // no config — same scan the graph view uses, so lint and view never disagree.
4
+ import { scanMd, type MdDanglingRef, type ScanMdConfig } from 'rvlib-shipkit/src/wiki/scan-md.ts'
5
+
6
+ export type MdLintReport = {
7
+ index: string | null // resolved entrypoint (null = neither index.md nor README.md found)
8
+ files: number
9
+ orphans: string[] // .md files with no path from the entrypoint
10
+ dangling: MdDanglingRef[] // refs whose target does not exist in the scan
11
+ ok: boolean
12
+ }
13
+
14
+ export function lintMd(cfg: ScanMdConfig): MdLintReport {
15
+ const scan = scanMd(cfg)
16
+ const orphans = scan.index == null ? [] : scan.graph.nodes.filter((n) => n.depth == null).map((n) => n.id)
17
+ return {
18
+ index: scan.index,
19
+ files: scan.graph.nodes.length,
20
+ orphans,
21
+ dangling: scan.dangling,
22
+ ok: scan.index != null && orphans.length === 0 && scan.dangling.length === 0,
23
+ }
24
+ }
@@ -0,0 +1,6 @@
1
+ // A markdown wiki's link graph, ready to render. Two edge kinds mirror the two
2
+ // loading modes a doc system can have: eager (auto-included with its parent,
3
+ // e.g. `@path` imports) and lazy (loaded on demand, e.g. `→ path` refs).
4
+ export type MdNode = { id: string; label: string; group: string | null; eager: boolean; depth?: number } // depth = hops from the wiki's root, drawn inside the ball
5
+ export type MdEdge = { from: string; to: string; eager: boolean }
6
+ export type MdGraph = { nodes: MdNode[]; edges: MdEdge[] }