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 +21 -0
- package/README.md +58 -0
- package/package.json +19 -0
- package/src/asks/asks-queue.ts +95 -0
- package/src/cli/shipkit-asks.ts +26 -0
- package/src/cli/shipkit-check.ts +26 -0
- package/src/cli/shipkit-ci.ts +30 -0
- package/src/cli/shipkit-contracts.ts +26 -0
- package/src/cli/shipkit-init.ts +40 -0
- package/src/cli/shipkit-wiki.ts +74 -0
- package/src/cli.ts +28 -0
- package/src/contracts/contract-manifests.ts +10 -0
- package/src/contracts/contract-types.ts +77 -0
- package/src/contracts/contracts-engine.ts +186 -0
- package/src/contracts/manifests/discord.contract.ts +15 -0
- package/src/contracts/manifests/identity.contract.ts +19 -0
- package/src/contracts/manifests/itch.contract.ts +16 -0
- package/src/contracts/manifests/social.contract.ts +18 -0
- package/src/contracts/manifests/steam.contract.ts +28 -0
- package/src/core/check-ctx.ts +63 -0
- package/src/core/check-suite.ts +179 -0
- package/src/core/task-runner.ts +274 -0
- package/src/core/wire.ts +36 -0
- package/src/repo-config.ts +119 -0
- package/src/shipkit.ts +23 -0
- package/src/wiki/graph-page.ts +169 -0
- package/src/wiki/lint-md.ts +24 -0
- package/src/wiki/md-graph.ts +6 -0
- package/src/wiki/scan-md.ts +108 -0
- package/src/wiki/serve-graph.ts +19 -0
|
@@ -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
|
+
}
|
package/src/core/wire.ts
ADDED
|
@@ -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, '&').replace(/</g, '<')
|
|
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[] }
|