rvlib-shipkit 1.3.0 → 1.4.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 +37 -0
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +4 -2
- package/src/assets/kits/identity-kit.ts +15 -13
- package/src/assets/run-assets.ts +12 -2
- package/src/check.ts +6 -4
- package/src/checks/SK001-package-json-shape.check.ts +20 -2
- package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
- package/src/checks/SK014-prose-ste.check.ts +16 -6
- package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
- package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
- package/src/checks/SK028-readme.check.ts +6 -2
- package/src/cli/cmd_CrossRepoActivity.ts +4 -1
- package/src/collections/collection-types.ts +15 -0
- package/src/collections/providers.ts +6 -2
- package/src/contracts/apply-contracts.ts +9 -8
- package/src/contracts/contract-types.ts +8 -2
- package/src/contracts/contracts-engine.ts +34 -13
- package/src/contracts/manifests/identity.contract.ts +6 -5
- package/src/contracts/manifests/social.contract.ts +4 -2
- package/src/contracts/manifests/steam.contract.ts +7 -3
- package/src/contracts/web-surface.ts +59 -0
- package/src/core/budget.ts +19 -9
- package/src/core/check-runner.ts +26 -5
- package/src/core/discover-checks.ts +8 -5
- package/src/core/layout.ts +2 -0
- package/src/core/motherhome.ts +88 -66
- package/src/core/run-check.ts +2 -5
- package/src/core/run-ci.ts +3 -3
- package/src/core/wire.ts +38 -9
- package/src/home/activity-shape.ts +4 -1
- package/src/home/activity.ts +22 -15
- package/src/home/gui-app/ShipkitGui.swift +144 -25
- package/src/home/gui-app.ts +93 -30
- package/src/home/home-launchd.ts +10 -2
- package/src/home/home-routes.ts +2 -1
- package/src/home/home-start.ts +33 -5
- package/src/home/machine-stores.ts +4 -2
- package/src/home/open-app.ts +23 -10
- package/src/home/pm-config.ts +15 -11
- package/src/home/pm-inputs.ts +76 -0
- package/src/home/pm.ts +3 -3
- package/src/home/process-rows.ts +23 -6
- package/src/home/resources.ts +67 -15
- package/src/home/run-daemon.ts +6 -1
- package/src/home/run-pm.ts +29 -5
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +12 -2
- package/src/ledger/gate-checks.ts +3 -3
- package/src/ledger/store.ts +11 -0
- package/src/ledger/world-cache.ts +17 -15
- package/src/repo-config.ts +6 -1
- package/src/serve/hub-app/activity-view.tsx +3 -3
- package/src/serve/hub-app/checks-svc.ts +2 -1
- package/src/serve/hub-app/col-tables-view.tsx +124 -0
- package/src/serve/hub-app/col-tables.ts +115 -0
- package/src/serve/hub-app/cols-svc.ts +28 -0
- package/src/serve/hub-app/dock.css +1 -1
- package/src/serve/hub-app/home-st.ts +1 -1
- package/src/serve/hub-app/home-strip.css +32 -17
- package/src/serve/hub-app/home-strip.tsx +99 -89
- package/src/serve/hub-app/hub-app.tsx +7 -1
- package/src/serve/hub-app/hub-types.ts +6 -3
- package/src/serve/hub-app/hub.css +24 -2
- package/src/serve/hub-app/icons.tsx +4 -0
- package/src/serve/hub-app/keys.ts +6 -6
- package/src/serve/hub-app/machine-st.ts +26 -37
- package/src/serve/hub-app/palette-st.ts +5 -5
- package/src/serve/hub-app/palette-view.tsx +1 -1
- package/src/serve/hub-app/project-list.tsx +3 -3
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/timeline-view.tsx +14 -11
- package/src/serve/project-gutter.ts +1 -2
- package/src/serve/warm-cell.ts +49 -16
- package/src/suite/judge-suites.ts +42 -17
- package/src/suite/suite-cli.ts +5 -8
- package/src/wiki/lint-md.ts +2 -0
- package/src/wiki/scan-md.ts +37 -10
|
@@ -56,20 +56,23 @@ export type DiscoveredCheck = {
|
|
|
56
56
|
from: CheckFrom
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
function walk(dir: string, out: string[], suffix = SUFFIX): void {
|
|
59
|
+
function walk(dir: string, out: string[], suffix = SUFFIX, dirs?: string[]): void {
|
|
60
|
+
dirs?.push(dir)
|
|
60
61
|
let entries
|
|
61
62
|
try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
|
|
62
63
|
for (const e of entries) {
|
|
63
64
|
if (e.isDirectory()) {
|
|
64
|
-
if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix)
|
|
65
|
+
if (!IGNORED_DIRS.has(e.name) && !e.name.startsWith('.')) walk(join(dir, e.name), out, suffix, dirs)
|
|
65
66
|
} else if (e.name.endsWith(suffix)) out.push(join(dir, e.name))
|
|
66
67
|
}
|
|
67
68
|
}
|
|
68
69
|
|
|
69
|
-
/** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted
|
|
70
|
-
|
|
70
|
+
/** every `*.shipkit.ts` file under a repo root, by the same walk. Sorted.
|
|
71
|
+
* `dirs` collects every dir the walk read: a file added to one moves its
|
|
72
|
+
* mtime, which is how a reader tells the list moved without walking again */
|
|
73
|
+
export function repoUnitFiles(root: string, dirs?: string[]): string[] {
|
|
71
74
|
const out: string[] = []
|
|
72
|
-
walk(root, out, UNIT_SUFFIX)
|
|
75
|
+
walk(root, out, UNIT_SUFFIX, dirs)
|
|
73
76
|
return out.sort()
|
|
74
77
|
}
|
|
75
78
|
|
package/src/core/layout.ts
CHANGED
|
@@ -32,6 +32,8 @@ export const LAYOUT = {
|
|
|
32
32
|
reviews: sk('reviews'),
|
|
33
33
|
// scratch: plans, logs, leases, detached runs, screenshots
|
|
34
34
|
tmp: sk('tmp'),
|
|
35
|
+
// built app bundles, one per global process opened as an app (home/gui-app.ts)
|
|
36
|
+
wrapper: sk('wrapper'),
|
|
35
37
|
// generated on every run
|
|
36
38
|
status: sk('STATUS.md'),
|
|
37
39
|
statusFull: sk('STATUS.full.md'),
|
package/src/core/motherhome.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
|
-
// Report to the motherhome
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
1
|
+
// Report to the motherhome: a repo's row in the docsBase wiki's
|
|
2
|
+
// `build/shipkit/local-projects.md`, in two halves.
|
|
3
|
+
//
|
|
4
|
+
// - `reportToMotherhome` (every ci/check/init) files the row in the MACHINE
|
|
5
|
+
// DB and nothing else. A gate answers about its own repo, so it never
|
|
6
|
+
// writes, let alone commits, in the docs repo.
|
|
7
|
+
// - `landMotherhome` (the daemon's hourly sweep) renders every filed row into
|
|
8
|
+
// its page and lands the page as ONE commit, at most once per LAND_GAP_MS
|
|
9
|
+
// per page, only when a row's data changed or its last-seen day is older
|
|
10
|
+
// than STALE_DAYS.
|
|
11
|
+
//
|
|
12
|
+
// The lib NEVER creates the page and never reads a relative or URL docsBase:
|
|
13
|
+
// the page is a tracked wiki file, so a real docs checkout has it, and a
|
|
14
|
+
// consumer that merely set docsBase as a fix-pointer prefix must never find
|
|
15
|
+
// surprise files in its docs tree. The page's read-modify-write runs under
|
|
16
|
+
// the shared file lock (core/locked-write). Rows whose repo path no longer
|
|
17
|
+
// exists are pruned from the page and from the machine db.
|
|
13
18
|
import { existsSync, readFileSync } from 'fs'
|
|
14
19
|
import { homedir } from 'os'
|
|
15
20
|
import { join, resolve } from 'path'
|
|
@@ -17,46 +22,31 @@ import type { RepoConfig } from '../repo-config.ts'
|
|
|
17
22
|
import { atomicWrite, withFileLock } from './locked-write.ts'
|
|
18
23
|
import { isLinkedWorktree } from './git-root.ts'
|
|
19
24
|
import { machineCommit } from './machine-commit.ts'
|
|
25
|
+
import { machineDelete, machineGet, machineList, machineSet } from './machine-db.ts'
|
|
20
26
|
import { LIB_VERSION } from './lib-version.ts'
|
|
21
27
|
|
|
22
28
|
const BEGIN = '<!-- >>> shipkit local-projects — machine-written, edit outside the markers -->'
|
|
23
29
|
const END = '<!-- <<< shipkit local-projects -->'
|
|
24
30
|
const STALE_DAYS = 7
|
|
31
|
+
/** the longest the page waits for a changed row: one commit a day at most */
|
|
32
|
+
export const LAND_GAP_MS = 24 * 60 * 60 * 1000
|
|
33
|
+
/** a row filed in the machine db, by its repo's path */
|
|
34
|
+
const ROW = 'motherhome:'
|
|
35
|
+
/** when a page last landed, by its path */
|
|
36
|
+
const LANDED = 'motherhome-landed:'
|
|
25
37
|
|
|
26
38
|
type Row = { name: string; root: string; version: string; mode: string; seen: string }
|
|
39
|
+
type Filed = Row & { page: string }
|
|
27
40
|
|
|
28
41
|
const rowLine = (r: Row): string => `- ${r.name} — \`${r.root}\` — shipkit ${r.version} (${r.mode}) — last seen ${r.seen}`
|
|
29
42
|
|
|
30
|
-
/**
|
|
31
|
-
* Commit the row we just wrote, and ONLY that file.
|
|
32
|
-
*
|
|
33
|
-
* This page is machine written and rewrites whenever a repo's shipkit version
|
|
34
|
-
* or consumption mode changes, so left alone it dirties the docs repo from a
|
|
35
|
-
* gate run nobody associates with it, and the dirt then rides into an unrelated
|
|
36
|
-
* commit or blocks a release. The row is not worth a human's attention in
|
|
37
|
-
* either direction, so the machine that writes it also lands it.
|
|
38
|
-
*
|
|
39
|
-
* The scoping, the refusals and the escape hatch live in `core/machine-commit`,
|
|
40
|
-
* which every machine written path commits through. This one fires only when
|
|
41
|
-
* the row's DATA changed, not on every gate, so the hooks it runs are affordable.
|
|
42
|
-
*/
|
|
43
|
-
function autoCommitRow(page: string, name: string): string {
|
|
44
|
-
const landed = machineCommit({
|
|
45
|
-
anchor: resolve(page, '..'),
|
|
46
|
-
message: `chore(machine): refresh local-projects row (${name})`,
|
|
47
|
-
paths: [page],
|
|
48
|
-
})
|
|
49
|
-
return landed ? ' + committed' : ''
|
|
50
|
-
}
|
|
51
|
-
|
|
52
43
|
const parseRow = (line: string): Row | null => {
|
|
53
44
|
const m = line.match(/^- (.+?) — `(.+?)` — shipkit (\S+) \((.+?)\) — last seen (\d{4}-\d{2}-\d{2})$/)
|
|
54
45
|
return m == null ? null : { name: m[1]!, root: m[2]!, version: m[3]!, mode: m[4]!, seen: m[5]! }
|
|
55
46
|
}
|
|
56
47
|
|
|
57
|
-
|
|
58
|
-
//
|
|
59
|
-
// read failure must never be rendered as the fact 'path')
|
|
48
|
+
// how this repo consumes the lib: `?` on an unreadable package.json (a read
|
|
49
|
+
// failure must never be rendered as the fact 'path')
|
|
60
50
|
function consumptionMode(root: string): string {
|
|
61
51
|
try {
|
|
62
52
|
const pkg = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
|
|
@@ -70,47 +60,79 @@ function consumptionMode(root: string): string {
|
|
|
70
60
|
|
|
71
61
|
export type MotherhomeOutcome = { wrote: boolean; note?: string }
|
|
72
62
|
|
|
63
|
+
/** file this repo's row in the machine db. `wrote` says the filed row changed */
|
|
73
64
|
export function reportToMotherhome(root: string, config: RepoConfig, today: string = new Date().toISOString().slice(0, 10)): MotherhomeOutcome {
|
|
74
65
|
try {
|
|
75
66
|
if (config.docsBase == null) return { wrote: false }
|
|
76
67
|
const base = config.docsBase.startsWith('~/') ? join(homedir(), config.docsBase.slice(2)) : config.docsBase
|
|
77
|
-
// absolute local paths only
|
|
68
|
+
// absolute local paths only: a relative docsBase would resolve
|
|
78
69
|
// against CWD, a URL is a display prefix
|
|
79
70
|
if (!base.startsWith('/')) return { wrote: false }
|
|
80
71
|
const page = join(base, 'build/shipkit/local-projects.md')
|
|
81
72
|
if (!existsSync(page)) return { wrote: false }
|
|
82
|
-
// a
|
|
83
|
-
//
|
|
84
|
-
// side checkout, and the write lands as unexplained dirt in a tree
|
|
85
|
-
// somebody else is working in. The worktree's own runs are covered
|
|
86
|
-
// when its branch merges.
|
|
73
|
+
// a linked worktree is a temporary side checkout: its runs are
|
|
74
|
+
// covered when its branch merges
|
|
87
75
|
if (isLinkedWorktree(root)) return { wrote: false, note: `motherhome: skipped (${config.name} is a linked worktree — its row belongs to the main checkout)` }
|
|
88
|
-
|
|
89
76
|
const mode = consumptionMode(root)
|
|
90
77
|
// source-consumed code is not the published release its version
|
|
91
|
-
// string names
|
|
78
|
+
// string names: disclose it
|
|
92
79
|
const version = mode === 'npm' ? LIB_VERSION : `${LIB_VERSION}+src`
|
|
93
|
-
const me:
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
const b = lines.indexOf(BEGIN)
|
|
98
|
-
const e = lines.indexOf(END)
|
|
99
|
-
if (b === -1 || e === -1 || e < b) return { wrote: false, note: `WARN motherhome upsert skipped — markers missing/corrupted in ${page}` }
|
|
100
|
-
const rows = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
|
|
101
|
-
const cur = rows.find((row) => row.root === me.root)
|
|
102
|
-
const dataSame = cur != null && cur.name === me.name && cur.version === me.version && cur.mode === me.mode
|
|
103
|
-
const fresh = cur != null && (Date.parse(today) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
|
|
104
|
-
if (dataSame && fresh) return { wrote: false }
|
|
105
|
-
// prune rows for repos that no longer exist at their recorded path
|
|
106
|
-
const next = [...rows.filter((row) => row.root !== me.root && existsSync(row.root)), me].sort((a, z) => a.name.localeCompare(z.name))
|
|
107
|
-
atomicWrite(page, [...lines.slice(0, b + 1), ...next.map(rowLine), ...lines.slice(e)].join('\n'))
|
|
108
|
-
const committed = autoCommitRow(page, me.name)
|
|
109
|
-
return { wrote: true, note: `motherhome: local-projects.md ${cur == null ? 'row added' : 'row refreshed'} (${me.name})${committed}` }
|
|
110
|
-
})
|
|
111
|
-
// lock lost to a racing reporter — self-heals on the next run
|
|
112
|
-
return r.ok ? r.value : { wrote: false }
|
|
80
|
+
const me: Filed = { page, name: config.name, root: resolve(root), version, mode, seen: today }
|
|
81
|
+
const key = `${ROW}${me.root}`
|
|
82
|
+
if (JSON.stringify(machineGet<Filed>(key)) === JSON.stringify(me)) return { wrote: false }
|
|
83
|
+
return machineSet(key, me) ? { wrote: true } : { wrote: false, note: 'WARN motherhome row not filed: the machine db could not be written' }
|
|
113
84
|
} catch (err) {
|
|
114
|
-
return { wrote: false, note: `WARN motherhome
|
|
85
|
+
return { wrote: false, note: `WARN motherhome row not filed (${String(err).slice(0, 120)})` }
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** a page's rows once the filed ones are merged in: a filed row replaces the
|
|
90
|
+
* page's only when its data changed or the page's day is STALE_DAYS old,
|
|
91
|
+
* and a row whose repo path is gone is pruned */
|
|
92
|
+
function mergeRows(onPage: Row[], filed: Row[]): Row[] {
|
|
93
|
+
const by = new Map(onPage.map((r) => [r.root, r]))
|
|
94
|
+
for (const f of filed) {
|
|
95
|
+
const cur = by.get(f.root)
|
|
96
|
+
const same = cur != null && cur.name === f.name && cur.version === f.version && cur.mode === f.mode
|
|
97
|
+
const fresh = cur != null && (Date.parse(f.seen) - Date.parse(cur.seen)) / 86_400_000 < STALE_DAYS
|
|
98
|
+
if (!(same && fresh)) by.set(f.root, { name: f.name, root: f.root, version: f.version, mode: f.mode, seen: f.seen })
|
|
99
|
+
}
|
|
100
|
+
return [...by.values()].filter((r) => existsSync(r.root)).sort((a, z) => a.name.localeCompare(z.name) || a.root.localeCompare(z.root))
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** render every filed row into its page and land each changed page as one
|
|
104
|
+
* commit, at most once per LAND_GAP_MS. Returns what to print */
|
|
105
|
+
export function landMotherhome(o: { now?: number; gapMs?: number } = {}): string[] {
|
|
106
|
+
const now = o.now ?? Date.now()
|
|
107
|
+
const notes: string[] = []
|
|
108
|
+
const filed = machineList<Filed>(ROW)
|
|
109
|
+
for (const f of filed) if (!existsSync(f.value.root)) machineDelete(f.name)
|
|
110
|
+
const pages = new Map<string, Row[]>()
|
|
111
|
+
for (const f of filed) if (existsSync(f.value.root)) pages.set(f.value.page, [...(pages.get(f.value.page) ?? []), f.value])
|
|
112
|
+
for (const [page, rows] of pages) {
|
|
113
|
+
try {
|
|
114
|
+
if (!existsSync(page)) continue
|
|
115
|
+
const last = machineGet<number>(`${LANDED}${page}`)
|
|
116
|
+
if (last != null && now - last < (o.gapMs ?? LAND_GAP_MS)) continue
|
|
117
|
+
const r = withFileLock(page, (): boolean => {
|
|
118
|
+
const text = readFileSync(page, 'utf8')
|
|
119
|
+
const lines = text.split('\n')
|
|
120
|
+
const b = lines.indexOf(BEGIN)
|
|
121
|
+
const e = lines.indexOf(END)
|
|
122
|
+
if (b === -1 || e === -1 || e < b) { notes.push(`WARN motherhome: markers missing or out of order in ${page}, rows not written`); return false }
|
|
123
|
+
const onPage = lines.slice(b + 1, e).map(parseRow).filter((row): row is Row => row != null)
|
|
124
|
+
const next = [...lines.slice(0, b + 1), ...mergeRows(onPage, rows).map(rowLine), ...lines.slice(e)].join('\n')
|
|
125
|
+
if (next === text) return false
|
|
126
|
+
atomicWrite(page, next)
|
|
127
|
+
return true
|
|
128
|
+
})
|
|
129
|
+
if (!r.ok || !r.value) continue
|
|
130
|
+
machineSet(`${LANDED}${page}`, now)
|
|
131
|
+
const landed = machineCommit({ anchor: resolve(page, '..'), message: 'chore(machine): refresh local-projects rows', paths: [page] })
|
|
132
|
+
notes.push(`motherhome: ${page} rows refreshed${landed ? ' + committed' : ', not committed'}`)
|
|
133
|
+
} catch (err) {
|
|
134
|
+
notes.push(`WARN motherhome: ${page} not refreshed (${String(err).slice(0, 120)})`)
|
|
135
|
+
}
|
|
115
136
|
}
|
|
137
|
+
return notes
|
|
116
138
|
}
|
package/src/core/run-check.ts
CHANGED
|
@@ -87,11 +87,8 @@ export type CheckOpts = {
|
|
|
87
87
|
/** the gate binary that own ledger is keyed on (default: the one running
|
|
88
88
|
* it). Ignored when `ledger` is passed, which carries its own. */
|
|
89
89
|
cliEntry?: string
|
|
90
|
-
/**
|
|
91
|
-
*
|
|
92
|
-
* `wiki/`, so writing it before the post-run re-hash makes the gate look
|
|
93
|
-
* like an agent editing the tree and throws away every result the run just
|
|
94
|
-
* measured. */
|
|
90
|
+
/** file the motherhome row in the machine db (core/motherhome.ts). runCi
|
|
91
|
+
* passes false and does it ITSELF, once, after its verdict. */
|
|
95
92
|
reportHome?: boolean
|
|
96
93
|
}
|
|
97
94
|
|
package/src/core/run-ci.ts
CHANGED
|
@@ -289,9 +289,9 @@ export async function runCi(root: string, o: CiOpts = {}): Promise<number> {
|
|
|
289
289
|
// a narrowed run asked no question about
|
|
290
290
|
const verdict = code === 0 && !run.interrupted ? verdictLine(run.results, scope.fullGate ? { tier, cache: ledger?.counts ?? null } : null) : null
|
|
291
291
|
if (verdict != null) sink(verdict)
|
|
292
|
-
// motherhome row
|
|
293
|
-
//
|
|
294
|
-
//
|
|
292
|
+
// the motherhome row, filed in the machine db and landed in the docsBase
|
|
293
|
+
// page by the daemon (core/motherhome.ts): never affects the gate, never
|
|
294
|
+
// writes in another repo, and a frozen run files nothing
|
|
295
295
|
if (o.frozen !== true) {
|
|
296
296
|
const mh = reportToMotherhome(root, c)
|
|
297
297
|
if (mh.note != null) print(mh.note)
|
package/src/core/wire.ts
CHANGED
|
@@ -3,22 +3,51 @@
|
|
|
3
3
|
// exit 0/1. FROZEN: consumers grep startsWith('FAIL'/'WARN'/'skip:') — a
|
|
4
4
|
// prefix change is a breaking change of the whole toolkit.
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
import { CACHED, RAN } from './outcome-words.ts'
|
|
7
|
+
|
|
8
|
+
/** when a remembered world answer was measured, and until when it keeps
|
|
9
|
+
* (null: until its declaration changes). Kept as data, never as text, and
|
|
10
|
+
* rendered against the reader's clock: a time frozen into the line reads
|
|
11
|
+
* as a future expiry long after it passed */
|
|
12
|
+
export type Stamp = { at: number; until: number | null; cached?: boolean }
|
|
13
|
+
|
|
14
|
+
export type Line = { kind: 'ok' | 'warn' | 'fail' | 'note' | 'fix-warn' | 'fix-fail' | 'skip'; text: string; stamp?: Stamp }
|
|
15
|
+
|
|
16
|
+
/** a span in its one biggest unit: `45s`, `12m`, `3h`, `2d` */
|
|
17
|
+
export function spanShort(ms: number): string {
|
|
18
|
+
const s = Math.max(0, Math.round(ms / 1000))
|
|
19
|
+
if (s < 60) return `${s}s`
|
|
20
|
+
if (s < 3600) return `${Math.floor(s / 60)}m`
|
|
21
|
+
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
|
22
|
+
return `${Math.floor(s / 86400)}d`
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** `cached, ran 3h ago, kept 57m more`, `ran 1d ago, expired 1d ago` */
|
|
26
|
+
export function stampWords(s: Stamp, now: number): string {
|
|
27
|
+
const age = now - s.at
|
|
28
|
+
const ran = age < 1000 ? `${RAN} just now` : `${RAN} ${spanShort(age)} ago`
|
|
29
|
+
const keeps = s.until == null ? 'kept until its declaration changes' : s.until > now ? `kept ${spanShort(s.until - now)} more` : `expired ${spanShort(now - s.until)} ago`
|
|
30
|
+
return `${s.cached === true ? `${CACHED}, ` : ''}${ran}, ${keeps}`
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** a line's text as a reader sees it now, its stamp rendered */
|
|
34
|
+
export const lineText = (l: Line, now: number = Date.now()): string => (l.stamp == null ? l.text : `${l.text} (${stampWords(l.stamp, now)})`)
|
|
7
35
|
|
|
8
36
|
const green = (s: string): string => `\x1b[32m${s}\x1b[0m`
|
|
9
37
|
const red = (s: string): string => `\x1b[31m${s}\x1b[0m`
|
|
10
38
|
const yellow = (s: string): string => `\x1b[33m${s}\x1b[0m`
|
|
11
39
|
|
|
12
|
-
export function formatLine(l: Line, color: boolean): string {
|
|
40
|
+
export function formatLine(l: Line, color: boolean, now: number = Date.now()): string {
|
|
13
41
|
const c = (f: (s: string) => string, s: string) => (color ? f(s) : s)
|
|
42
|
+
const text = lineText(l, now)
|
|
14
43
|
switch (l.kind) {
|
|
15
|
-
case 'ok': return `${c(green, 'ok:')} ${
|
|
16
|
-
case 'warn': return c(yellow, `WARN ${
|
|
17
|
-
case 'fail': return c(red, `FAIL: ${
|
|
18
|
-
case 'fix-warn': return ` ${c(yellow, `fix: ${
|
|
19
|
-
case 'fix-fail': return ` ${c(red, `fix: ${
|
|
20
|
-
case 'skip': return `skip: ${
|
|
21
|
-
case 'note': return
|
|
44
|
+
case 'ok': return `${c(green, 'ok:')} ${text}`
|
|
45
|
+
case 'warn': return c(yellow, `WARN ${text}`)
|
|
46
|
+
case 'fail': return c(red, `FAIL: ${text}`)
|
|
47
|
+
case 'fix-warn': return ` ${c(yellow, `fix: ${text}`)}`
|
|
48
|
+
case 'fix-fail': return ` ${c(red, `fix: ${text}`)}`
|
|
49
|
+
case 'skip': return `skip: ${text}`
|
|
50
|
+
case 'note': return text
|
|
22
51
|
}
|
|
23
52
|
}
|
|
24
53
|
|
|
@@ -64,8 +64,11 @@ export type StatusCheck = {
|
|
|
64
64
|
status: 'fail' | 'warn'
|
|
65
65
|
/** when that outcome was recorded, epoch ms */
|
|
66
66
|
at: number | null
|
|
67
|
-
/** the
|
|
67
|
+
/** the worst line it printed about the problem, '' when it printed none */
|
|
68
68
|
line: string
|
|
69
|
+
/** every fail and warn line it printed, the worst weight first: a check
|
|
70
|
+
* reporting three findings lists three */
|
|
71
|
+
lines: string[]
|
|
69
72
|
}
|
|
70
73
|
|
|
71
74
|
export type ActivityAnswer = { now: number; minutes: number; rows: ActivityRow[]; ms: number }
|
package/src/home/activity.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { gitBin } from '../core/git-bin.ts'
|
|
12
12
|
import { readLastStatusReadonly } from '../ledger/run-log.ts'
|
|
13
13
|
import { checkStateOf, recordOf } from '../serve/project-gutter.ts'
|
|
14
|
-
import type
|
|
14
|
+
import { lineText, type Line } from '../core/wire.ts'
|
|
15
15
|
import { MAX_COMMITS, MAX_FILES, type ActivityAnswer, type ActivityCommit, type ActivityRepo, type ActivityRow, type FileKind, type StatusCheck } from './activity-shape.ts'
|
|
16
16
|
|
|
17
17
|
export * from './activity-shape.ts'
|
|
@@ -93,26 +93,32 @@ export type Declared = (name: string) => ReadonlySet<string> | null
|
|
|
93
93
|
/** each declared check's title by id; null = not known, the id stands in */
|
|
94
94
|
export type Titles = (name: string) => ReadonlyMap<string, string> | null
|
|
95
95
|
|
|
96
|
-
/**
|
|
97
|
-
* weight (a fail for a failure, a warning for a
|
|
98
|
-
* line that is not a pass, without the id
|
|
99
|
-
|
|
96
|
+
/** every line a check printed about its problems: its fail and warn lines,
|
|
97
|
+
* those of its own weight first (a fail for a failure, a warning for a
|
|
98
|
+
* warning), else its first line that is not a pass, each without the id it
|
|
99
|
+
* leads with */
|
|
100
|
+
export function findingsOf(lines: string, status: 'fail' | 'warn', id: string): string[] {
|
|
100
101
|
let parsed: Line[]
|
|
101
102
|
try {
|
|
102
103
|
parsed = JSON.parse(lines) as Line[]
|
|
103
104
|
} catch {
|
|
104
|
-
return
|
|
105
|
+
return []
|
|
105
106
|
}
|
|
106
|
-
if (!Array.isArray(parsed)) return
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
if (
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
107
|
+
if (!Array.isArray(parsed)) return []
|
|
108
|
+
const of = (k: Line['kind']): Line[] => parsed.filter((l) => l.kind === k)
|
|
109
|
+
let hits = status === 'fail' ? [...of('fail'), ...of('warn')] : [...of('warn'), ...of('fail')]
|
|
110
|
+
if (hits.length === 0) hits = parsed.filter((l) => l.kind !== 'ok' && l.kind !== 'skip').slice(0, 1)
|
|
111
|
+
return hits.map((l) => {
|
|
112
|
+
const t = lineText(l).trim()
|
|
113
|
+
// `[ID] text`, and a runner line's `ID — text`: the list names the id already
|
|
114
|
+
for (const lead of [`[${id}]`, `${id} —`]) if (t.startsWith(lead)) return t.slice(lead.length).trim()
|
|
115
|
+
return t
|
|
116
|
+
})
|
|
114
117
|
}
|
|
115
118
|
|
|
119
|
+
/** the worst of them, '' when it printed none */
|
|
120
|
+
export const findingOf = (lines: string, status: 'fail' | 'warn', id: string): string => findingsOf(lines, status, id)[0] ?? ''
|
|
121
|
+
|
|
116
122
|
/** a repo's checks as its run record last saw them, counted over what it
|
|
117
123
|
* declares, and the failing and warning ones with what each said */
|
|
118
124
|
export function statusOf(repo: ActivityRepo, declared: Declared | null = null, titles: Titles | null = null): Pick<ActivityRow, 'status' | 'failing'> {
|
|
@@ -125,7 +131,8 @@ export function statusOf(repo: ActivityRepo, declared: Declared | null = null, t
|
|
|
125
131
|
const named = titles?.(repo.name) ?? null
|
|
126
132
|
const one = (id: string, st: 'fail' | 'warn'): StatusCheck => {
|
|
127
133
|
const r = byId.get(id)
|
|
128
|
-
|
|
134
|
+
const lines = r == null ? [] : findingsOf(r.lines, st, id)
|
|
135
|
+
return { id, title: named?.get(id) ?? id, status: st, at: status.since[id] ?? null, line: lines[0] ?? '', lines }
|
|
129
136
|
}
|
|
130
137
|
return { status, failing: [...status.reds.map((id) => one(id, 'fail')), ...status.warns.map((id) => one(id, 'warn'))] }
|
|
131
138
|
}
|