rvlib-shipkit 1.1.1 → 1.3.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.
Files changed (210) hide show
  1. package/CHANGELOG.md +130 -0
  2. package/README.md +1 -1
  3. package/package.json +3 -1
  4. package/src/assets/run-assets.ts +3 -8
  5. package/src/check.ts +40 -2
  6. package/src/checks/SK001-package-json-shape.check.ts +14 -3
  7. package/src/checks/SK002-contracts-fulfillness.check.ts +3 -3
  8. package/src/checks/SK004-gitignore-policy.check.ts +11 -3
  9. package/src/checks/SK005-rvlib-versions.check.ts +13 -1
  10. package/src/checks/SK006-npm-publish-safety.check.ts +5 -0
  11. package/src/checks/SK007-github-visibility.check.ts +9 -9
  12. package/src/checks/SK008-reviewer-agent.check.ts +14 -8
  13. package/src/checks/SK009-commit-ai-tells.check.ts +29 -5
  14. package/src/checks/SK013-one-way-in-policy.check.ts +10 -2
  15. package/src/checks/SK015-nested-repo.check.ts +5 -1
  16. package/src/checks/SK017-vscode-hub-task.check.ts +7 -0
  17. package/src/checks/SK022-machine-runs-the-pin.check.ts +47 -0
  18. package/src/checks/SK024-identity-colors.check.ts +54 -0
  19. package/src/checks/SK025-release-drift.check.ts +58 -0
  20. package/src/checks/SK026-asks-autoclose.check.ts +44 -0
  21. package/src/checks/SK027-agent-wiki-links.check.ts +28 -0
  22. package/src/checks/SK028-readme.check.ts +79 -0
  23. package/src/cli/cmd_Assets.ts +1 -1
  24. package/src/cli/cmd_Check.ts +26 -1
  25. package/src/cli/cmd_Ci.ts +23 -5
  26. package/src/cli/cmd_CrossRepoActivity.ts +116 -0
  27. package/src/cli/cmd_GlobalProcesses.ts +155 -0
  28. package/src/cli/cmd_Home.ts +170 -0
  29. package/src/cli/cmd_Keys.ts +17 -3
  30. package/src/cli/cmd_Overview.ts +98 -0
  31. package/src/cli/cmd_Planned.ts +117 -0
  32. package/src/cli.ts +30 -2
  33. package/src/collections/checks.ts +3 -2
  34. package/src/collections/providers.ts +1 -1
  35. package/src/collections/test-time.ts +10 -1
  36. package/src/comfy/comfy-panel.ts +1 -16
  37. package/src/core/agent-spawn.ts +46 -0
  38. package/src/core/archived.ts +82 -0
  39. package/src/core/attach.ts +14 -9
  40. package/src/core/canonical-scripts.ts +12 -3
  41. package/src/core/check-file-names.ts +8 -0
  42. package/src/core/check-runner.ts +80 -30
  43. package/src/core/discover-checks.ts +38 -18
  44. package/src/core/file-stamp.ts +20 -0
  45. package/src/{checks/SK021-github-login.check.ts → core/gh-login.ts} +45 -54
  46. package/src/core/identity-color.ts +203 -0
  47. package/src/core/identity.ts +91 -0
  48. package/src/core/job-follow.ts +102 -0
  49. package/src/core/job-run.ts +27 -0
  50. package/src/core/jobs.ts +87 -27
  51. package/src/core/legacy-shapes.ts +1 -14
  52. package/src/core/machine-db.ts +83 -0
  53. package/src/core/one-lib.ts +85 -0
  54. package/src/core/port-holders.ts +43 -0
  55. package/src/core/repo-facts.ts +14 -0
  56. package/src/core/retention.ts +222 -0
  57. package/src/core/retired-checks.ts +7 -0
  58. package/src/core/run-check.ts +12 -4
  59. package/src/core/run-ci.ts +46 -12
  60. package/src/core/run-fix.ts +7 -2
  61. package/src/core/run-fixers.ts +10 -6
  62. package/src/core/slots.ts +5 -0
  63. package/src/core/statement-end.ts +17 -0
  64. package/src/core/wire.ts +11 -0
  65. package/src/fleet/scan-fleet.ts +2 -1
  66. package/src/home/activity-shape.ts +135 -0
  67. package/src/home/activity.ts +249 -0
  68. package/src/home/attach.ts +95 -0
  69. package/src/home/autostart.ts +108 -0
  70. package/src/home/global-units.ts +87 -0
  71. package/src/home/gui-app/ShipkitGui.swift +159 -0
  72. package/src/home/gui-app.ts +99 -0
  73. package/src/home/handoff.ts +51 -0
  74. package/src/home/home-launchd.ts +104 -0
  75. package/src/home/home-paths.ts +88 -0
  76. package/src/home/home-routes.ts +235 -0
  77. package/src/home/home-start.ts +105 -0
  78. package/src/home/home-state.ts +43 -0
  79. package/src/home/home-words.ts +12 -0
  80. package/src/home/hubs.ts +286 -0
  81. package/src/home/lib-watch.ts +139 -0
  82. package/src/home/machine-stores.ts +115 -0
  83. package/src/home/open-app.ts +16 -0
  84. package/src/home/pm-config.ts +37 -0
  85. package/src/home/pm.ts +174 -0
  86. package/src/home/process-rows.ts +205 -0
  87. package/src/home/resources.ts +365 -0
  88. package/src/home/run-daemon.ts +137 -0
  89. package/src/home/run-pm.ts +150 -0
  90. package/src/home/run-unit-action.ts +25 -0
  91. package/src/home/run-web.ts +37 -0
  92. package/src/home/serve-daemon.ts +31 -0
  93. package/src/home/serve-web.ts +175 -0
  94. package/src/home/supervise.ts +361 -0
  95. package/src/home/via-hub.ts +142 -0
  96. package/src/home/widget-text.ts +119 -0
  97. package/src/home/widgets.ts +102 -0
  98. package/src/hooks/run-hooks.ts +20 -3
  99. package/src/ledger/check-stats.ts +19 -45
  100. package/src/ledger/db.ts +69 -17
  101. package/src/ledger/gate-checks.ts +22 -3
  102. package/src/ledger/git-scan.ts +31 -7
  103. package/src/ledger/house-runs.ts +43 -0
  104. package/src/ledger/ledger.ts +40 -14
  105. package/src/ledger/live-timeline.ts +4 -19
  106. package/src/ledger/run-log.ts +92 -28
  107. package/src/ledger/store.ts +245 -103
  108. package/src/ledger/world-cache.ts +64 -15
  109. package/src/plan/cadence.ts +253 -0
  110. package/src/plan/catch-up.ts +37 -0
  111. package/src/plan/plan-timeline.ts +99 -0
  112. package/src/plan/planner.ts +428 -0
  113. package/src/plan/repo-source.ts +212 -0
  114. package/src/problems/problem-line.ts +1 -1
  115. package/src/release/run-release.ts +9 -2
  116. package/src/release/unit-resolve.ts +8 -12
  117. package/src/repo-config.ts +61 -4
  118. package/src/serve/house-runs.ts +53 -0
  119. package/src/serve/hub-app/activity-st.ts +168 -0
  120. package/src/serve/hub-app/activity-view.tsx +218 -0
  121. package/src/serve/hub-app/activity.css +78 -0
  122. package/src/serve/hub-app/admin-svc.ts +41 -0
  123. package/src/serve/hub-app/admin-view.tsx +42 -0
  124. package/src/serve/hub-app/chart-view.ts +12 -5
  125. package/src/serve/hub-app/check-view.tsx +9 -9
  126. package/src/serve/hub-app/check-words.ts +3 -1
  127. package/src/serve/hub-app/checks-svc.ts +12 -8
  128. package/src/serve/hub-app/col-card.tsx +4 -4
  129. package/src/serve/hub-app/cols-svc.ts +92 -13
  130. package/src/serve/hub-app/comfy-svc.ts +12 -6
  131. package/src/serve/hub-app/comfy-view.tsx +3 -3
  132. package/src/serve/hub-app/controls.css +32 -0
  133. package/src/serve/hub-app/cycle-button.tsx +36 -0
  134. package/src/serve/hub-app/dock-anim.ts +25 -0
  135. package/src/serve/hub-app/dock-st.ts +329 -0
  136. package/src/serve/hub-app/dock-view.tsx +68 -0
  137. package/src/serve/hub-app/dock.css +35 -0
  138. package/src/serve/hub-app/floating.tsx +49 -0
  139. package/src/serve/hub-app/geometry-view.tsx +4 -4
  140. package/src/serve/hub-app/hash-route.ts +8 -2
  141. package/src/serve/hub-app/header-bar.tsx +102 -95
  142. package/src/serve/hub-app/health-svc.ts +3 -3
  143. package/src/serve/hub-app/help.tsx +10 -7
  144. package/src/serve/hub-app/home-st.ts +278 -0
  145. package/src/serve/hub-app/home-strip.css +113 -0
  146. package/src/serve/hub-app/home-strip.tsx +376 -0
  147. package/src/serve/hub-app/home.html +21 -0
  148. package/src/serve/hub-app/hub-app.tsx +221 -177
  149. package/src/serve/hub-app/hub-types.ts +0 -3
  150. package/src/serve/hub-app/hub.css +112 -52
  151. package/src/serve/hub-app/icons.tsx +34 -0
  152. package/src/serve/hub-app/index.html +7 -1
  153. package/src/serve/hub-app/keys.ts +60 -0
  154. package/src/serve/hub-app/layout-st.ts +55 -0
  155. package/src/serve/hub-app/live-svc.ts +35 -72
  156. package/src/serve/hub-app/machine-st.ts +333 -0
  157. package/src/serve/hub-app/md.ts +38 -5
  158. package/src/serve/hub-app/media-svc.ts +63 -44
  159. package/src/serve/hub-app/media-view.tsx +20 -20
  160. package/src/serve/hub-app/next-run.ts +24 -30
  161. package/src/serve/hub-app/palette-st.ts +92 -0
  162. package/src/serve/hub-app/palette-view.tsx +47 -0
  163. package/src/serve/hub-app/pill-row.tsx +65 -0
  164. package/src/serve/hub-app/planned-st.ts +107 -0
  165. package/src/serve/hub-app/planned-view.tsx +114 -0
  166. package/src/serve/hub-app/planned.css +42 -0
  167. package/src/serve/hub-app/processes-svc.ts +63 -0
  168. package/src/serve/hub-app/project-list.css +119 -0
  169. package/src/serve/hub-app/project-list.tsx +266 -0
  170. package/src/serve/hub-app/projects-svc.ts +253 -23
  171. package/src/serve/hub-app/prose-hits-st.ts +3 -3
  172. package/src/serve/hub-app/prose-hits-view.tsx +6 -6
  173. package/src/serve/hub-app/readme-svc.ts +38 -0
  174. package/src/serve/hub-app/readme-view.tsx +19 -0
  175. package/src/serve/hub-app/readme.css +11 -0
  176. package/src/serve/hub-app/repo-sections.tsx +61 -0
  177. package/src/serve/hub-app/repo-st.ts +340 -0
  178. package/src/serve/hub-app/runs-tabs.ts +1 -1
  179. package/src/serve/hub-app/screen-cache.ts +74 -0
  180. package/src/serve/hub-app/sidebar.tsx +84 -0
  181. package/src/serve/hub-app/test-view.tsx +4 -4
  182. package/src/serve/hub-app/tests-svc.ts +3 -3
  183. package/src/serve/hub-app/tests-view.tsx +41 -14
  184. package/src/serve/hub-app/timeline-chart.tsx +55 -39
  185. package/src/serve/hub-app/timeline-geometry.ts +40 -5
  186. package/src/serve/hub-app/timeline-svc.ts +7 -6
  187. package/src/serve/hub-app/timeline-view.tsx +33 -16
  188. package/src/serve/hub-app/tree-row.tsx +2 -2
  189. package/src/serve/hub-app/tree-svc.ts +2 -2
  190. package/src/serve/hub-app/wikis-view.tsx +10 -6
  191. package/src/serve/hub-runtime.ts +550 -0
  192. package/src/serve/other-repos.ts +8 -45
  193. package/src/serve/project-gutter-route.ts +121 -20
  194. package/src/serve/project-gutter.ts +33 -11
  195. package/src/serve/readme-route.ts +57 -0
  196. package/src/serve/repo-processes.ts +89 -0
  197. package/src/serve/run-serve.ts +110 -402
  198. package/src/serve/serve-hub.ts +155 -52
  199. package/src/serve/stale-restart.ts +5 -0
  200. package/src/serve/{gate-trigger.ts → tracked-watch.ts} +37 -65
  201. package/src/serve/warm-cell.ts +95 -0
  202. package/src/shipkit-files.ts +130 -0
  203. package/src/shipkit.ts +2 -1
  204. package/src/suite/kept-timings.ts +1 -1
  205. package/src/wiki/graph-positions.ts +1 -1
  206. package/src/checks/contracts.check.ts +0 -20
  207. package/src/serve/health-schedule.ts +0 -102
  208. package/src/serve/hub-app/hub-st.ts +0 -250
  209. package/src/serve/hub-app/project-gutter.css +0 -65
  210. package/src/serve/hub-app/project-gutter.tsx +0 -128
