rvlib-shipkit 0.38.0 → 1.0.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 +371 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +216 -18
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// which tsconfig files a typecheck must actually visit.
|
|
2
|
+
//
|
|
3
|
+
// `tsc --noEmit -p <solution>` on a solution-style config (`files: []` plus
|
|
4
|
+
// `references`) compiles ZERO files and exits 0. The gate then prints
|
|
5
|
+
// `ok: typecheck` while a type error sits in src, which is the single worst
|
|
6
|
+
// output this repo can produce: an unmeasured green nobody can tell from a
|
|
7
|
+
// measured one. tsc will not warn about it, so the caller has to resolve the
|
|
8
|
+
// leaves itself and refuse when there are none.
|
|
9
|
+
//
|
|
10
|
+
// No disk access of its own: every read goes through the reader it is handed,
|
|
11
|
+
// plain fs outside a check (ts/tsconfig-projects.ts), the check context
|
|
12
|
+
// inside one, so a check that asks how typecheck resolves is keyed on the
|
|
13
|
+
// configs it read.
|
|
14
|
+
import { dirname, isAbsolute, join, relative, resolve } from 'path'
|
|
15
|
+
import type { UnitReader } from '../release/unit-resolve.ts'
|
|
16
|
+
import { parseJsonc } from './jsonc.ts'
|
|
17
|
+
|
|
18
|
+
export type TypecheckProjects = {
|
|
19
|
+
/** absolute paths, in declaration order, of the configs that compile files */
|
|
20
|
+
projects: string[]
|
|
21
|
+
/** nothing would be compiled: the check would pass without checking */
|
|
22
|
+
vacuous: boolean
|
|
23
|
+
/** references pointing at a path that does not exist, verbatim as declared */
|
|
24
|
+
missing: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
type RawConfig = {
|
|
28
|
+
files?: unknown
|
|
29
|
+
include?: unknown
|
|
30
|
+
references?: { path?: unknown }[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** the reader, over absolute paths: it answers by the path relative to `root` */
|
|
34
|
+
type At = { text: (abs: string) => string | null; exists: (abs: string, kind?: 'file' | 'dir') => boolean }
|
|
35
|
+
|
|
36
|
+
/** a reference may name a directory, a config file, or a dir with no config */
|
|
37
|
+
function configPathFor(at: At, base: string, declared: string): string | null {
|
|
38
|
+
const p = isAbsolute(declared) ? declared : resolve(base, declared)
|
|
39
|
+
if (at.exists(p, 'dir')) return at.exists(join(p, 'tsconfig.json')) ? join(p, 'tsconfig.json') : null
|
|
40
|
+
return at.exists(p) ? p : null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* whether tsc would compile anything for THIS config, ignoring its references.
|
|
45
|
+
*
|
|
46
|
+
* tsc's rule, and every branch here is a shape found in the field:
|
|
47
|
+
* - `files` non-empty, or `include` non-empty: compiles those
|
|
48
|
+
* - neither key present: the default `include` is `**\/*`, so it compiles the
|
|
49
|
+
* whole directory tree, which is why a bare `{ references: [...] }` is NOT
|
|
50
|
+
* a solution config
|
|
51
|
+
* - `files: []` with no `include`: the solution-style marker, compiles nothing
|
|
52
|
+
* - `include: []`: compiles nothing whatever `files` says, unless `files` lists
|
|
53
|
+
* something
|
|
54
|
+
*/
|
|
55
|
+
function compilesOwnFiles(cfg: RawConfig): boolean {
|
|
56
|
+
const files = Array.isArray(cfg.files) ? cfg.files : null
|
|
57
|
+
const include = Array.isArray(cfg.include) ? cfg.include : null
|
|
58
|
+
if (files != null && files.length > 0) return true
|
|
59
|
+
if (include != null) return include.length > 0
|
|
60
|
+
return files == null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function read(at: At, path: string): RawConfig {
|
|
64
|
+
const text = at.text(path)
|
|
65
|
+
if (text == null) throw new Error(`tsconfig unreadable at ${path}: no file there`)
|
|
66
|
+
try { return (parseJsonc(text) ?? {}) as RawConfig }
|
|
67
|
+
catch (e) { throw new Error(`tsconfig unparseable at ${path}: ${String(e).slice(0, 120)}`) }
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Walk `references` to the leaves. A leaf that declares `files: []` with no
|
|
72
|
+
* `include` compiles nothing and is dropped, so a solution whose every leaf is
|
|
73
|
+
* empty comes back `vacuous` rather than as a command that passes for free.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveTypecheckProjects(tsconfigPath: string, io: UnitReader, root: string): TypecheckProjects {
|
|
76
|
+
const rel = (abs: string): string => relative(root, abs).replace(/\\/g, '/')
|
|
77
|
+
const at: At = { text: (abs) => io.text(rel(abs)), exists: (abs, kind) => io.exists(rel(abs), kind) }
|
|
78
|
+
const start = resolve(root, tsconfigPath)
|
|
79
|
+
const projects: string[] = []
|
|
80
|
+
const missing: string[] = []
|
|
81
|
+
// a reference loop is legal to WRITE and would otherwise recurse forever
|
|
82
|
+
const seen = new Set<string>()
|
|
83
|
+
|
|
84
|
+
const visit = (path: string): void => {
|
|
85
|
+
if (seen.has(path)) return
|
|
86
|
+
seen.add(path)
|
|
87
|
+
const cfg = read(at, path)
|
|
88
|
+
// references are checked FIRST so the list reads dependencies before
|
|
89
|
+
// dependents, the order `tsc -b` would build in
|
|
90
|
+
for (const r of Array.isArray(cfg.references) ? cfg.references : []) {
|
|
91
|
+
const declared = typeof r?.path === 'string' ? r.path : null
|
|
92
|
+
if (declared == null) continue
|
|
93
|
+
const next = configPathFor(at, dirname(path), declared)
|
|
94
|
+
if (next == null) missing.push(declared)
|
|
95
|
+
else visit(next)
|
|
96
|
+
}
|
|
97
|
+
// `references` does NOT suppress a config's own inputs: only an empty
|
|
98
|
+
// `files` with no `include` does, and that is what makes a config
|
|
99
|
+
// solution-style. Skipping a referencing config outright is how the
|
|
100
|
+
// first version of this file reintroduced the false green it exists to
|
|
101
|
+
// kill, on `{ include: ['src'], references: [...] }`, which is the
|
|
102
|
+
// ordinary monorepo package shape
|
|
103
|
+
if (compilesOwnFiles(cfg)) projects.push(path)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
visit(start)
|
|
107
|
+
return { projects, vacuous: projects.length === 0, missing }
|
|
108
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// built-in panel: the gate's BUILD STEPS through runCi, results streaming in
|
|
2
|
+
// via the onCheck tap. A build step is what `shipkit ci` runs and the health
|
|
3
|
+
// pass leaves out, which is the ONE axis that splits this panel from the one
|
|
4
|
+
// beside it. Every other check is skipped here on purpose: the checks panel
|
|
5
|
+
// runs and renders them, and running them twice per refresh proves the same
|
|
6
|
+
// thing twice.
|
|
7
|
+
// no panel writes STATUS.md: a dashboard refresh runs the build steps alone,
|
|
8
|
+
// so stamping a verdict from it would overwrite what a real gate proved with
|
|
9
|
+
// a narrower claim, just by looking at the repo.
|
|
10
|
+
import type { RepoConfig } from '../repo-config.ts'
|
|
11
|
+
import { allTasks } from '../ledger/gate-checks.ts'
|
|
12
|
+
import { runCi } from '../core/run-ci.ts'
|
|
13
|
+
import { formatLine } from '../core/wire.ts'
|
|
14
|
+
import type { CheckEvent } from '../core/check-runner.ts'
|
|
15
|
+
import type { TuiPanelDef, TuiRow } from './tui-panel.ts'
|
|
16
|
+
|
|
17
|
+
const checkRow = (e: CheckEvent): TuiRow => ({
|
|
18
|
+
key: e.id,
|
|
19
|
+
label: e.id,
|
|
20
|
+
status: e.status === 'pass' ? 'pass' : e.status === 'fail' ? 'fail' : e.status === 'warn' ? 'warn' : 'skip',
|
|
21
|
+
hint: `${(e.ms / 1000).toFixed(1)}s`,
|
|
22
|
+
detail: [
|
|
23
|
+
...e.lines.map((l) => formatLine(l, false)),
|
|
24
|
+
...(e.note != null && e.note !== '' ? [`skip: ${e.note}`] : []),
|
|
25
|
+
...(e.logPath != null ? [`full log: ${e.logPath}`] : []),
|
|
26
|
+
],
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
export function buildStepsPanel(root: string, config: RepoConfig): TuiPanelDef {
|
|
30
|
+
let rows: TuiRow[] = []
|
|
31
|
+
return {
|
|
32
|
+
id: 'build',
|
|
33
|
+
title: 'build steps',
|
|
34
|
+
rows: () => rows,
|
|
35
|
+
refresh: async (update) => {
|
|
36
|
+
const { tasks, disabled, unfilled } = await allTasks(config, root)
|
|
37
|
+
rows = [
|
|
38
|
+
...tasks.map((t): TuiRow => ({ key: t.id, label: t.id, status: 'pending', detail: [] })),
|
|
39
|
+
...disabled.map((id): TuiRow => ({ key: id, label: id, status: 'skip', detail: ['skip: disabled by repo.config defaults'] })),
|
|
40
|
+
...unfilled.map((u): TuiRow => ({ key: u.id, label: u.id, status: 'skip', detail: [`skip: ${u.reason}`] })),
|
|
41
|
+
]
|
|
42
|
+
const byKey = new Map(rows.map((r, i) => [r.key, i]))
|
|
43
|
+
update()
|
|
44
|
+
await runCi(root, {
|
|
45
|
+
config,
|
|
46
|
+
buildOnly: true,
|
|
47
|
+
status: false,
|
|
48
|
+
print: () => {}, // rows carry everything the wire lines would
|
|
49
|
+
onCheck: (e) => {
|
|
50
|
+
const i = byKey.get(e.id)
|
|
51
|
+
if (i == null) return
|
|
52
|
+
rows[i] = checkRow(e)
|
|
53
|
+
update()
|
|
54
|
+
},
|
|
55
|
+
})
|
|
56
|
+
// bail/interrupt leftovers never got a finish tap — visible, never a fake pass
|
|
57
|
+
rows = rows.map((r) => (r.status === 'pending' ? { ...r, status: 'skip', detail: ['skip: never ran (bailed or interrupted)'] } : r))
|
|
58
|
+
update()
|
|
59
|
+
},
|
|
60
|
+
}
|
|
61
|
+
}
|
package/src/tui/checks-panel.ts
CHANGED
|
@@ -1,35 +1,43 @@
|
|
|
1
|
-
// built-in panel:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// built-in panel: every check the health pass asks for, through runCheck,
|
|
2
|
+
// results streaming in via the onCheck tap. The rows are drawn from the same
|
|
3
|
+
// inventory every other surface reads, so a check cannot exist for the gate
|
|
4
|
+
// and not for the dashboard. status:false: no TUI panel writes STATUS.md
|
|
5
|
+
// (build-steps-panel.ts).
|
|
6
|
+
import { gateInventory } from '../ledger/gate-checks.ts'
|
|
7
|
+
import { loadRepoConfig, type RepoConfig } from '../repo-config.ts'
|
|
5
8
|
import { runCheck } from '../core/run-check.ts'
|
|
6
9
|
import { formatLine } from '../core/wire.ts'
|
|
7
10
|
import type { TuiPanelDef, TuiRow } from './tui-panel.ts'
|
|
11
|
+
import { CACHED } from '../core/outcome-words.ts'
|
|
8
12
|
|
|
9
|
-
export function checksPanel(root: string): TuiPanelDef {
|
|
13
|
+
export function checksPanel(root: string, config?: RepoConfig): TuiPanelDef {
|
|
10
14
|
let rows: TuiRow[] = []
|
|
11
15
|
return {
|
|
12
16
|
id: 'checks',
|
|
13
|
-
title: '
|
|
17
|
+
title: 'checks',
|
|
14
18
|
rows: () => rows,
|
|
15
19
|
refresh: async (update) => {
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
const c = config ?? (await loadRepoConfig(root))
|
|
21
|
+
// the checks the HEALTH pass asks for: everything that is not a
|
|
22
|
+
// build step, which is the panel beside this one
|
|
23
|
+
rows = (await gateInventory(root, c))
|
|
24
|
+
.filter((r) => !r.build)
|
|
25
|
+
.map((r): TuiRow => ({ key: r.decl.id, label: r.decl.id, status: 'pending', detail: [r.description] }))
|
|
18
26
|
const byKey = new Map(rows.map((r, i) => [r.key, i]))
|
|
19
27
|
update()
|
|
20
28
|
await runCheck(root, {
|
|
21
29
|
status: false,
|
|
22
30
|
print: () => {},
|
|
23
|
-
|
|
31
|
+
onCheck: (e) => {
|
|
24
32
|
const row: TuiRow = {
|
|
25
|
-
key: name,
|
|
26
|
-
label: name,
|
|
27
|
-
status:
|
|
28
|
-
...(
|
|
29
|
-
detail:
|
|
33
|
+
key: e.name,
|
|
34
|
+
label: e.name,
|
|
35
|
+
status: e.status,
|
|
36
|
+
...(e.replayed ? { hint: CACHED } : {}),
|
|
37
|
+
detail: e.lines.map((l) => formatLine(l, false)),
|
|
30
38
|
}
|
|
31
|
-
const i = byKey.get(name)
|
|
32
|
-
if (i == null) { byKey.set(name, rows.length); rows.push(row) }
|
|
39
|
+
const i = byKey.get(e.name)
|
|
40
|
+
if (i == null) { byKey.set(e.name, rows.length); rows.push(row) }
|
|
33
41
|
else rows[i] = row
|
|
34
42
|
update()
|
|
35
43
|
},
|
package/src/tui/run-tui.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { render } from 'ink'
|
|
|
6
6
|
import { configure } from 'mobx'
|
|
7
7
|
import { loadRepoConfig } from '../repo-config.ts'
|
|
8
8
|
import { checksPanel } from './checks-panel.ts'
|
|
9
|
-
import {
|
|
9
|
+
import { buildStepsPanel } from './build-steps-panel.ts'
|
|
10
10
|
import { TuiApp } from './tui-app.tsx'
|
|
11
11
|
import { TuiStore } from './tui-store.ts'
|
|
12
12
|
|
|
@@ -16,7 +16,7 @@ export async function runTui(root: string): Promise<number> {
|
|
|
16
16
|
// only signal. Read-warning flags stay off: useInput handler reads are
|
|
17
17
|
// legitimately untracked and would false-positive.
|
|
18
18
|
configure({ enforceActions: 'always' })
|
|
19
|
-
const store = new TuiStore(c.name, [
|
|
19
|
+
const store = new TuiStore(c.name, [buildStepsPanel(root, c), checksPanel(root, c), ...(c.tui?.panels ?? [])])
|
|
20
20
|
store.refresh() // instant static paint; results stream in
|
|
21
21
|
const app = render(React.createElement(TuiApp, { store }))
|
|
22
22
|
await app.waitUntilExit()
|
package/src/tui/tui-panel.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// TUI panel contract — render-free and mobx-free on purpose: a panel is
|
|
2
2
|
// DATA (rows + wire-format detail lines), the generic view (tui-app.tsx)
|
|
3
|
-
// owns all ink. Built-ins:
|
|
4
|
-
// (
|
|
3
|
+
// owns all ink. Built-ins: build-steps-panel.ts (the ci gate) + checks-panel.ts
|
|
4
|
+
// (the gate's build steps, then every other check); a repo injects extras via
|
|
5
5
|
// repo.config `tui: { panels: [...] }`.
|
|
6
6
|
|
|
7
7
|
export type TuiRowStatus = 'pass' | 'fail' | 'warn' | 'skip' | 'pending' | 'info'
|
|
@@ -21,14 +21,15 @@ export type TuiRow = {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export type TuiPanelDef = {
|
|
24
|
+
/** stable panel id, used for the tab order and the key binding */
|
|
24
25
|
id: string
|
|
25
26
|
/** tab label */
|
|
26
27
|
title: string
|
|
27
|
-
/** shown green when rows() is empty after refresh
|
|
28
|
+
/** shown green when rows() is empty after refresh, as in "no open asks" */
|
|
28
29
|
emptyText?: string
|
|
29
30
|
/** current rows — the store re-reads after every update() */
|
|
30
31
|
rows: () => TuiRow[]
|
|
31
|
-
/** (re)load data
|
|
32
|
+
/** (re)load data. Call update() after each mutation so the view repaints.
|
|
32
33
|
* The spinner runs until every panel's returned promise settled. */
|
|
33
34
|
refresh: (update: () => void) => void | Promise<void>
|
|
34
35
|
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// one hue per node, so linked pages share a colour family and distant ones
|
|
2
|
+
// differ. a spectral layout on the colour wheel: the two smoothest eigenvectors
|
|
3
|
+
// of the normalized link graph (hub weight damped, so an index linking
|
|
4
|
+
// everything cannot pull every node to one colour) place each node on a plane,
|
|
5
|
+
// its angle is its hue. then hues are rank equalized: the order around the
|
|
6
|
+
// wheel survives, the whole wheel is used. starts are hashed from ids, so a
|
|
7
|
+
// reload keeps every colour.
|
|
8
|
+
import type { MdGraph } from './md-graph.ts'
|
|
9
|
+
|
|
10
|
+
const ROUNDS = 300
|
|
11
|
+
/** an @ edge binds two pages harder than a lazy ref */
|
|
12
|
+
const EAGER_WEIGHT = 3
|
|
13
|
+
|
|
14
|
+
/** fnv-1a, 32 bit: a stable pseudo random start per id */
|
|
15
|
+
export function hashUnit(s: string): number {
|
|
16
|
+
let h = 0x811c9dc5
|
|
17
|
+
for (let i = 0; i < s.length; i++) h = Math.imul(h ^ s.charCodeAt(i), 0x01000193)
|
|
18
|
+
return (h >>> 0) / 2 ** 32
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** hue in degrees [0, 360) per node id */
|
|
22
|
+
export function nodeHues(g: MdGraph): Record<string, number> {
|
|
23
|
+
const ids = g.nodes.map((n) => n.id)
|
|
24
|
+
const index = new Map(ids.map((id, i) => [id, i]))
|
|
25
|
+
const deg = new Array<number>(ids.length).fill(0)
|
|
26
|
+
const links: { a: number; b: number; w: number }[] = []
|
|
27
|
+
for (const e of g.edges) {
|
|
28
|
+
const a = index.get(e.from), b = index.get(e.to)
|
|
29
|
+
if (a == null || b == null || a === b) continue
|
|
30
|
+
const w = e.eager ? EAGER_WEIGHT : 1
|
|
31
|
+
links.push({ a, b, w })
|
|
32
|
+
deg[a]! += w
|
|
33
|
+
deg[b]! += w
|
|
34
|
+
}
|
|
35
|
+
// power iteration on (I + D^-1/2 W D^-1/2) / 2, deflated against its top
|
|
36
|
+
// eigenvector sqrt(deg): what survives are the two smoothest directions
|
|
37
|
+
const top = deg.map((d) => Math.sqrt(d))
|
|
38
|
+
const topNorm = Math.hypot(...top) || 1
|
|
39
|
+
const mul = (v: number[]): number[] => {
|
|
40
|
+
const out = v.map((x) => x / 2)
|
|
41
|
+
for (const { a, b, w } of links) {
|
|
42
|
+
const k = w / Math.sqrt(deg[a]! * deg[b]!) / 2
|
|
43
|
+
out[a]! += v[b]! * k
|
|
44
|
+
out[b]! += v[a]! * k
|
|
45
|
+
}
|
|
46
|
+
return out
|
|
47
|
+
}
|
|
48
|
+
const dot = (u: number[], v: number[]) => u.reduce((s, x, i) => s + x * v[i]!, 0)
|
|
49
|
+
const orth = (v: number[], against: number[][]) => {
|
|
50
|
+
for (const u of against) { const k = dot(v, u); v = v.map((x, i) => x - k * u[i]!) }
|
|
51
|
+
const len = Math.hypot(...v) || 1
|
|
52
|
+
return v.map((x) => x / len)
|
|
53
|
+
}
|
|
54
|
+
const unitTop = top.map((x) => x / topNorm)
|
|
55
|
+
let u = ids.map((id) => hashUnit(id) - 0.5)
|
|
56
|
+
let v = ids.map((id) => hashUnit(`${id}#`) - 0.5)
|
|
57
|
+
for (let r = 0; r < ROUNDS; r++) {
|
|
58
|
+
u = orth(mul(u), [unitTop])
|
|
59
|
+
v = orth(mul(v), [unitTop, u])
|
|
60
|
+
}
|
|
61
|
+
// a node with no link sits at the origin, its hashed start gives its angle
|
|
62
|
+
const angle = ids.map((id, i) =>
|
|
63
|
+
deg[i] === 0 ? hashUnit(id) * 2 * Math.PI : Math.atan2(v[i]! / Math.sqrt(deg[i]!), u[i]! / Math.sqrt(deg[i]!)))
|
|
64
|
+
const order = ids.map((_, i) => i).sort((i, j) => angle[i]! - angle[j]!)
|
|
65
|
+
const out: Record<string, number> = {}
|
|
66
|
+
order.forEach((i, rank) => { out[ids[i]!] = (rank * 360) / ids.length })
|
|
67
|
+
return out
|
|
68
|
+
}
|
package/src/wiki/graph-mount.ts
CHANGED
|
@@ -1,21 +1,19 @@
|
|
|
1
|
-
// one MOUNTED md graph, as the hub serves it and as the
|
|
1
|
+
// one MOUNTED md wiki graph, as the hub serves it and as the wikis page shows
|
|
2
2
|
// it. ONE list per repo (serve/run-serve builds it): the route, the card and
|
|
3
3
|
// the lint verdict all read the same entries, so a card can never link at a
|
|
4
4
|
// route that is not served.
|
|
5
5
|
import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
6
6
|
|
|
7
7
|
export type GraphMount = {
|
|
8
|
-
/** route + card id: /graph/<id>, instance
|
|
8
|
+
/** route + card id: /graph/<id>, instance wiki:<id> */
|
|
9
9
|
id: string
|
|
10
|
-
/**
|
|
10
|
+
/** its tab on the wikis page and its tool label, e.g. `this repo's wiki`, `<repo> wiki`, `agent/` */
|
|
11
11
|
label: string
|
|
12
12
|
/** graph page title */
|
|
13
13
|
title: string
|
|
14
14
|
load: () => MdGraph
|
|
15
|
-
/**
|
|
16
|
-
*
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
* graph carries no verdict here */
|
|
20
|
-
note?: string
|
|
15
|
+
/** what judges this graph: the built-in md lint over a dir, the repo's own
|
|
16
|
+
* checks by id (their last verdicts), or null for a wiki living in another
|
|
17
|
+
* repo, which the card links and never judges */
|
|
18
|
+
lint: { root: string; index?: string } | { checks: string[] } | null
|
|
21
19
|
}
|
package/src/wiki/graph-page.ts
CHANGED
|
@@ -1,19 +1,23 @@
|
|
|
1
1
|
// Self-contained HTML: a force-directed canvas graph (no deps, no CDN).
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// Balls sized by hops from the index and coloured by link neighbourhood
|
|
3
|
+
// (graph-colors.ts), eager layer ringed. Directed solid edges with arrowheads,
|
|
4
|
+
// a thick purple one = eager auto-include, a thin grey one = lazy ref. Drag
|
|
5
|
+
// balls, drag background to pan, wheel to zoom, hover to focus a node's links.
|
|
6
|
+
import { nodeHues } from 'rvlib-shipkit/src/wiki/graph-colors.ts'
|
|
7
|
+
import { SAVE_AFTER_MS, saveOnceAfter, type Seed } from 'rvlib-shipkit/src/wiki/graph-positions.ts'
|
|
5
8
|
import type { MdGraph } from 'rvlib-shipkit/src/wiki/md-graph.ts'
|
|
6
9
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
export function graphHtml(g: MdGraph, opts?: { title?: string }): string {
|
|
10
|
+
/** `seed` = where nodes start (graph-positions.ts). `saveUrl` = where the page
|
|
11
|
+
* POSTs its node positions once per display; absent = it never does */
|
|
12
|
+
export function graphHtml(g: MdGraph, opts?: { title?: string; seed?: Seed; saveUrl?: string }): string {
|
|
10
13
|
// arbitrary wikis can hold hostile-looking titles/labels — keep the page unbreakable
|
|
11
14
|
const title = (opts?.title ?? 'wiki graph').replace(/&/g, '&').replace(/</g, '<')
|
|
12
|
-
const
|
|
13
|
-
const color = (grp: string | null) => PALETTE[groups.indexOf(grp ?? '') % PALETTE.length]!
|
|
15
|
+
const hues = nodeHues(g)
|
|
14
16
|
const data = {
|
|
15
|
-
nodes: g.nodes.map((n) => ({ ...n, color:
|
|
17
|
+
nodes: g.nodes.map((n) => ({ ...n, color: `oklch(0.74 0.15 ${Math.round(hues[n.id]!)})`, ...opts?.seed?.start[n.id] })),
|
|
16
18
|
edges: g.edges,
|
|
19
|
+
alpha: opts?.seed?.alpha ?? 1,
|
|
20
|
+
saveUrl: opts?.saveUrl ?? null,
|
|
17
21
|
}
|
|
18
22
|
const dataJson = JSON.stringify(data).replaceAll('<', '\\u003c')
|
|
19
23
|
return `<!doctype html>
|
|
@@ -33,7 +37,7 @@ export function graphHtml(g: MdGraph, opts?: { title?: string }): string {
|
|
|
33
37
|
</style>
|
|
34
38
|
</head>
|
|
35
39
|
<body>
|
|
36
|
-
<header><b>${title}</b><span>
|
|
40
|
+
<header><b>${title}</b><span>thick purple → @ co-load · thin → lazy ref · ringed = eager layer · number = hops from index, bigger = closer · colour = link neighbourhood · drag / pan / zoom · reload = regenerate</span></header>
|
|
37
41
|
<canvas id="c"></canvas>
|
|
38
42
|
<script>
|
|
39
43
|
const DATA = ${dataJson}
|
|
@@ -42,14 +46,19 @@ const ctx = canvas.getContext('2d')
|
|
|
42
46
|
const dark = matchMedia('(prefers-color-scheme: dark)').matches
|
|
43
47
|
const N = DATA.nodes, byId = new Map(N.map(n => [n.id, n]))
|
|
44
48
|
const E = DATA.edges.filter(e => byId.has(e.from) && byId.has(e.to))
|
|
49
|
+
const SIZE_BY_DEPTH = [3, 2, 1.5]
|
|
45
50
|
const deg = new Map()
|
|
46
51
|
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
52
|
for (const n of N) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
53
|
+
if (n.x == null || n.y == null) {
|
|
54
|
+
const a = Math.random() * Math.PI * 2, d = 120 + Math.random() * 240
|
|
55
|
+
n.x = Math.cos(a) * d; n.y = Math.sin(a) * d
|
|
56
|
+
}
|
|
57
|
+
n.vx = 0; n.vy = 0
|
|
58
|
+
// hops from the index set the size: 0 → x3, 1 → x2, 2 → x1.5, deeper or unreachable → x1
|
|
59
|
+
n.r = 6 * (SIZE_BY_DEPTH[n.depth] ?? 1) + Math.min(4, Math.sqrt(deg.get(n.id) ?? 0))
|
|
51
60
|
}
|
|
52
|
-
let scale = 1, ox = 0, oy = 0, alpha =
|
|
61
|
+
let scale = 1, ox = 0, oy = 0, alpha = DATA.alpha, hover = null, drag = null, panning = null
|
|
53
62
|
function resize() {
|
|
54
63
|
const dpr = devicePixelRatio || 1
|
|
55
64
|
canvas.width = innerWidth * dpr; canvas.height = innerHeight * dpr
|
|
@@ -66,8 +75,9 @@ function step() {
|
|
|
66
75
|
const a = N[i], b = N[j]
|
|
67
76
|
let dx = a.x - b.x, dy = a.y - b.y
|
|
68
77
|
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
78
|
const d = Math.sqrt(d2)
|
|
79
|
+
// plus a hard push while two balls overlap, big balls need room
|
|
80
|
+
const f = 4000 / d2 + Math.max(0, a.r + b.r + 24 - d) * 0.3
|
|
71
81
|
dx /= d; dy /= d
|
|
72
82
|
a.vx += dx * f; a.vy += dy * f; b.vx -= dx * f; b.vy -= dy * f
|
|
73
83
|
}
|
|
@@ -75,12 +85,12 @@ function step() {
|
|
|
75
85
|
const a = byId.get(e.from), b = byId.get(e.to)
|
|
76
86
|
const dx = b.x - a.x, dy = b.y - a.y
|
|
77
87
|
const d = Math.sqrt(dx * dx + dy * dy) || 1
|
|
78
|
-
const rest = e.eager ?
|
|
88
|
+
const rest = (e.eager ? 90 : 180) + a.r + b.r
|
|
79
89
|
const f = (d - rest) * (e.eager ? 0.02 : 0.008)
|
|
80
90
|
a.vx += dx / d * f; a.vy += dy / d * f; b.vx -= dx / d * f; b.vy -= dy / d * f
|
|
81
91
|
}
|
|
82
92
|
for (const n of N) {
|
|
83
|
-
n.vx -= n.x * 0.
|
|
93
|
+
n.vx -= n.x * 0.0008; n.vy -= n.y * 0.0008
|
|
84
94
|
if (n === drag) { n.vx = 0; n.vy = 0; continue }
|
|
85
95
|
n.x += n.vx * alpha; n.y += n.vy * alpha
|
|
86
96
|
n.vx *= 0.85; n.vy *= 0.85
|
|
@@ -95,15 +105,13 @@ function draw() {
|
|
|
95
105
|
for (const e of E) {
|
|
96
106
|
const a = byId.get(e.from), b = byId.get(e.to)
|
|
97
107
|
const lit = focus == null || e.from === hover.id || e.to === hover.id
|
|
98
|
-
ctx.globalAlpha = lit ? (e.eager ? 1 : 0.
|
|
108
|
+
ctx.globalAlpha = lit ? (e.eager ? 1 : 0.45) : 0.06
|
|
99
109
|
ctx.strokeStyle = e.eager ? '#8b5cf6' : (dark ? '#8a8a95' : '#777')
|
|
100
|
-
ctx.lineWidth = (e.eager ?
|
|
101
|
-
ctx.setLineDash(e.eager ? [] : [4 / scale, 4 / scale])
|
|
110
|
+
ctx.lineWidth = (e.eager ? 5 : 1.2) / scale
|
|
102
111
|
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 *
|
|
112
|
+
const gap = b.r + (b.eager ? 6 : 3) / scale, tx = b.x - dx / d * gap, ty = b.y - dy / d * gap
|
|
104
113
|
ctx.beginPath(); ctx.moveTo(a.x + dx / d * a.r, a.y + dy / d * a.r); ctx.lineTo(tx, ty); ctx.stroke()
|
|
105
|
-
|
|
106
|
-
const ah = 5 / scale
|
|
114
|
+
const ah = (e.eager ? 7 : 4.5) / scale
|
|
107
115
|
ctx.fillStyle = ctx.strokeStyle
|
|
108
116
|
ctx.beginPath()
|
|
109
117
|
ctx.moveTo(tx, ty)
|
|
@@ -116,9 +124,9 @@ function draw() {
|
|
|
116
124
|
ctx.globalAlpha = lit ? 1 : 0.15
|
|
117
125
|
ctx.fillStyle = n.color
|
|
118
126
|
ctx.beginPath(); ctx.arc(n.x, n.y, n.r, 0, 7); ctx.fill()
|
|
119
|
-
if (n.eager) { ctx.strokeStyle = '#8b5cf6'; ctx.lineWidth =
|
|
127
|
+
if (n.eager) { ctx.strokeStyle = '#8b5cf6'; ctx.lineWidth = 3 / scale; ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 3 / scale, 0, 7); ctx.stroke() }
|
|
120
128
|
if (n.depth != null && scale * n.r > 3) {
|
|
121
|
-
ctx.fillStyle = '#
|
|
129
|
+
ctx.fillStyle = '#16161c'
|
|
122
130
|
ctx.font = '600 ' + n.r * 1.1 + 'px system-ui'
|
|
123
131
|
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'
|
|
124
132
|
ctx.fillText(n.depth, n.x, n.y + n.r * 0.08)
|
|
@@ -138,6 +146,15 @@ function draw() {
|
|
|
138
146
|
function loop() { step(); draw(); requestAnimationFrame(loop) }
|
|
139
147
|
loop()
|
|
140
148
|
|
|
149
|
+
const saveOnceAfter = ${saveOnceAfter.toString()}
|
|
150
|
+
if (DATA.saveUrl != null) saveOnceAfter(${SAVE_AFTER_MS}, setTimeout, () => {
|
|
151
|
+
const pos = {}
|
|
152
|
+
for (const n of N) pos[n.id] = [Math.round(n.x), Math.round(n.y)]
|
|
153
|
+
fetch(DATA.saveUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(pos) })
|
|
154
|
+
.then(r => { if (!r.ok) console.warn('graph positions not saved: ' + r.status) })
|
|
155
|
+
.catch(e => console.warn('graph positions not saved: ' + e.message))
|
|
156
|
+
})()
|
|
157
|
+
|
|
141
158
|
const world = (px, py) => [(px - ox) / scale, (py - oy) / scale]
|
|
142
159
|
const nodeAt = (px, py) => {
|
|
143
160
|
const [x, y] = world(px, py)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// where a graph's nodes sat last time, so the next display starts close to it.
|
|
2
|
+
// one json file per graph per checkout, `<git-common-dir>/shipkit/graph-positions/<id>.json`,
|
|
3
|
+
// never tracked. a start, never a pin: the layout still runs from there.
|
|
4
|
+
import { mkdirSync, readFileSync } from 'fs'
|
|
5
|
+
import { dirname, join } from 'path'
|
|
6
|
+
import { atomicWrite } from '../core/locked-write.ts'
|
|
7
|
+
import { shipkitRoot } from '../ledger/store.ts'
|
|
8
|
+
import type { MdGraph } from './md-graph.ts'
|
|
9
|
+
|
|
10
|
+
export type Positions = Record<string, [number, number]>
|
|
11
|
+
|
|
12
|
+
/** how long a display lays out before the page sends its positions, once */
|
|
13
|
+
export const SAVE_AFTER_MS = 3000
|
|
14
|
+
/** a POST body past this is refused: a few thousand nodes fit well under it */
|
|
15
|
+
export const MAX_POSITIONS_BYTES = 512 * 1024
|
|
16
|
+
/** from this share of seeded nodes on, the layout starts calm */
|
|
17
|
+
const CALM_FROM_SHARE = 0.5
|
|
18
|
+
const CALM_ALPHA = 0.25
|
|
19
|
+
const JITTER = 12
|
|
20
|
+
|
|
21
|
+
const safeId = (id: string): string => id.replace(/[^a-zA-Z0-9._-]/g, '_')
|
|
22
|
+
|
|
23
|
+
export const positionsFile = (root: string, graphId: string): string => join(shipkitRoot(root), 'graph-positions', `${safeId(graphId)}.json`)
|
|
24
|
+
|
|
25
|
+
const isPoint = (p: unknown): p is [number, number] =>
|
|
26
|
+
Array.isArray(p) && p.length === 2 && Number.isFinite(p[0]) && Number.isFinite(p[1])
|
|
27
|
+
|
|
28
|
+
/** keeps the well formed points of the ids in `keep`, rounded. anything else
|
|
29
|
+
* (a vanished node, a malformed entry) is dropped, never an error */
|
|
30
|
+
export function cleanPositions(raw: unknown, keep: ReadonlySet<string>): Positions {
|
|
31
|
+
const out: Positions = {}
|
|
32
|
+
if (raw == null || typeof raw !== 'object' || Array.isArray(raw)) return out
|
|
33
|
+
for (const [id, p] of Object.entries(raw as Record<string, unknown>)) {
|
|
34
|
+
if (keep.has(id) && isPoint(p)) out[id] = [Math.round(p[0]), Math.round(p[1])]
|
|
35
|
+
}
|
|
36
|
+
return out
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** a missing or unreadable file is no memory, and the graph lays out as new */
|
|
40
|
+
export function readPositions(file: string): Positions {
|
|
41
|
+
try {
|
|
42
|
+
const doc = JSON.parse(readFileSync(file, 'utf8')) as { positions?: unknown }
|
|
43
|
+
const raw = doc.positions
|
|
44
|
+
if (raw == null || typeof raw !== 'object') return {}
|
|
45
|
+
return cleanPositions(raw, new Set(Object.keys(raw)))
|
|
46
|
+
} catch {
|
|
47
|
+
return {}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function writePositions(file: string, positions: Positions): void {
|
|
52
|
+
mkdirSync(dirname(file), { recursive: true })
|
|
53
|
+
atomicWrite(file, `${JSON.stringify({ v: 1, positions }, null, 1)}\n`)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export type Seed = { start: Record<string, { x: number; y: number }>; alpha: number }
|
|
57
|
+
|
|
58
|
+
/** the starting point of every node that has one: its saved position, else
|
|
59
|
+
* the mean of its saved neighbours (links either way) plus a small jitter.
|
|
60
|
+
* a node with neither is absent, and the page places it as a new node */
|
|
61
|
+
export function seedPositions(g: MdGraph, saved: Positions, rand: () => number = Math.random): Seed {
|
|
62
|
+
const start: Seed['start'] = {}
|
|
63
|
+
for (const n of g.nodes) {
|
|
64
|
+
const p = saved[n.id]
|
|
65
|
+
if (p != null) start[n.id] = { x: p[0], y: p[1] }
|
|
66
|
+
}
|
|
67
|
+
for (const n of g.nodes) {
|
|
68
|
+
if (start[n.id] != null) continue
|
|
69
|
+
let sx = 0, sy = 0, k = 0
|
|
70
|
+
for (const e of g.edges) {
|
|
71
|
+
const other = e.from === n.id ? e.to : e.to === n.id ? e.from : null
|
|
72
|
+
const p = other == null ? undefined : saved[other]
|
|
73
|
+
if (p != null) { sx += p[0]; sy += p[1]; k++ }
|
|
74
|
+
}
|
|
75
|
+
if (k > 0) start[n.id] = { x: sx / k + (rand() - 0.5) * 2 * JITTER, y: sy / k + (rand() - 0.5) * 2 * JITTER }
|
|
76
|
+
}
|
|
77
|
+
const seeded = g.nodes.filter((n) => saved[n.id] != null).length
|
|
78
|
+
return { start, alpha: g.nodes.length > 0 && seeded / g.nodes.length >= CALM_FROM_SHARE ? CALM_ALPHA : 1 }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** arms ONE delayed save per display: every later call is a no op. inlined
|
|
82
|
+
* into the graph page by its source text, so it must stay self contained */
|
|
83
|
+
export function saveOnceAfter(ms: number, later: (fn: () => void, ms: number) => unknown, save: () => void): () => void {
|
|
84
|
+
let armed = false
|
|
85
|
+
return () => {
|
|
86
|
+
if (armed) return
|
|
87
|
+
armed = true
|
|
88
|
+
later(save, ms)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** the POST handler both servers mount: bounded body, ids checked against the
|
|
93
|
+
* graph as it is now, so a vanished node leaves the file on this save */
|
|
94
|
+
export async function acceptPositions(req: Request, file: string, g: () => MdGraph): Promise<Response> {
|
|
95
|
+
if (req.method !== 'POST') return new Response('POST only', { status: 405 })
|
|
96
|
+
const len = Number(req.headers.get('content-length') ?? 0)
|
|
97
|
+
if (len > MAX_POSITIONS_BYTES) return new Response('too large', { status: 413 })
|
|
98
|
+
const text = await req.text()
|
|
99
|
+
if (text.length > MAX_POSITIONS_BYTES) return new Response('too large', { status: 413 })
|
|
100
|
+
let body: unknown
|
|
101
|
+
try { body = JSON.parse(text) } catch { return new Response('not json', { status: 400 }) }
|
|
102
|
+
const positions = cleanPositions(body, new Set(g().nodes.map((n) => n.id)))
|
|
103
|
+
writePositions(file, positions)
|
|
104
|
+
return new Response(null, { status: 204 })
|
|
105
|
+
}
|
package/src/wiki/run-wiki.ts
CHANGED
|
@@ -28,7 +28,7 @@ export async function runWikiServe(o: WikiScanOpts & { port: number; title?: str
|
|
|
28
28
|
const rel = relative(process.cwd(), cfg.root)
|
|
29
29
|
const title = o.title ?? (rel === '' ? basename(cfg.root) : `${basename(process.cwd())} ${rel}`)
|
|
30
30
|
try {
|
|
31
|
-
serveMdGraph({ port: o.port, title, load: () => scanMdGraph(cfg) })
|
|
31
|
+
serveMdGraph({ port: o.port, title, root: cfg.root, load: () => scanMdGraph(cfg) })
|
|
32
32
|
say(`wiki graph → http://localhost:${o.port} (open in browser, or VSCode "Simple Browser: Show")`)
|
|
33
33
|
// keep the server alive — the command must not resolve
|
|
34
34
|
await new Promise(() => {})
|