rvlib-shipkit 0.38.1 → 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.
Files changed (252) hide show
  1. package/CHANGELOG.md +367 -16
  2. package/README.md +26 -14
  3. package/package.json +2 -2
  4. package/src/assets/gen/comfyts-gen.ts +27 -10
  5. package/src/assets/gen/generate-roll.ts +1 -1
  6. package/src/assets/kit-config.ts +6 -6
  7. package/src/assets/run-assets.ts +14 -10
  8. package/src/assets/server/studio-server.ts +6 -5
  9. package/src/check.ts +343 -0
  10. package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
  11. package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
  12. package/src/checks/SK003-claude-md-shape.check.ts +66 -0
  13. package/src/checks/SK004-gitignore-policy.check.ts +309 -0
  14. package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
  15. package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
  16. package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
  17. package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
  18. package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
  19. package/src/checks/SK010-undeclared-unit.check.ts +111 -0
  20. package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
  21. package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
  22. package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
  23. package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
  24. package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
  25. package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
  26. package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
  27. package/src/checks/SK018-test-files-run.check.ts +28 -0
  28. package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
  29. package/src/checks/SK020-self-spawning-check.check.ts +147 -0
  30. package/src/checks/contracts.check.ts +20 -0
  31. package/src/cli/cmd_Asks.ts +3 -2
  32. package/src/cli/cmd_Assets.ts +2 -2
  33. package/src/cli/cmd_Bump.ts +1 -1
  34. package/src/cli/cmd_Check.ts +6 -6
  35. package/src/cli/cmd_Checks.ts +95 -0
  36. package/src/cli/cmd_Ci.ts +48 -13
  37. package/src/cli/cmd_Commands.ts +2 -6
  38. package/src/cli/cmd_ConfigKeys.ts +69 -0
  39. package/src/cli/cmd_Contracts.ts +1 -1
  40. package/src/cli/cmd_Fleet.ts +6 -2
  41. package/src/cli/cmd_Hooks.ts +1 -1
  42. package/src/cli/cmd_Keys.ts +43 -18
  43. package/src/cli/cmd_Mv.ts +2 -2
  44. package/src/cli/cmd_Prose.ts +10 -1
  45. package/src/cli/cmd_Releases.ts +2 -2
  46. package/src/cli/cmd_Serve.ts +1 -1
  47. package/src/cli/cmd_Start.ts +6 -6
  48. package/src/cli/cmd_Status.ts +25 -0
  49. package/src/cli/cmd_Tui.ts +2 -2
  50. package/src/cli.ts +7 -4
  51. package/src/collections/actions.ts +4 -4
  52. package/src/collections/checks.ts +258 -0
  53. package/src/collections/collection-types.ts +254 -1
  54. package/src/collections/comfy.ts +23 -0
  55. package/src/collections/md-body.ts +14 -0
  56. package/src/collections/pings.ts +38 -12
  57. package/src/collections/posts.ts +19 -3
  58. package/src/collections/providers.ts +198 -91
  59. package/src/collections/reflections.ts +41 -0
  60. package/src/collections/state-rules.ts +73 -0
  61. package/src/collections/test-time.ts +288 -0
  62. package/src/collections/tests.ts +181 -0
  63. package/src/collections/wikis.ts +66 -0
  64. package/src/comfy/comfy-panel.ts +292 -0
  65. package/src/contracts/apply-contracts.ts +17 -12
  66. package/src/contracts/contract-types.ts +16 -4
  67. package/src/contracts/contracts-engine.ts +78 -42
  68. package/src/contracts/manifests/discord.contract.ts +6 -6
  69. package/src/contracts/manifests/identity.contract.ts +3 -3
  70. package/src/contracts/manifests/itch.contract.ts +12 -15
  71. package/src/contracts/manifests/social.contract.ts +5 -5
  72. package/src/contracts/manifests/steam.contract.ts +16 -16
  73. package/src/contracts/run-contracts.ts +4 -3
  74. package/src/core/attach.ts +96 -0
  75. package/src/core/canonical-scripts.ts +25 -0
  76. package/src/core/check-ctx.ts +204 -14
  77. package/src/core/check-file-names.ts +112 -0
  78. package/src/core/check-pass.ts +84 -0
  79. package/src/core/check-runner.ts +796 -0
  80. package/src/core/config-keys.ts +256 -0
  81. package/src/core/config-rewrite.ts +77 -0
  82. package/src/core/discover-checks.ts +166 -90
  83. package/src/core/discover-legacy-checks.ts +26 -0
  84. package/src/core/doc-block.ts +13 -0
  85. package/src/core/floor-resolve.ts +264 -0
  86. package/src/core/floor.ts +20 -137
  87. package/src/core/git-bin.ts +24 -0
  88. package/src/core/git-root.ts +108 -0
  89. package/src/core/instance.ts +195 -0
  90. package/src/core/jobs.ts +118 -0
  91. package/src/core/layout.ts +52 -0
  92. package/src/core/legacy-shapes.ts +207 -0
  93. package/src/core/lib-root.ts +5 -0
  94. package/src/core/locked-write.ts +110 -39
  95. package/src/core/machine-commit.ts +5 -4
  96. package/src/core/migrate-layout.ts +557 -0
  97. package/src/core/motherhome.ts +2 -8
  98. package/src/core/nesting.ts +4 -12
  99. package/src/core/one-way-in-policy.ts +73 -17
  100. package/src/core/outcome-words.ts +45 -0
  101. package/src/core/package-manager.ts +86 -0
  102. package/src/core/parallel.ts +21 -0
  103. package/src/core/process-tree.ts +44 -0
  104. package/src/core/raw-access.ts +93 -0
  105. package/src/core/read-set.ts +152 -0
  106. package/src/core/run-check.ts +155 -213
  107. package/src/core/run-ci.ts +153 -189
  108. package/src/core/run-fix.ts +96 -0
  109. package/src/core/run-fixers.ts +53 -30
  110. package/src/core/self-update.ts +9 -3
  111. package/src/core/slots.ts +83 -0
  112. package/src/core/spawn-timeout.ts +34 -3
  113. package/src/core/when-block.ts +401 -0
  114. package/src/core/when.ts +135 -0
  115. package/src/core/write-status.ts +5 -2
  116. package/src/hooks/leak-guard.ts +10 -8
  117. package/src/hooks/one-way-in-guard.ts +13 -5
  118. package/src/hooks/root-identity.ts +2 -1
  119. package/src/hooks/run-hooks.ts +35 -23
  120. package/src/init-repo.ts +39 -23
  121. package/src/ledger/check-stats.ts +155 -0
  122. package/src/ledger/check-table.ts +179 -0
  123. package/src/ledger/db.ts +255 -0
  124. package/src/ledger/gate-checks.ts +433 -0
  125. package/src/ledger/git-key.ts +116 -77
  126. package/src/ledger/git-scan.ts +96 -35
  127. package/src/ledger/ledger.ts +116 -42
  128. package/src/ledger/live-runs.ts +111 -0
  129. package/src/ledger/live-timeline.ts +97 -0
  130. package/src/ledger/repo-view.ts +73 -0
  131. package/src/ledger/run-log.ts +448 -0
  132. package/src/ledger/run-timeline.ts +173 -0
  133. package/src/ledger/store.ts +23 -12
  134. package/src/ledger/suite-log.ts +161 -0
  135. package/src/ledger/toolchain-identity.ts +7 -1
  136. package/src/ledger/world-cache.ts +111 -0
  137. package/src/mv/mv-md.ts +16 -6
  138. package/src/problems/extract.ts +182 -0
  139. package/src/problems/problem-board.ts +37 -0
  140. package/src/problems/problem-line.ts +77 -0
  141. package/src/prose/prose-view.ts +90 -0
  142. package/src/prose/run-prose.ts +60 -19
  143. package/src/prose/ste.ts +140 -24
  144. package/src/reflections/reflections-queue.ts +81 -0
  145. package/src/release/git-facts.ts +15 -9
  146. package/src/release/run-bump.ts +6 -5
  147. package/src/release/run-release.ts +25 -11
  148. package/src/release/run-releases.ts +2 -2
  149. package/src/release/unit-gate.ts +7 -54
  150. package/src/release/unit-resolve.ts +282 -0
  151. package/src/release/units.ts +33 -217
  152. package/src/repo-config.ts +155 -85
  153. package/src/rules/rule.ts +36 -96
  154. package/src/rules/untrack-generated.ts +5 -4
  155. package/src/scaffold/files/vscode-tasks.json +10 -31
  156. package/src/scaffold/jsonc-spans.ts +113 -0
  157. package/src/scaffold/project-scaffold.ts +16 -43
  158. package/src/scan.ts +98 -0
  159. package/src/serve/gate-trigger.ts +148 -0
  160. package/src/serve/health-schedule.ts +102 -0
  161. package/src/serve/hub-app/chart-view.ts +179 -0
  162. package/src/serve/hub-app/check-view.tsx +345 -0
  163. package/src/serve/hub-app/check-words.ts +164 -0
  164. package/src/serve/hub-app/checks-svc.ts +280 -0
  165. package/src/serve/hub-app/clock-svc.ts +33 -0
  166. package/src/serve/hub-app/col-card.tsx +23 -39
  167. package/src/serve/hub-app/cols-svc.ts +44 -20
  168. package/src/serve/hub-app/comfy-svc.ts +171 -0
  169. package/src/serve/hub-app/comfy-view.tsx +81 -0
  170. package/src/serve/hub-app/dur.ts +41 -0
  171. package/src/serve/hub-app/hash-route.ts +121 -0
  172. package/src/serve/hub-app/header-bar.tsx +117 -0
  173. package/src/serve/hub-app/health-svc.ts +23 -28
  174. package/src/serve/hub-app/help.tsx +29 -0
  175. package/src/serve/hub-app/hub-app.tsx +122 -119
  176. package/src/serve/hub-app/hub-st.ts +115 -42
  177. package/src/serve/hub-app/hub-types.ts +117 -2
  178. package/src/serve/hub-app/hub.css +534 -192
  179. package/src/serve/hub-app/index.html +2 -0
  180. package/src/serve/hub-app/live-svc.ts +174 -0
  181. package/src/serve/hub-app/md.ts +15 -0
  182. package/src/serve/hub-app/media-svc.ts +4 -1
  183. package/src/serve/hub-app/media-view.tsx +15 -0
  184. package/src/serve/hub-app/next-run.ts +68 -0
  185. package/src/serve/hub-app/project-gutter.css +65 -0
  186. package/src/serve/hub-app/project-gutter.tsx +128 -0
  187. package/src/serve/hub-app/projects-svc.ts +226 -0
  188. package/src/serve/hub-app/prose-hits-st.ts +124 -0
  189. package/src/serve/hub-app/prose-hits-view.tsx +176 -0
  190. package/src/serve/hub-app/prose-hits.css +21 -0
  191. package/src/serve/hub-app/run-strip.ts +47 -0
  192. package/src/serve/hub-app/runs-tabs.ts +24 -0
  193. package/src/serve/hub-app/test-view.tsx +107 -0
  194. package/src/serve/hub-app/tests-svc.ts +62 -0
  195. package/src/serve/hub-app/tests-view.tsx +238 -0
  196. package/src/serve/hub-app/time-ago.tsx +34 -0
  197. package/src/serve/hub-app/timeline-chart.tsx +274 -0
  198. package/src/serve/hub-app/timeline-geometry.ts +314 -0
  199. package/src/serve/hub-app/timeline-svc.ts +62 -0
  200. package/src/serve/hub-app/timeline-view.tsx +245 -0
  201. package/src/serve/hub-app/tree-build.ts +410 -0
  202. package/src/serve/hub-app/tree-row.tsx +42 -0
  203. package/src/serve/hub-app/tree-svc.ts +174 -0
  204. package/src/serve/hub-app/wikis-view.tsx +53 -0
  205. package/src/serve/other-repos.ts +79 -0
  206. package/src/serve/project-gutter-route.ts +350 -0
  207. package/src/serve/project-gutter.ts +449 -0
  208. package/src/serve/run-serve.ts +245 -16
  209. package/src/serve/same-origin.ts +14 -0
  210. package/src/serve/serve-hub.ts +213 -18
  211. package/src/shipkit.ts +19 -12
  212. package/src/smoke/run-smoke.ts +3 -2
  213. package/src/start-repo.ts +75 -36
  214. package/src/suite/judge-suites.ts +209 -15
  215. package/src/suite/kept-timings.ts +58 -0
  216. package/src/suite/runner-shapes.ts +88 -0
  217. package/src/suite/suite-cli.ts +147 -24
  218. package/src/suite/suite-cpu-preload.ts +92 -0
  219. package/src/suite/test-units.ts +243 -0
  220. package/src/ts/config.ts +4 -69
  221. package/src/ts/jsonc.ts +69 -0
  222. package/src/ts/lex.ts +143 -0
  223. package/src/ts/rename.ts +73 -10
  224. package/src/ts/source-graph.ts +28 -8
  225. package/src/ts/tsconfig-projects.ts +6 -97
  226. package/src/ts/tsconfig-resolve.ts +108 -0
  227. package/src/tui/build-steps-panel.ts +61 -0
  228. package/src/tui/checks-panel.ts +24 -16
  229. package/src/tui/run-tui.ts +2 -2
  230. package/src/tui/tui-panel.ts +5 -4
  231. package/src/wiki/graph-colors.ts +68 -0
  232. package/src/wiki/graph-mount.ts +7 -9
  233. package/src/wiki/graph-page.ts +42 -25
  234. package/src/wiki/graph-positions.ts +105 -0
  235. package/src/wiki/run-wiki.ts +1 -1
  236. package/src/wiki/serve-graph.ts +33 -3
  237. package/src/cli/cmd_Rules.ts +0 -51
  238. package/src/cli/cmd_Units.ts +0 -82
  239. package/src/collections/graphs.ts +0 -32
  240. package/src/contracts/contracts-once.ts +0 -43
  241. package/src/core/check-suite.ts +0 -302
  242. package/src/core/repair-contract-dirs.ts +0 -136
  243. package/src/core/task-runner.ts +0 -339
  244. package/src/ledger/gate-units.ts +0 -138
  245. package/src/ledger/unit-table.ts +0 -105
  246. package/src/rules/SK002-contracts-fulfillness.ts +0 -24
  247. package/src/rules/SK003-claude-md-shape.ts +0 -70
  248. package/src/rules/SK004-gitignore-policy.ts +0 -238
  249. package/src/rules/SK010-undeclared-release-unit.ts +0 -127
  250. package/src/rules/rule-registry.ts +0 -64
  251. package/src/rules/run-fix.ts +0 -72
  252. package/src/tui/tasks-panel.ts +0 -60