@@ -0,0 +1,203 @@
1
+ // a repo's identity colours: `identity: { colorLight, colorDark }` in its
2
+ // repo.config.ts, one hex each. colorLight reads on a light background,
3
+ // colorDark on a dark one. This module is pure (the page imports it): hue
4
+ // maths, the fallback from the repo name, and the byte preserving edit of the
5
+ // config text. Reading an icon is core/identity.ts.
6
+
7
+ export type Identity = { colorLight: string; colorDark: string }
8
+
9
+ export const HEX_RE = /^#[0-9a-fA-F]{6}$/
10
+
11
+ /** why this value is not an identity, or null */
12
+ export function identityProblem(v: unknown): string | null {
13
+ if (v == null) return 'no identity colours'
14
+ if (typeof v !== 'object') return 'identity is not an object'
15
+ const o = v as Record<string, unknown>
16
+ for (const k of ['colorLight', 'colorDark'] as const)
17
+ if (typeof o[k] !== 'string' || !HEX_RE.test(o[k] as string)) return `identity.${k} is ${o[k] == null ? 'missing' : `'${String(o[k])}'`}, not a #rrggbb hex`
18
+ return null
19
+ }
20
+
21
+ const clamp = (v: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, v))
22
+
23
+ /** hsl (h in degrees, s and l in 0..1) as #rrggbb */
24
+ export function hslHex(h: number, s: number, l: number): string {
25
+ const a = s * Math.min(l, 1 - l)
26
+ const f = (n: number): string => {
27
+ const k = (n + h / 30) % 12
28
+ const c = l - a * Math.max(-1, Math.min(k - 3, 9 - k, 1))
29
+ return Math.round(c * 255).toString(16).padStart(2, '0')
30
+ }
31
+ return `#${f(0)}${f(8)}${f(4)}`
32
+ }
33
+
34
+ /** #rrggbb as hsl, h in degrees */
35
+ export function hexHsl(hex: string): { h: number; s: number; l: number } {
36
+ const n = Number.parseInt(hex.slice(1), 16)
37
+ return rgbHsl((n >> 16) & 255, (n >> 8) & 255, n & 255)
38
+ }
39
+
40
+ export function rgbHsl(r8: number, g8: number, b8: number): { h: number; s: number; l: number } {
41
+ const r = r8 / 255
42
+ const g = g8 / 255
43
+ const b = b8 / 255
44
+ const max = Math.max(r, g, b)
45
+ const min = Math.min(r, g, b)
46
+ const l = (max + min) / 2
47
+ const d = max - min
48
+ if (d === 0) return { h: 0, s: 0, l }
49
+ const s = d / (1 - Math.abs(2 * l - 1))
50
+ let h = max === r ? ((g - b) / d) % 6 : max === g ? (b - r) / d + 2 : (r - g) / d + 4
51
+ h *= 60
52
+ if (h < 0) h += 360
53
+ return { h, s, l }
54
+ }
55
+
56
+ /** the pair for one hue: saturated enough to read as a colour, dark enough
57
+ * for white paper, light enough for a dark page */
58
+ export function identityOfHue(h: number, s = 0.7): Identity {
59
+ const sat = clamp(s, 0.5, 0.85)
60
+ return { colorLight: hslHex(h, sat, 0.38), colorDark: hslHex(h, sat, 0.68) }
61
+ }
62
+
63
+ /** a hue hashed from the name, stable from one run to the next */
64
+ export function nameHue(name: string): number {
65
+ let h = 0
66
+ for (const c of name) h = (h * 31 + c.charCodeAt(0)) >>> 0
67
+ return h % 360
68
+ }
69
+
70
+ export const identityOfName = (name: string): Identity => identityOfHue(nameHue(name))
71
+
72
+ /** the dominant saturated hue of rgba pixels, weighted by saturation, or
73
+ * null when the image is grey, transparent or empty */
74
+ export function dominantHue(rgba: Uint8Array | Buffer, channels = 4): { h: number; s: number } | null {
75
+ const BINS = 36
76
+ const weight = new Float64Array(BINS)
77
+ const sumS = new Float64Array(BINS)
78
+ const vecX = new Float64Array(BINS)
79
+ const vecY = new Float64Array(BINS)
80
+ for (let i = 0; i + channels - 1 < rgba.length; i += channels) {
81
+ if (channels === 4 && rgba[i + 3]! < 128) continue
82
+ const { h, s, l } = rgbHsl(rgba[i]!, rgba[i + 1]!, rgba[i + 2]!)
83
+ if (s < 0.3 || l < 0.12 || l > 0.9) continue
84
+ const b = Math.floor(h / (360 / BINS)) % BINS
85
+ const w = s * (1 - Math.abs(2 * l - 1))
86
+ weight[b]! += w
87
+ sumS[b]! += s * w
88
+ vecX[b]! += Math.cos((h * Math.PI) / 180) * w
89
+ vecY[b]! += Math.sin((h * Math.PI) / 180) * w
90
+ }
91
+ let best = -1
92
+ let bestW = 0
93
+ // a bin and its two neighbours, so a hue on a bin edge is not split
94
+ for (let b = 0; b < BINS; b++) {
95
+ const w = weight[b]! + weight[(b + 1) % BINS]! + weight[(b + BINS - 1) % BINS]!
96
+ if (w > bestW) { bestW = w; best = b }
97
+ }
98
+ if (best < 0 || bestW === 0) return null
99
+ let x = 0, y = 0, s = 0, w = 0
100
+ for (const b of [(best + BINS - 1) % BINS, best, (best + 1) % BINS]) {
101
+ x += vecX[b]!
102
+ y += vecY[b]!
103
+ s += sumS[b]!
104
+ w += weight[b]!
105
+ }
106
+ let h = (Math.atan2(y, x) * 180) / Math.PI
107
+ if (h < 0) h += 360
108
+ return { h, s: s / w }
109
+ }
110
+
111
+ // ── the config text ─────────────────────────────────────────────────────
112
+
113
+ // the key where a config writes it: at a line start, or inline after `{` or
114
+ // `,` (a one line config), bare or quoted
115
+ const KEY_RE = /(?:^|[{,])\s*(['"]?)identity\1\s*:\s*/gm
116
+
117
+ /** per char of the text: the object depth inside `defineRepo({` after that
118
+ * char (1 on the top level keys), or -1 inside a string or a comment */
119
+ export function configDepths(text: string): Int32Array {
120
+ const depth = new Int32Array(text.length).fill(0)
121
+ const open = /defineRepo\(\s*\{/.exec(text)
122
+ if (open == null) return depth
123
+ let d = 0
124
+ let quote: string | null = null
125
+ let comment: 'line' | 'block' | null = null
126
+ for (let i = open.index + open[0].length - 1; i < text.length; i++) {
127
+ const ch = text[i]!
128
+ if (comment === 'line') { if (ch === '\n') comment = null; else { depth[i] = -1; continue } }
129
+ else if (comment === 'block') { depth[i] = -1; if (ch === '/' && text[i - 1] === '*') comment = null; continue }
130
+ else if (quote != null) { depth[i] = -1; if (ch === '\\') { depth[++i] = -1 } else if (ch === quote) quote = null; continue }
131
+ else if (ch === '/' && text[i + 1] === '/') { comment = 'line'; depth[i] = -1; continue }
132
+ else if (ch === '/' && text[i + 1] === '*') { comment = 'block'; depth[i] = -1; continue }
133
+ else if (ch === "'" || ch === '"' || ch === '`') { quote = ch; depth[i] = -1; continue }
134
+ if (ch === '{' || ch === '[' || ch === '(') d++
135
+ else if (ch === '}' || ch === ']' || ch === ')') d--
136
+ depth[i] = d
137
+ }
138
+ return depth
139
+ }
140
+
141
+ /** every top level `identity` key of `defineRepo({…})`, as the offsets of
142
+ * its `{…}` value (end exclusive), null when the value is not an object
143
+ * literal. A key nested deeper, in a string or in a comment is not it */
144
+ function identityKeys(text: string): { value: { start: number; end: number } | null }[] {
145
+ const depth = configDepths(text)
146
+ const out: { value: { start: number; end: number } | null }[] = []
147
+ for (const m of text.matchAll(KEY_RE)) {
148
+ const sep = /^[{,]/.test(m[0]) ? m.index : m.index - 1
149
+ if (sep < 0 || depth[sep] !== 1) continue
150
+ const at = m.index + m[0].length
151
+ if (text[at] !== '{') { out.push({ value: null }); continue }
152
+ let end = -1
153
+ for (let j = at; j < text.length; j++) if (depth[j] === 1 && text[j] === '}') { end = j + 1; break }
154
+ out.push({ value: end === -1 ? null : { start: at, end } })
155
+ }
156
+ return out
157
+ }
158
+
159
+ /** whether the text declares an `identity` key at all, readable or not */
160
+ export const declaresIdentity = (text: string): boolean => identityKeys(text).length > 0
161
+
162
+ /** the identity the config text declares, read without loading it: the page
163
+ * rows and the daemon read many configs per poll */
164
+ export function identityOfText(text: string): Identity | null {
165
+ const keys = identityKeys(text)
166
+ const v = keys.length === 1 ? keys[0]!.value : null
167
+ if (v == null) return null
168
+ const body = text.slice(v.start, v.end)
169
+ const light = /(['"]?)colorLight\1\s*:\s*['"`](#[0-9a-fA-F]{6})['"`]/.exec(body)?.[2]
170
+ const dark = /(['"]?)colorDark\1\s*:\s*['"`](#[0-9a-fA-F]{6})['"`]/.exec(body)?.[2]
171
+ return light == null || dark == null ? null : { colorLight: light.toLowerCase(), colorDark: dark.toLowerCase() }
172
+ }
173
+
174
+ /** the config text with `identity` set to these colours. Only that key
175
+ * moves: an existing object value is replaced in place, a missing key lands
176
+ * as the first key of `defineRepo({`, and every other byte stays. Throws
177
+ * when the text holds the key twice, holds it with a value that is not an
178
+ * object literal, or has no `defineRepo({` to write into */
179
+ export function setIdentityText(text: string, id: Identity): string {
180
+ const bad = identityProblem(id)
181
+ if (bad != null) throw new Error(bad)
182
+ const q = /\bname\s*:\s*"/.test(text) && !/\bname\s*:\s*'/.test(text) ? '"' : "'"
183
+ const value = `{ colorLight: ${q}${id.colorLight.toLowerCase()}${q}, colorDark: ${q}${id.colorDark.toLowerCase()}${q} }`
184
+ const keys = identityKeys(text)
185
+ if (keys.length > 1) throw new Error('repo.config.ts declares `identity` more than once, set it by hand')
186
+ if (keys.length === 1) {
187
+ const v = keys[0]!.value
188
+ if (v == null) throw new Error('repo.config.ts declares `identity` as something other than `{ … }`, set it by hand')
189
+ return `${text.slice(0, v.start)}${value}${text.slice(v.end)}`
190
+ }
191
+ const lines = text.split('\n')
192
+ const open = lines.findIndex((l) => /defineRepo\(\s*\{/.test(l))
193
+ if (open === -1) throw new Error('repo.config.ts has no `defineRepo({` to write `identity` into, set it by hand')
194
+ const line = lines[open]!
195
+ if (/defineRepo\(\s*\{\s*$/.test(line)) {
196
+ const next = lines.slice(open + 1).find((l) => l.trim() !== '' && !/^\s*}/.test(l))
197
+ const pad = next != null ? /^(\s*)/.exec(next)![1]! : `${/^(\s*)/.exec(line)![1]!} `
198
+ return [...lines.slice(0, open + 1), `${pad}identity: ${value},`, ...lines.slice(open + 1)].join('\n')
199
+ }
200
+ // a config on one line: the key goes right after the brace
201
+ lines[open] = line.replace(/defineRepo\(\s*\{\s*/, (m) => `${m}identity: ${value}, `)
202
+ return lines.join('\n')
203
+ }
@@ -0,0 +1,91 @@
1
+ // a repo's identity colours, from disk: the dominant saturated colour of its
2
+ // rail icon (serve/project-gutter.ts ICON_FILES), else a hue hashed from its
3
+ // name, written into repo.config.ts with only the `identity` key moving. SK024
4
+ // warns when the key is missing, its fix and the daemon's load both call
5
+ // ensureIdentity, and the page's two swatches reach writeIdentity.
6
+ import { existsSync, readFileSync } from 'fs'
7
+ import { basename, join } from 'path'
8
+ import sharp from 'sharp'
9
+ import { atomicWrite } from './locked-write.ts'
10
+ import { dominantHue, identityOfHue, identityOfName, identityOfText, identityProblem, setIdentityText, type Identity } from './identity-color.ts'
11
+ import { ICON_FILES } from '../serve/project-gutter.ts'
12
+
13
+ export const CONFIG_FILE = 'repo.config.ts'
14
+
15
+ /** where a repo's own copy declares the config keys it knows */
16
+ export const INSTALLED_CONFIG_SRC = 'node_modules/rvlib-shipkit/src/repo-config.ts'
17
+
18
+ /** why the key cannot be written in this repo yet, or null. `source` is the
19
+ * text of its own copy's repo-config.ts, null when it has none. A copy that
20
+ * does not know the key would refuse it (its config load in ci, its
21
+ * typecheck), so nothing writes it there. Read by capability, not version:
22
+ * a linked copy is the source itself, whatever its package.json says */
23
+ export const identityRefusal = (source: string | null): string | null =>
24
+ source != null && !source.includes("'identity'")
25
+ ? `its own rvlib-shipkit predates the identity key, so writing it would break that copy: update rvlib-shipkit first`
26
+ : null
27
+
28
+ const installedSource = (root: string): string | null => {
29
+ try {
30
+ return readFileSync(join(root, INSTALLED_CONFIG_SRC), 'utf8')
31
+ } catch {
32
+ return null
33
+ }
34
+ }
35
+
36
+ /** the name the config declares, else the folder name */
37
+ export const configName = (text: string, root: string): string => /\bname\s*:\s*['"`]([^'"`]+)['"`]/.exec(text)?.[1] ?? basename(root)
38
+
39
+ /** the colours an image's pixels suggest, or null when it is grey, unreadable
40
+ * or a format sharp does not read (an .ico) */
41
+ export async function identityOfImage(bytes: Uint8Array): Promise<Identity | null> {
42
+ try {
43
+ const { data } = await sharp(bytes).resize(64, 64, { fit: 'inside' }).ensureAlpha().raw().toBuffer({ resolveWithObject: true })
44
+ const d = dominantHue(data, 4)
45
+ return d == null ? null : identityOfHue(d.h, d.s)
46
+ } catch {
47
+ return null
48
+ }
49
+ }
50
+
51
+ /** the repo's icon file, first hit wins, repo-relative */
52
+ export const iconFileOf = (root: string): string | null => ICON_FILES.find((f) => existsSync(join(root, f))) ?? null
53
+
54
+ /** what the colours should be: from the icon when it has a colour, else from the name */
55
+ export async function deriveIdentity(root: string, name: string, read: (rel: string) => Uint8Array | null = (rel) => readFileSync(join(root, rel))): Promise<{ id: Identity; from: string }> {
56
+ const icon = iconFileOf(root)
57
+ if (icon != null) {
58
+ const bytes = read(icon)
59
+ const id = bytes == null ? null : await identityOfImage(bytes)
60
+ if (id != null) return { id, from: icon }
61
+ }
62
+ return { id: identityOfName(name), from: `the name '${name}'` }
63
+ }
64
+
65
+ /** the config with valid colours: derived and written when the key is
66
+ * missing or malformed, the text left byte for byte otherwise */
67
+ export async function ensureIdentity(root: string): Promise<{ changed: boolean; id: Identity | null; from: string | null; refused?: string }> {
68
+ const path = join(root, CONFIG_FILE)
69
+ if (!existsSync(path)) return { changed: false, id: null, from: null }
70
+ const text = readFileSync(path, 'utf8')
71
+ const have = identityOfText(text)
72
+ if (have != null && identityProblem(have) == null) return { changed: false, id: have, from: null }
73
+ const refused = identityRefusal(installedSource(root))
74
+ if (refused != null) return { changed: false, id: identityOfName(configName(text, root)), from: null, refused }
75
+ const { id, from } = await deriveIdentity(root, configName(text, root))
76
+ atomicWrite(path, setIdentityText(text, id))
77
+ return { changed: true, id, from }
78
+ }
79
+
80
+ /** set the colours by hand; a value left out keeps what the config has */
81
+ export function writeIdentity(root: string, next: Partial<Identity>): Identity {
82
+ const refused = identityRefusal(installedSource(root))
83
+ if (refused != null) throw new Error(refused)
84
+ const path = join(root, CONFIG_FILE)
85
+ const text = readFileSync(path, 'utf8')
86
+ const have = identityOfText(text) ?? identityOfName(configName(text, root))
87
+ const id = { colorLight: next.colorLight ?? have.colorLight, colorDark: next.colorDark ?? have.colorDark }
88
+ const out = setIdentityText(text, id)
89
+ if (out !== text) atomicWrite(path, out)
90
+ return { colorLight: id.colorLight.toLowerCase(), colorDark: id.colorDark.toLowerCase() }
91
+ }
@@ -0,0 +1,102 @@
1
+ // following a detached gate job from its folder (jobs.ts): its wire lines as
2
+ // out.log grows, its verdicts as checks.ndjson grows, then its exit code. The
3
+ // daemon follows every job it starts, and every job still running when a new
4
+ // image of it boots; a terminal whose stream the daemon cut follows the rest
5
+ // of its job here, from the line it had reached.
6
+ //
7
+ // the one constraint a reader breaks first: the verdict is exit.json, never
8
+ // the pid. A pid can outlive its job as a zombie nobody reaps, and a job can
9
+ // write exit.json a moment before its pid goes, so the folder is drained once
10
+ // more after either.
11
+ import { closeSync, openSync, readSync } from 'fs'
12
+ import type { CheckEvent } from './check-runner.ts'
13
+ import { jobAlive, jobChecks, jobOut, readExit, readJob } from './jobs.ts'
14
+
15
+ export type FollowTap = { line: (l: string) => void; check?: (e: CheckEvent) => void }
16
+
17
+ /** one file read from a byte offset, one complete line at a time */
18
+ class Tail {
19
+ private at = 0
20
+ private partial = ''
21
+ constructor(private path: string) {}
22
+
23
+ lines(): string[] {
24
+ let fd: number
25
+ try { fd = openSync(this.path, 'r') } catch { return [] }
26
+ const chunks: string[] = []
27
+ try {
28
+ const buf = Buffer.alloc(64 * 1024)
29
+ for (;;) {
30
+ const n = readSync(fd, buf, 0, buf.length, this.at)
31
+ if (n <= 0) break
32
+ this.at += n
33
+ chunks.push(buf.subarray(0, n).toString('utf8'))
34
+ }
35
+ } finally { closeSync(fd) }
36
+ if (chunks.length === 0) return []
37
+ const all = (this.partial + chunks.join('')).split('\n')
38
+ this.partial = all.pop() ?? ''
39
+ return all
40
+ }
41
+
42
+ /** what is left once the writer is gone: a last line with no newline */
43
+ rest(): string[] {
44
+ const out = this.lines()
45
+ if (this.partial !== '') out.push(this.partial)
46
+ this.partial = ''
47
+ return out
48
+ }
49
+ }
50
+
51
+ export type FollowOpts = {
52
+ /** out.log lines already delivered elsewhere, skipped here */
53
+ skipLines?: number
54
+ pollMs?: number
55
+ /** how often the pid is asked whether it still runs (it costs a ps) */
56
+ aliveEveryMs?: number
57
+ alive?: (pid: number) => boolean
58
+ sleep?: (ms: number) => Promise<void>
59
+ /** stop following early (the tab went away); resolves null */
60
+ abort?: () => boolean
61
+ }
62
+
63
+ /** the job's exit code once it has one, 1 for a job that died without one,
64
+ * null when abort said stop */
65
+ export async function followJob(root: string, id: string, tap: FollowTap, o: FollowOpts = {}): Promise<number | null> {
66
+ const out = new Tail(jobOut(root, id))
67
+ const checks = new Tail(jobChecks(root, id))
68
+ const sleep = o.sleep ?? Bun.sleep
69
+ const isAlive = o.alive ?? jobAlive
70
+ let skip = o.skipLines ?? 0
71
+ const drain = (end: boolean): void => {
72
+ for (const l of end ? out.rest() : out.lines()) {
73
+ if (skip > 0) { skip--; continue }
74
+ tap.line(l)
75
+ }
76
+ for (const l of end ? checks.rest() : checks.lines()) {
77
+ if (l === '' || tap.check == null) continue
78
+ try { tap.check(JSON.parse(l) as CheckEvent) } catch { /* a torn line: the ledger has the verdict */ }
79
+ }
80
+ }
81
+ let lastAlive = 0
82
+ for (;;) {
83
+ drain(false)
84
+ const exit = readExit(root, id)
85
+ if (exit != null) { drain(true); return exit.code }
86
+ if (o.abort?.() === true) return null
87
+ const now = Date.now()
88
+ if (now - lastAlive >= (o.aliveEveryMs ?? 1_000)) {
89
+ lastAlive = now
90
+ const job = readJob(root, id)
91
+ if (job == null || !isAlive(job.pid)) {
92
+ // it may have written its verdict between the two reads
93
+ const late = readExit(root, id)
94
+ drain(true)
95
+ if (late != null) return late.code
96
+ tap.line(`FAIL: gate job ${id} died without a verdict (pid ${job?.pid ?? '?'} is gone)`)
97
+ return 1
98
+ }
99
+ }
100
+ await sleep(o.pollMs ?? 100)
101
+ }
102
+ }
@@ -0,0 +1,27 @@
1
+ // the inside of a gate job the daemon started (jobs.ts startJob): the run's
2
+ // options come from the job folder rather than from flags, every verdict is
3
+ // appended to checks.ndjson for whoever follows it, and a background run takes
4
+ // the machine's one background turn here, so the turn outlives a daemon
5
+ // reload the same as the run does.
6
+ import { basename } from 'path'
7
+ import { useOneLib } from './one-lib.ts'
8
+ import { appendJobCheck, type JobKind, type JobOpts } from './jobs.ts'
9
+ import { autoGatePool, takeSlot } from './slots.ts'
10
+ import type { CheckEvent } from './check-runner.ts'
11
+
12
+ export async function runJob(root: string, id: string, kind: JobKind, job: JobOpts, print: (l: string) => void): Promise<number> {
13
+ // before the repo config loads: it imports this copy of the lib, never its own install
14
+ if (job.oneLib === true) useOneLib()
15
+ const turn = job.turn === true ? await takeSlot(autoGatePool(), { repo: basename(root), check: 'auto gate' }, { pollMs: 250 }) : null
16
+ try {
17
+ const onCheck = (e: CheckEvent): void => appendJobCheck(root, id, e)
18
+ if (kind === 'ci') {
19
+ const { runCi } = await import('./run-ci.ts')
20
+ return await runCi(root, { ...job.opts, print, onCheck })
21
+ }
22
+ const { runCheck } = await import('./run-check.ts')
23
+ return await runCheck(root, { ...job.opts, print, onCheck })
24
+ } finally {
25
+ turn?.release()
26
+ }
27
+ }
package/src/core/jobs.ts CHANGED
@@ -1,13 +1,17 @@
1
1
  // DETACHED GATES: `shipkit ci --detach` starts an ordinary gate in the
2
- // background and hands back a run id, `shipkit status [id]` reads it back.
2
+ // background and hands back a run id, `shipkit status [id]` reads it back. The
3
+ // daemon runs every gate of its repos the same way (job-follow.ts), so a
4
+ // daemon reload never kills one.
3
5
  //
4
6
  // The background process OWNS NOTHING. It is the same gate a terminal runs,
5
7
  // its answers land in the same ledger and its cost in the same run record;
6
8
  // this module only remembers where its output went and how it ended, in
7
9
  // `.shipkit/tmp/shipkit-jobs/<id>/`: `job.json` (who, what, when), `out.log` (its
8
- // wire lines as it prints them) and `exit.json` (the verdict, once).
10
+ // wire lines as it prints them), `checks.ndjson` (one verdict per line, for a
11
+ // job the daemon started), `opts.json` (the run's options, same) and
12
+ // `exit.json` (the verdict, once).
9
13
  import { spawn } from 'child_process'
10
- import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
14
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync } from 'fs'
11
15
  import { join } from 'path'
12
16
  import { LAYOUT } from './layout.ts'
13
17
  import { atomicWrite } from './locked-write.ts'
@@ -16,16 +20,22 @@ import { fmtStamp } from './wire.ts'
16
20
  /** status exit code while the run has no verdict yet */
17
21
  export const STILL_RUNNING = 3
18
22
 
19
- type Job = { id: string; pid: number; args: string[]; startedAt: number }
20
- type Exit = { code: number; finishedAt: number }
23
+ export type JobKind = 'ci' | 'check'
24
+ export type Job = { id: string; pid: number; args: string[]; startedAt: number; kind?: JobKind }
25
+ export type Exit = { code: number; finishedAt: number }
21
26
 
22
- const JOBS = `${LAYOUT.tmp}/shipkit-jobs`
27
+ export const JOBS = `${LAYOUT.tmp}/shipkit-jobs`
23
28
  /** how long a finished run stays readable */
24
- const KEEP_MS = 7 * 24 * 60 * 60 * 1000
29
+ export const JOBS_KEEP_MS = 7 * 24 * 60 * 60 * 1000
30
+ /** the most finished runs kept, newest first */
31
+ export const JOBS_MAX = 100
25
32
 
26
- const ciLine = (args: string[]): string => ['shipkit ci', ...args].join(' ')
33
+ const ciLine = (args: string[], kind: JobKind = 'ci'): string => [`shipkit ${kind}`, ...args].join(' ')
27
34
 
28
- export const jobDir =(root: string, id: string): string => join(root, JOBS, id)
35
+ export const jobDir = (root: string, id: string): string => join(root, JOBS, id)
36
+ export const jobOut = (root: string, id: string): string => join(jobDir(root, id), 'out.log')
37
+ export const jobChecks = (root: string, id: string): string => join(jobDir(root, id), 'checks.ndjson')
38
+ const jobOpts = (root: string, id: string): string => join(jobDir(root, id), 'opts.json')
29
39
 
30
40
  const readJson = <T>(p: string): T | null => {
31
41
  try { return JSON.parse(readFileSync(p, 'utf8')) as T } catch { return null }
@@ -35,19 +45,41 @@ const alive = (pid: number): boolean => {
35
45
  try { process.kill(pid, 0); return true } catch (e) { return (e as { code?: string }).code === 'EPERM' }
36
46
  }
37
47
 
38
- /** start `shipkit ci <args> --job <id>` detached from this process and its
39
- * terminal, and return at once */
40
- export function startDetached(root: string, o: { cli: string; args: string[] }): { id: string; pid: number; lines: string[] } {
48
+ /** alive and not a zombie: a job the daemon started and never reaps (its
49
+ * previous image did) stays in the process table after it exits */
50
+ export function jobAlive(pid: number): boolean {
51
+ if (pid <= 0 || !alive(pid)) return false
52
+ const st = Bun.spawnSync(['ps', '-o', 'stat=', '-p', String(pid)]).stdout.toString().trim()
53
+ return st !== '' && !st.startsWith('Z')
54
+ }
55
+
56
+ let seq = 0
57
+
58
+ /** what a job the daemon starts carries beside its flags: the run's options
59
+ * as the runner takes them, one lib copy for the repo config, and the
60
+ * machine's one background turn for a run nobody waits on */
61
+ export type JobOpts = { opts: Record<string, unknown>; oneLib?: boolean; turn?: boolean }
62
+
63
+ /** start `shipkit <kind> <args> --job <id>` detached from this process and
64
+ * its terminal, and return at once */
65
+ export function startJob(root: string, o: { cli: string; kind: JobKind; args: string[]; job?: JobOpts }): { id: string; pid: number } {
41
66
  sweepJobs(root)
42
- const id = `ci-${Date.now().toString(36)}-${process.pid.toString(36)}`
67
+ const id = `${o.kind}-${Date.now().toString(36)}-${process.pid.toString(36)}${(seq++).toString(36)}`
43
68
  const dir = jobDir(root, id)
44
69
  mkdirSync(dir, { recursive: true })
70
+ if (o.job != null) atomicWrite(jobOpts(root, id), JSON.stringify(o.job))
45
71
  const out = openSync(join(dir, 'out.log'), 'a')
46
- const child = spawn(process.execPath, [o.cli, 'ci', ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: process.env })
72
+ const child = spawn(process.execPath, [o.cli, o.kind, ...o.args, '--job', id], { cwd: root, detached: true, stdio: ['ignore', out, out], env: process.env })
47
73
  closeSync(out)
48
74
  child.unref()
49
75
  const pid = child.pid ?? 0
50
- atomicWrite(join(dir, 'job.json'), JSON.stringify({ id, pid, args: o.args, startedAt: Date.now() } satisfies Job))
76
+ atomicWrite(join(dir, 'job.json'), JSON.stringify({ id, pid, args: o.args, startedAt: Date.now(), kind: o.kind } satisfies Job))
77
+ return { id, pid }
78
+ }
79
+
80
+ /** `shipkit ci --detach`: a job, and the two lines that say how to read it back */
81
+ export function startDetached(root: string, o: { cli: string; args: string[] }): { id: string; pid: number; lines: string[] } {
82
+ const { id, pid } = startJob(root, { cli: o.cli, kind: 'ci', args: o.args })
51
83
  return {
52
84
  id,
53
85
  pid,
@@ -58,6 +90,30 @@ export function startDetached(root: string, o: { cli: string; args: string[] }):
58
90
  }
59
91
  }
60
92
 
93
+ /** the options a daemon job was started with, null for a `--detach` one */
94
+ export const readJobOpts = (root: string, id: string): JobOpts | null => readJson<JobOpts>(jobOpts(root, id))
95
+
96
+ /** one verdict of the job, for whoever follows it */
97
+ export function appendJobCheck(root: string, id: string, e: unknown): void {
98
+ try { appendFileSync(jobChecks(root, id), `${JSON.stringify(e)}\n`) } catch { /* the folder was swept: the verdict is in the ledger anyway */ }
99
+ }
100
+
101
+ export const readJob = (root: string, id: string): Job | null => readJson<Job>(join(jobDir(root, id), 'job.json'))
102
+ export const readExit = (root: string, id: string): Exit | null => readJson<Exit>(join(jobDir(root, id), 'exit.json'))
103
+
104
+ /** the jobs of this checkout still running: started, no verdict, pid alive */
105
+ export function liveJobs(root: string): Job[] {
106
+ const out: Job[] = []
107
+ try {
108
+ for (const name of readdirSync(join(root, JOBS))) {
109
+ const j = readJob(root, name)
110
+ if (j == null || existsSync(join(root, JOBS, name, 'exit.json')) || !jobAlive(j.pid)) continue
111
+ out.push(j)
112
+ }
113
+ } catch { /* no jobs yet */ }
114
+ return out.sort((a, b) => a.startedAt - b.startedAt)
115
+ }
116
+
61
117
  /** the detached gate records how it ended. Called by the gate itself. */
62
118
  export function recordExit(root: string, id: string, code: number): void {
63
119
  const dir = jobDir(root, id)
@@ -85,7 +141,7 @@ export function jobStatus(root: string, id?: string): { code: number; lines: str
85
141
  const dir = jobDir(root, want)
86
142
  const job = readJson<Job>(join(dir, 'job.json'))
87
143
  if (job == null) return { code: 2, lines: [`FAIL: no detached run '${want}' in this checkout`, ` fix: shipkit status (no id) shows the latest one`] }
88
- const lines = [`ok: run ${job.id}: ${ciLine(job.args)} (pid ${job.pid}, started ${fmtStamp(job.startedAt)})`]
144
+ const lines = [`ok: run ${job.id}: ${ciLine(job.args, job.kind)} (pid ${job.pid}, started ${fmtStamp(job.startedAt)})`]
89
145
  let out = ''
90
146
  try { out = readFileSync(join(dir, 'out.log'), 'utf8') } catch { /* nothing printed yet */ }
91
147
  lines.push(...out.split('\n').filter((l) => l !== ''))
@@ -97,22 +153,26 @@ export function jobStatus(root: string, id?: string): { code: number; lines: str
97
153
  }
98
154
  // it may have written its verdict between the two reads
99
155
  e ??= exit()
100
- if (e == null) return { code: 1, lines: [...lines, `FAIL: run ${job.id} died without a verdict (pid ${job.pid} is gone)`, ` fix: ${ciLine(job.args)}`] }
156
+ if (e == null) return { code: 1, lines: [...lines, `FAIL: run ${job.id} died without a verdict (pid ${job.pid} is gone)`, ` fix: ${ciLine(job.args, job.kind)}`] }
101
157
  const took = `${((e.finishedAt - job.startedAt) / 1000).toFixed(1)}s`
102
158
  lines.push(e.code === 0 ? `ok: run ${job.id} finished green in ${took}` : `FAIL: run ${job.id} finished red (exit ${e.code}) in ${took}`)
103
159
  return { code: e.code, lines }
104
160
  }
105
161
 
106
- /** finished runs older than a week go; a running one never does */
107
- function sweepJobs(root: string): void {
162
+ /** finished runs older than a week go, then the oldest past the count cap;
163
+ * a running one never does */
164
+ export function sweepJobs(root: string, now = Date.now(), o: { keepMs?: number; max?: number } = {}): void {
108
165
  const base = join(root, JOBS)
166
+ let dirs: { dir: string; mtime: number }[]
109
167
  try {
110
- for (const name of readdirSync(base)) {
111
- const dir = join(base, name)
112
- const j = readJson<Job>(join(dir, 'job.json'))
113
- try { if (statSync(dir).mtimeMs > Date.now() - KEEP_MS) continue } catch { continue }
114
- if (j != null && alive(j.pid)) continue
115
- rmSync(dir, { recursive: true, force: true })
116
- }
117
- } catch { /* no jobs yet: nothing to sweep */ }
168
+ dirs = readdirSync(base).flatMap((name) => {
169
+ try { return [{ dir: join(base, name), mtime: statSync(join(base, name)).mtimeMs }] } catch { return [] }
170
+ })
171
+ } catch { return /* no jobs yet: nothing to sweep */ }
172
+ dirs.sort((a, b) => b.mtime - a.mtime).forEach((d, i) => {
173
+ if (d.mtime > now - (o.keepMs ?? JOBS_KEEP_MS) && i < (o.max ?? JOBS_MAX)) return
174
+ const j = readJson<Job>(join(d.dir, 'job.json'))
175
+ if (j != null && alive(j.pid)) return
176
+ try { rmSync(d.dir, { recursive: true, force: true }) } catch { /* raced: gone either way */ }
177
+ })
118
178
  }
@@ -17,6 +17,7 @@
17
17
  // It reads ONLY through the check context it is handed, so SK016 is cached
18
18
  // on the listing of check files and their sources.
19
19
  import { basename, join, relative, resolve } from 'path'
20
+ import { statementEnd } from './statement-end.ts'
20
21
  import { fallbackNames, isCheckFilePath, readLegacyCheck, type LegacyCheck } from './check-file-names.ts'
21
22
  import type { ScanCtx } from './check-ctx.ts'
22
23
  import type { RepoConfig } from '../repo-config.ts'
@@ -177,20 +178,6 @@ export const checkFileName = (id: string): string => `${id.replace(/[^A-Za-z0-9.
177
178
 
178
179
  // ── editing repo.config.ts ───────────────────────────────────────────────
179
180
 
180
- /** a statement that starts on `i`, ending where its brackets balance and the
181
- * line is not obviously continued. Deliberately simple: anything it cannot
182
- * read lands as a hand pass rather than as a half rewritten config. */
183
- function statementEnd(lines: string[], i: number): number {
184
- let depth = 0
185
- for (let j = i; j < lines.length; j++) {
186
- for (const ch of lines[j]!) {
187
- if (ch === '(' || ch === '[' || ch === '{') depth++
188
- else if (ch === ')' || ch === ']' || ch === '}') depth--
189
- }
190
- if (depth <= 0) return j
191
- }
192
- return lines.length - 1
193
- }
194
181
 
195
182
  /** the config text without one top level key, its trailing comma and the
196
183
  * comment block written above it. Null when the key is not there, or when it