@@ -0,0 +1,182 @@
1
+ // file:line findings out of what a check printed, one function per format the
2
+ // floor meets: tsc, oxlint (both its piped and its graphical output), eslint's
3
+ // stylish output, a failing bun test, and a shipkit wire line naming a file.
4
+ // Every one feeds the one Problem shape (problem-line.ts).
5
+ //
6
+ // a finding the source cannot anchor in a file is dropped rather than pinned
7
+ // to a guess: the pane is for things an edit can fix, and the check's own
8
+ // page still carries every line it printed.
9
+ import { existsSync } from 'fs'
10
+ import { isAbsolute, relative, resolve } from 'path'
11
+ import type { Line } from '../core/wire.ts'
12
+ import type { Problem, Severity } from './problem-line.ts'
13
+
14
+ export type ExtractCtx = {
15
+ /** the repo root every file is made relative to */
16
+ root: string
17
+ /** where the tool ran, for the relative paths it printed (default: root) */
18
+ cwd?: string
19
+ /** the check id each finding is filed under */
20
+ check: string
21
+ /** is this file there? Injected so a fixture needs no tree on disk */
22
+ exists?: (abs: string) => boolean
23
+ }
24
+
25
+ const stripAnsi = (s: string): string => s.replace(/\x1b\[[0-9;]*m/g, '').replace(/\r/g, '')
26
+
27
+ /** repo-relative, forward slashes. null when the path is not a file this
28
+ * repo has, which is what keeps a url or a `host:port` out of the pane */
29
+ function repoFile(ctx: ExtractCtx, raw: string, mustExist: boolean): string | null {
30
+ const p = raw.trim().replace(/^file:\/\//, '')
31
+ if (p === '') return null
32
+ const abs = isAbsolute(p) ? p : resolve(ctx.cwd ?? ctx.root, p)
33
+ const rel = relative(ctx.root, abs).replace(/\\/g, '/')
34
+ if (rel === '' || rel.startsWith('../') || isAbsolute(rel)) return null
35
+ if (mustExist && !(ctx.exists ?? existsSync)(abs)) return null
36
+ return rel
37
+ }
38
+
39
+ const TSC = /^(.+?)\((\d+),(\d+)\): (error|warning) (TS\d+): (.*)$/
40
+
41
+ /** `src/a.ts(3,7): error TS2322: msg`, and the indented elaboration lines
42
+ * under it, joined into the one message */
43
+ export function fromTsc(text: string, ctx: ExtractCtx): Problem[] {
44
+ const out: Problem[] = []
45
+ let last: Problem | null = null
46
+ let depth = 0
47
+ const indent = (s: string): number => /^\s*/.exec(s)![0].length
48
+ for (const raw of stripAnsi(text).split('\n')) {
49
+ const m = TSC.exec(raw)
50
+ if (m != null) {
51
+ const file = repoFile(ctx, m[1]!, false)
52
+ last = file == null ? null : { file, line: Number(m[2]), col: Number(m[3]), severity: m[4] as Severity, check: ctx.check, message: `${m[5]} ${m[6]!.trim()}` }
53
+ depth = indent(raw)
54
+ if (last != null) out.push(last)
55
+ continue
56
+ }
57
+ // an elaboration sits deeper than the diagnostic it belongs to
58
+ if (last != null && raw.trim() !== '' && indent(raw) > depth) last.message += ` ${raw.trim()}`
59
+ else last = null
60
+ }
61
+ return out
62
+ }
63
+
64
+ const OXLINT_PIPED = /^(.+?):(\d+):(\d+): (error|warning) (\S+?): (.*)$/
65
+ const OXLINT_HEAD = /^\s*(x|!|×|⚠)\s+(.+)$/
66
+ const OXLINT_AT = /^\s*(?:,-|╭─)\[(.+):(\d+):(\d+)\]\s*$/
67
+
68
+ /** oxlint piped (`src/a.ts:1:7: warning eslint(no-unused-vars): msg`, what
69
+ * the gate sees) and graphical (a `x rule: msg` head, then `,-[file:l:c]`) */
70
+ export function fromOxlint(text: string, ctx: ExtractCtx): Problem[] {
71
+ const out: Problem[] = []
72
+ let head: { severity: Severity; message: string } | null = null
73
+ for (const raw of stripAnsi(text).split('\n')) {
74
+ const piped = OXLINT_PIPED.exec(raw)
75
+ if (piped != null && /\(|\//.test(piped[5]!)) {
76
+ const file = repoFile(ctx, piped[1]!, false)
77
+ if (file != null) out.push({ file, line: Number(piped[2]), col: Number(piped[3]), severity: piped[4] as Severity, check: ctx.check, message: `${piped[5]} ${piped[6]!.replace(/\s+help: .*$/, '').trim()}` })
78
+ continue
79
+ }
80
+ const h = OXLINT_HEAD.exec(raw)
81
+ if (h != null) { head = { severity: h[1] === 'x' || h[1] === '×' ? 'error' : 'warning', message: h[2]!.trim() }; continue }
82
+ const at = OXLINT_AT.exec(raw)
83
+ if (at != null && head != null) {
84
+ const file = repoFile(ctx, at[1]!, false)
85
+ if (file != null) out.push({ file, line: Number(at[2]), col: Number(at[3]), severity: head.severity, check: ctx.check, message: head.message })
86
+ head = null
87
+ }
88
+ }
89
+ return out
90
+ }
91
+
92
+ const ESLINT_HEAD = /^(?:\/|[A-Za-z]:\\)[^:()]*\.\w+\s*$/
93
+ const ESLINT_ROW = /^\s+(\d+):(\d+)\s+(error|warning)\s+(.*?)(?:\s{2,}(\S+))?\s*$/
94
+
95
+ /** eslint stylish: a file path alone on its line, then ` 2:7 error msg rule` */
96
+ export function fromEslint(text: string, ctx: ExtractCtx): Problem[] {
97
+ const out: Problem[] = []
98
+ let file: string | null = null
99
+ for (const raw of stripAnsi(text).split('\n')) {
100
+ if (raw.trim() === '') { file = null; continue }
101
+ // stylish always prints the header as an absolute path, alone
102
+ if (!/^\s/.test(raw)) { file = ESLINT_HEAD.test(raw) ? repoFile(ctx, raw, false) : null; continue }
103
+ const m = ESLINT_ROW.exec(raw)
104
+ if (m == null || file == null) continue
105
+ const rule = m[5] == null ? '' : `${m[5]} `
106
+ out.push({ file, line: Number(m[1]), col: Number(m[2]), severity: m[3] as Severity, check: ctx.check, message: `${rule}${m[4]!.trim()}` })
107
+ }
108
+ return out
109
+ }
110
+
111
+ const BUN_SUITE = /^(\S.*\.(?:test|spec)\.[cm]?[jt]sx?):$/
112
+ const BUN_ERROR = /^(?:error|\w*Error): (.*)$/
113
+ const BUN_FRAME = /^\s+at .*?\(?((?:\/|[A-Za-z]:\\|\.{0,2}\/?[\w@.-]+\/)[^()]*?):(\d+):(\d+)\)?\s*$/
114
+ const BUN_FAIL = /^\(fail\) (.+?)(?: \[[\d.]+m?s\])?$/
115
+
116
+ /** a failing bun test: the `error:` line, the first stack frame inside this
117
+ * repo and outside node_modules, and the `(fail) name` line that closes it.
118
+ * With no frame in the repo it anchors at line 1 of the suite file */
119
+ export function fromBunTest(text: string, ctx: ExtractCtx): Problem[] {
120
+ const out: Problem[] = []
121
+ let suite: string | null = null
122
+ let error: string | null = null
123
+ let at: { file: string; line: number; col: number } | null = null
124
+ for (const raw of stripAnsi(text).split('\n')) {
125
+ const s = BUN_SUITE.exec(raw)
126
+ if (s != null) { suite = repoFile(ctx, s[1]!, false); error = null; at = null; continue }
127
+ const e = BUN_ERROR.exec(raw)
128
+ if (e != null) { error = e[1]!.trim(); at = null; continue }
129
+ const f = BUN_FRAME.exec(raw)
130
+ if (f != null && error != null && at == null && !f[1]!.includes('node_modules')) {
131
+ const file = repoFile(ctx, f[1]!, false)
132
+ if (file != null) at = { file, line: Number(f[2]), col: Number(f[3]) }
133
+ continue
134
+ }
135
+ const fail = BUN_FAIL.exec(raw)
136
+ if (fail != null) {
137
+ const where = at ?? (suite == null ? null : { file: suite, line: 1, col: 1 })
138
+ if (where != null) out.push({ ...where, severity: 'error', check: ctx.check, message: `${fail[1]!.trim()}${error == null ? '' : `: ${error}`}` })
139
+ error = null
140
+ at = null
141
+ }
142
+ }
143
+ return out
144
+ }
145
+
146
+ const WIRE_REF = /((?:[\w@.~-]+\/)*[\w@.~-]+\.[A-Za-z0-9]+):(\d+)(?::(\d+))?/g
147
+
148
+ /** a FAIL or WARN wire line naming `path:line` or `path:line:col`. The path
149
+ * must be a file this repo has: a url and a `host:port` look the same */
150
+ export function fromWire(lines: Line[], ctx: ExtractCtx): Problem[] {
151
+ const out: Problem[] = []
152
+ for (const l of lines) {
153
+ if (l.kind !== 'fail' && l.kind !== 'warn') continue
154
+ const severity: Severity = l.kind === 'fail' ? 'error' : 'warning'
155
+ const message = l.text.replace(/^\[[^\]]+\]\s*/, '').trim()
156
+ for (const m of l.text.matchAll(WIRE_REF)) {
157
+ const before = l.text.slice(0, m.index)
158
+ if (/[a-z]+:\/\/\S*$/i.test(before)) continue
159
+ const file = repoFile(ctx, m[1]!, true)
160
+ if (file == null) continue
161
+ out.push({ file, line: Number(m[2]), col: m[3] == null ? 1 : Number(m[3]), severity, check: ctx.check, message })
162
+ }
163
+ }
164
+ return out
165
+ }
166
+
167
+ /** every format, over what one check printed: its wire lines and, for a
168
+ * subprocess, its whole raw output. One finding is reported once */
169
+ export function extractProblems(src: { lines: Line[]; output?: string }, ctx: ExtractCtx): Problem[] {
170
+ const text = src.output ?? ''
171
+ const all = [
172
+ ...fromWire(src.lines, ctx),
173
+ ...(text === '' ? [] : [...fromTsc(text, ctx), ...fromOxlint(text, ctx), ...fromEslint(text, ctx), ...fromBunTest(text, ctx)]),
174
+ ]
175
+ const seen = new Set<string>()
176
+ return all.filter((p) => {
177
+ const k = `${p.file}:${p.line}:${p.col}:${p.severity}:${p.message}`
178
+ if (seen.has(k)) return false
179
+ seen.add(k)
180
+ return true
181
+ })
182
+ }
@@ -0,0 +1,37 @@
1
+ // the latest verdict of every check, as the problems pane shows it. A run that
2
+ // asked about three checks replaces those three and leaves the rest standing:
3
+ // vscode clears the whole pane at every frame, so a narrowed run that printed
4
+ // only its own findings would erase every other check's.
5
+ import { frameLines, type Problem } from './problem-line.ts'
6
+
7
+ export class ProblemBoard {
8
+ private readonly byCheck = new Map<string, Problem[]>()
9
+
10
+ /** a check reached a verdict. What it carries has already passed the pane
11
+ * rule (problem-line.ts feedsProblemsPane), so an empty list clears it */
12
+ set(check: string, problems: Problem[]): void {
13
+ if (problems.length === 0) this.byCheck.delete(check)
14
+ else this.byCheck.set(check, problems)
15
+ }
16
+
17
+ /** every finding standing, sorted by file then line, so a frame reads the
18
+ * same whatever order the checks finished in */
19
+ all(): Problem[] {
20
+ return [...this.byCheck.values()].flat().sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line || a.col - b.col || a.check.localeCompare(b.check))
21
+ }
22
+
23
+ /** one check's verdict, as the runner reports it. A skip is no verdict
24
+ * and what the check last said stands, except a check turned off, which
25
+ * has nothing to say anymore. Returns whether the board moved */
26
+ take(e: { id: string; status: string; note?: string; problems?: Problem[] }): boolean {
27
+ if (e.status === 'skip' && e.note !== 'off') return false
28
+ const next = e.status === 'skip' ? [] : e.problems ?? []
29
+ const before = JSON.stringify(this.byCheck.get(e.id) ?? [])
30
+ this.set(e.id, next)
31
+ return JSON.stringify(next) !== before
32
+ }
33
+
34
+ frame(): string[] {
35
+ return frameLines(this.all())
36
+ }
37
+ }
@@ -0,0 +1,77 @@
1
+ // the ONE shape a problem takes on the hub terminal, and the vscode problem
2
+ // matcher that reads it back. The printer and every tasks.json writer import
3
+ // this file, so the line the hub prints and the regex the editor matches can
4
+ // never drift apart.
5
+ //
6
+ // a run prints one FRAME: the begin line, one problem line per finding, the
7
+ // end line. The matcher is a background one, so vscode clears the pane at the
8
+ // begin line and refills it from what sits before the end line.
9
+
10
+ export type Severity = 'error' | 'warning'
11
+
12
+ /** one finding, anchored in a file of this repo */
13
+ export type Problem = {
14
+ /** repo-relative, forward slashes */
15
+ file: string
16
+ line: number
17
+ /** 1 when the source names no column */
18
+ col: number
19
+ severity: Severity
20
+ /** the check that produced it, printed where vscode shows the code */
21
+ check: string
22
+ /** one line, no newline */
23
+ message: string
24
+ }
25
+
26
+ export const FRAME_BEGIN = 'shipkit problems: begin'
27
+ const FRAME_END_HEAD = 'shipkit problems: end'
28
+
29
+ export function frameEnd(problems: Problem[]): string {
30
+ const errors = problems.filter((p) => p.severity === 'error').length
31
+ return `${FRAME_END_HEAD}, ${errors} error(s), ${problems.length - errors} warning(s)`
32
+ }
33
+
34
+ export const BEGINS_PATTERN = `^${FRAME_BEGIN}$`
35
+ export const ENDS_PATTERN = `^${FRAME_END_HEAD}\\b`
36
+
37
+ /** file, line, column, severity, check id, message. The file is lazy and the
38
+ * check id cannot hold a space, so a path with spaces and an id with a colon
39
+ * (`imports:check`) both split right */
40
+ export const PROBLEM_PATTERN = '^(.+?):(\\d+):(\\d+): (error|warning) (\\S+?): (.*)$'
41
+
42
+ export function formatProblem(p: Problem): string {
43
+ const message = p.message.replace(/\s*\n\s*/g, ' ').trim()
44
+ return `${p.file}:${p.line}:${p.col}: ${p.severity} ${p.check}: ${message}`
45
+ }
46
+
47
+ /** the frame, ready to print line by line */
48
+ export function frameLines(problems: Problem[]): string[] {
49
+ return [FRAME_BEGIN, ...problems.map(formatProblem), frameEnd(problems)]
50
+ }
51
+
52
+ /** what a tasks.json hub task carries, byte for byte the same everywhere */
53
+ export function hubProblemMatcher(): Record<string, unknown> {
54
+ return {
55
+ owner: 'shipkit',
56
+ source: 'shipkit',
57
+ fileLocation: ['relative', '${workspaceFolder}'],
58
+ pattern: { regexp: PROBLEM_PATTERN, file: 1, line: 2, column: 3, severity: 4, code: 5, message: 6 },
59
+ background: { activeBegin: false, beginsPattern: BEGINS_PATTERN, endsPattern: ENDS_PATTERN },
60
+ }
61
+ }
62
+
63
+ /** how a check is remembered, read from what it declares */
64
+ export type Population = 'content' | 'world' | 'reads' | 'neither'
65
+
66
+ export function populationOf(d: { inputs?: string[]; ttl?: string; never?: string; reads?: boolean }): Population {
67
+ if (d.ttl != null && d.ttl !== '') return 'world'
68
+ if (d.inputs != null) return 'content'
69
+ if (d.reads === true && (d.never == null || d.never === '')) return 'reads'
70
+ return 'neither'
71
+ }
72
+
73
+ /** THE rule for the pane: the latest verdict of every check feeds it, measured
74
+ * or replayed, except a world check. A world answer (a cred, a domain, a
75
+ * service) is not a problem in the code, and it needs hands rather than an
76
+ * edit, so it stays on the needs surface */
77
+ export const feedsProblemsPane = (p: Population): boolean => p !== 'world'
@@ -0,0 +1,90 @@
1
+ // The SK014 page's data: every hit of the prose check, grouped by the word
2
+ // (or the rule, for the classes that judge a shape), noisiest first, each hit
3
+ // with its file, its line and the sentence around it. It runs the SAME
4
+ // module the rule runs, over the same config, so the page and the gate can
5
+ // never count differently.
6
+ import type { CheckCtx } from '../core/check-ctx.ts'
7
+ import type { RepoConfig } from '../repo-config.ts'
8
+ import { runProseCheck } from './run-prose.ts'
9
+ import type { ProseClass, ProseHit, ProseTier, ProseWords } from './ste.ts'
10
+
11
+ export type ProseViewHit = {
12
+ file: string
13
+ line: number
14
+ /** the source line around the hit, clipped to a readable width */
15
+ excerpt: string
16
+ /** where the hit sits inside `excerpt`, [from, to) */
17
+ mark: [number, number]
18
+ why: string
19
+ fix?: string
20
+ }
21
+
22
+ export type ProseGroup = {
23
+ /** `<class>:<word>` for a word hit, `<class>` for a shape */
24
+ key: string
25
+ cls: ProseClass
26
+ tier: ProseTier
27
+ /** the dictionary word or phrase an allow entry names; null on a shape */
28
+ word: string | null
29
+ count: number
30
+ hits: ProseViewHit[]
31
+ }
32
+
33
+ export type ProseView = {
34
+ /** false when the repo declares no prose scope: the rule skips it */
35
+ scoped: boolean
36
+ files: number
37
+ total: number
38
+ byTier: Record<ProseTier, number>
39
+ groups: ProseGroup[]
40
+ /** the repo's own lists as the config loads them */
41
+ words: ProseWords
42
+ }
43
+
44
+ /** how much of a long line the page shows on each side of the hit */
45
+ const SIDE = 70
46
+
47
+ /** the source line a hit sits on, clipped around it, with the hit's span
48
+ * inside the clip. A hit that runs past its line (a long sentence, a
49
+ * paragraph) marks to the end of the line */
50
+ export function excerptOf(text: string, h: Pick<ProseHit, 'start' | 'end'>): { excerpt: string; mark: [number, number] } {
51
+ const from = text.lastIndexOf('\n', h.start - 1) + 1
52
+ const nl = text.indexOf('\n', h.start)
53
+ const to = nl < 0 ? text.length : nl
54
+ const line = text.slice(from, to)
55
+ let a = h.start - from
56
+ let b = Math.min(h.end, to) - from
57
+ let lo = Math.max(0, a - SIDE)
58
+ const hi = Math.min(line.length, Math.max(b, a) + SIDE)
59
+ // a leading indent is noise in a one line quote
60
+ while (lo < a && /\s/.test(line[lo]!)) lo++
61
+ const head = lo > 0 ? '…' : ''
62
+ const tail = hi < line.length ? '…' : ''
63
+ const excerpt = head + line.slice(lo, hi).trimEnd() + tail
64
+ a = a - lo + head.length
65
+ b = Math.min(b - lo + head.length, excerpt.length - tail.length)
66
+ return { excerpt, mark: [a, Math.max(a, b)] }
67
+ }
68
+
69
+ export function proseView(c: Pick<CheckCtx, 'root' | 'read' | 'list' | 'write'>, config: RepoConfig): ProseView {
70
+ const cfg = config.prose
71
+ const empty: ProseView = { scoped: false, files: 0, total: 0, byTier: { fix: 0, hard: 0, soft: 0 }, groups: [], words: cfg?.words ?? {} }
72
+ if (cfg == null || (cfg.include ?? []).length === 0) return empty
73
+ const r = runProseCheck({ c, config: cfg, keepText: true })
74
+ const groups = new Map<string, ProseGroup>()
75
+ for (const f of r.byFile)
76
+ for (const h of f.hits) {
77
+ const key = h.word == null ? h.cls : `${h.cls}:${h.word}`
78
+ let g = groups.get(key)
79
+ if (g == null) {
80
+ g = { key, cls: h.cls, tier: h.tier, word: h.word ?? null, count: 0, hits: [] }
81
+ groups.set(key, g)
82
+ }
83
+ g.count++
84
+ const ex = excerptOf(f.text ?? '', h)
85
+ g.hits.push({ file: f.file, line: h.line, ...ex, why: h.why, ...(h.fix == null ? {} : { fix: h.fix }) })
86
+ }
87
+ // the noisiest first: one allow entry on the top word clears the most
88
+ const sorted = [...groups.values()].sort((a, b) => b.count - a.count || a.key.localeCompare(b.key))
89
+ return { scoped: true, files: r.files, total: r.hits.length, byTier: r.byTier, groups: sorted, words: cfg.words ?? {} }
90
+ }
@@ -6,10 +6,15 @@
6
6
  // `prose: { include, exempt }` in repo.config.ts. A repo that declares
7
7
  // nothing is not checked, and the rule says so instead of failing a repo
8
8
  // that never opted in.
9
- import { readFileSync, statSync } from 'fs'
10
- import { isAbsolute, join, relative, resolve } from 'path'
11
- import { atomicWrite } from '../core/locked-write.ts'
12
- import { checkProse, fixProse, tierOf, type ProseClass, type ProseHit, type ProseTier } from './ste.ts'
9
+ //
10
+ // It reads and writes ONLY through the check context it is handed, so rule
11
+ // SK014 is cached on the scope's listings and the files in it.
12
+ import { isAbsolute, relative, resolve } from 'path'
13
+ import type { CheckCtx } from '../core/check-ctx.ts'
14
+ import { checkProse, fixProse, tierOf, type ProseClass, type ProseHit, type ProseTier, type ProseWords } from './ste.ts'
15
+
16
+ /** what the walker reads and writes through: a check context */
17
+ export type ProseDisk = Pick<CheckCtx, 'root' | 'read' | 'list' | 'write'>
13
18
 
14
19
  export type ProseConfig = {
15
20
  /** globs (repo-relative) that ARE house prose. Absent = the repo is not checked. */
@@ -20,6 +25,11 @@ export type ProseConfig = {
20
25
  allow?: ProseClass[]
21
26
  /** the 20-word procedural limit instead of the 25-word descriptive one */
22
27
  strict?: boolean
28
+ /** this repo's own word lists over the house dictionary: `allow` drops
29
+ * every hit on a word or phrase, `ban` refuses an extra one (a hard hit,
30
+ * with its replacement). They bind this repo only, and the hub's SK014
31
+ * page writes them through a managed block inside this object */
32
+ words?: ProseWords
23
33
  /** soft-tier ratchet: the count this repo is allowed to carry. The gate
24
34
  * fails ABOVE it, so the number can only go down. Absent is the default,
25
35
  * and it means soft hits warn in `shipkit prose` and never reach a gate
@@ -28,7 +38,13 @@ export type ProseConfig = {
28
38
  baseline?: number
29
39
  }
30
40
 
31
- export type FileReport = { file: string; hits: ProseHit[] }
41
+ export type FileReport = {
42
+ file: string
43
+ hits: ProseHit[]
44
+ /** the file as it was judged, kept only when the caller asked, so a page
45
+ * can quote the line around each hit without reading the file twice */
46
+ text?: string
47
+ }
32
48
 
33
49
  export type ProseReport = {
34
50
  files: number
@@ -58,51 +74,76 @@ export function explicitPath(root: string, p: string): string {
58
74
  return rel
59
75
  }
60
76
 
61
- export function proseFiles(root: string, cfg: ProseConfig): string[] {
77
+ /** does `path` match `pattern` under the dot rule: a hidden segment (one
78
+ * starting with a dot) only where the pattern names a hidden segment itself,
79
+ * never inside a `**` or under a wildcard segment */
80
+ export function hiddenAllowed(pattern: string, path: string): boolean {
81
+ const pat = pattern.split('/')
82
+ const segs = path.split('/')
83
+ const hidden = (s: string): boolean => s.startsWith('.') && s !== '.' && s !== '..'
84
+ const star = pat.indexOf('**')
85
+ const head = star === -1 ? pat : pat.slice(0, star)
86
+ const tail = star === -1 ? [] : pat.slice(pat.lastIndexOf('**') + 1)
87
+ for (let i = 0; i < segs.length; i++) {
88
+ if (!hidden(segs[i]!)) continue
89
+ const fromEnd = segs.length - 1 - i
90
+ const p = i < head.length ? head[i] : fromEnd < tail.length ? tail[tail.length - 1 - fromEnd] : undefined
91
+ if (p == null || !p.startsWith('.')) return false
92
+ }
93
+ return true
94
+ }
95
+
96
+ /** the files in scope: what git sees (tracked, or untracked and not ignored)
97
+ * matching an include glob, the dot rule applied, minus every exempt glob */
98
+ export function proseFiles(d: Pick<ProseDisk, 'list'>, cfg: ProseConfig): string[] {
62
99
  const include = cfg.include ?? []
63
100
  if (include.length === 0) return []
64
101
  const exempt = [...DEFAULT_EXEMPT, ...(cfg.exempt ?? [])].map((g) => new Bun.Glob(g))
65
102
  const seen = new Set<string>()
66
103
  for (const pattern of include)
67
- for (const f of new Bun.Glob(pattern).scanSync({ cwd: root, onlyFiles: true, dot: false })) {
68
- const rel = f.split('\\').join('/')
104
+ for (const rel of d.list(pattern)) {
105
+ if (!hiddenAllowed(pattern, rel)) continue
69
106
  if (exempt.some((g) => g.match(rel))) continue
70
107
  seen.add(rel)
71
108
  }
72
109
  return [...seen].sort()
73
110
  }
74
111
 
75
- export function runProseCheck(o: { root: string; config: ProseConfig; paths?: string[]; fix?: boolean }): ProseReport {
112
+ export function runProseCheck(o: { c: ProseDisk; config: ProseConfig; paths?: string[]; fix?: boolean; keepText?: boolean }): ProseReport {
76
113
  const cfg = o.config
77
- const files = o.paths != null && o.paths.length > 0 ? o.paths.map((p) => explicitPath(o.root, p)) : proseFiles(o.root, cfg)
114
+ const d = o.c
115
+ const files = o.paths != null && o.paths.length > 0 ? o.paths.map((p) => explicitPath(d.root, p)) : proseFiles(d, cfg)
78
116
  const explicit = o.paths != null && o.paths.length > 0
79
- const opts = { allow: cfg.allow, strict: cfg.strict }
117
+ const opts = { allow: cfg.allow, strict: cfg.strict, words: cfg.words }
80
118
  const byFile: FileReport[] = []
81
119
  const hits: ProseHit[] = []
82
120
  const fixed: string[] = []
83
121
  for (const rel of files) {
84
- const abs = join(o.root, rel)
85
- let text: string
122
+ let text: string | null
86
123
  try {
87
- text = readFileSync(abs, 'utf8')
124
+ text = d.read(rel)
88
125
  } catch (e) {
89
- // a path a HUMAN typed that cannot be read is a loud failure. A
90
- // path the glob produced can vanish between the scan and the read,
91
- // and skipping that one is right.
126
+ text = null
92
127
  if (explicit) throw new Error(`FAIL cannot read ${rel} — ${String(e)}`)
128
+ }
129
+ if (text == null) {
130
+ // a path a HUMAN typed that cannot be read is a loud failure. A
131
+ // path the listing produced can vanish between the scan and the
132
+ // read, and skipping that one is right.
133
+ if (explicit) throw new Error(`FAIL cannot read ${rel} — no such file`)
93
134
  continue
94
135
  }
95
136
  if (isGenerated(text)) continue
96
137
  if (o.fix === true) {
97
138
  const out = fixProse(text, opts)
98
139
  if (out.applied > 0) {
99
- atomicWrite(abs, out.text)
140
+ d.write(rel, out.text)
100
141
  fixed.push(rel)
101
142
  text = out.text
102
143
  }
103
144
  }
104
145
  const fh = checkProse(text, opts)
105
- if (fh.length > 0) byFile.push({ file: rel, hits: fh })
146
+ if (fh.length > 0) byFile.push({ file: rel, hits: fh, ...(o.keepText === true ? { text } : {}) })
106
147
  hits.push(...fh)
107
148
  }
108
149
  const byTier: Record<ProseTier, number> = { fix: 0, hard: 0, soft: 0 }