rvlib-shipkit 1.8.0 → 2.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 (127) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/check.ts +5 -2
  10. package/src/checks/SK001-package-json-shape.check.ts +47 -12
  11. package/src/checks/SK013-one-way-in-policy.check.ts +8 -7
  12. package/src/checks/SK017-vscode-hub-task.check.ts +109 -106
  13. package/src/checks/SK019-tests-run-in-units.check.ts +2 -1
  14. package/src/checks/SK027-agent-wiki-links.check.ts +5 -2
  15. package/src/checks/SK029-explicit-check-files.check.ts +25 -8
  16. package/src/checks/SK034-commit-gates-armed.check.ts +90 -0
  17. package/src/cli/cmd_Ci.ts +6 -5
  18. package/src/cli/cmd_Commands.ts +21 -5
  19. package/src/cli/cmd_Hooks.ts +6 -2
  20. package/src/cli/cmd_Init.ts +1 -1
  21. package/src/cli/cmd_Serve.ts +1 -2
  22. package/src/cli/cmd_Start.ts +1 -1
  23. package/src/cli.ts +2 -4
  24. package/src/collections/providers.ts +1 -1
  25. package/src/core/budget.ts +6 -8
  26. package/src/core/bun-builtins.ts +36 -0
  27. package/src/core/bun-tool-cli.ts +23 -0
  28. package/src/core/canonical-scripts.ts +6 -4
  29. package/src/core/check-ctx.ts +104 -10
  30. package/src/core/check-file-names.ts +2 -3
  31. package/src/core/check-pass.ts +17 -1
  32. package/src/core/check-runner.ts +32 -10
  33. package/src/core/config-rewrite.ts +27 -6
  34. package/src/core/discover-checks.ts +64 -15
  35. package/src/core/fix-scope.ts +5 -0
  36. package/src/core/floor-resolve.ts +18 -8
  37. package/src/core/git-root.ts +22 -12
  38. package/src/core/job-run.ts +2 -2
  39. package/src/core/layout.ts +4 -1
  40. package/src/core/legacy-shapes.ts +40 -0
  41. package/src/core/one-lib.ts +5 -3
  42. package/src/core/one-way-in-policy.ts +106 -13
  43. package/src/core/pre-commit-discovery.ts +106 -0
  44. package/src/core/retired-checks.ts +2 -0
  45. package/src/core/run-ci.ts +23 -16
  46. package/src/core/run-fix.ts +9 -0
  47. package/src/core/run-fixers.ts +91 -35
  48. package/src/core/runs-matrix.ts +7 -0
  49. package/src/core/slots.ts +36 -6
  50. package/src/core/vendored-read.ts +39 -0
  51. package/src/core/vendored.ts +82 -0
  52. package/src/core/waivers.ts +125 -0
  53. package/src/core/when-block.ts +25 -185
  54. package/src/core/when.ts +30 -13
  55. package/src/core/wire.ts +17 -1
  56. package/src/git-bootstrap.ts +1 -1
  57. package/src/home/attach.ts +3 -5
  58. package/src/home/gui-app/ShipkitGui.swift +96 -0
  59. package/src/home/insights.ts +6 -1
  60. package/src/home/pm-config.ts +2 -2
  61. package/src/home/resources.ts +3 -0
  62. package/src/home/run-daemon.ts +2 -2
  63. package/src/home/run-unit-action.ts +2 -2
  64. package/src/hooks/commit-message.ts +2 -14
  65. package/src/hooks/hook-gates.ts +38 -0
  66. package/src/hooks/leak-guard.ts +8 -3
  67. package/src/hooks/one-way-in-guard.ts +8 -2
  68. package/src/hooks/root-identity.ts +9 -6
  69. package/src/hooks/run-hooks.ts +72 -41
  70. package/src/hooks/staged.ts +49 -0
  71. package/src/hooks/tree-shrink.ts +41 -0
  72. package/src/init-repo.ts +0 -42
  73. package/src/ledger/check-table.ts +1 -1
  74. package/src/ledger/gate-checks.ts +45 -9
  75. package/src/ledger/ledger.ts +6 -1
  76. package/src/ledger/repo-view.ts +14 -4
  77. package/src/ledger/toolchain-identity.ts +4 -2
  78. package/src/mv/mv-md.ts +6 -1
  79. package/src/plan/planner.ts +21 -4
  80. package/src/plan/repo-source.ts +7 -3
  81. package/src/problems/extract.ts +27 -5
  82. package/src/problems/problem-line.ts +2 -3
  83. package/src/release/run-release.ts +30 -16
  84. package/src/release/unit-gate.ts +12 -5
  85. package/src/repo-config.ts +51 -27
  86. package/src/scaffold/files/bunfig.toml +11 -0
  87. package/src/scaffold/files/vscode-settings.json +1 -2
  88. package/src/scaffold/jsonc-spans.ts +16 -0
  89. package/src/scaffold/project-scaffold.ts +43 -77
  90. package/src/serve/hub-app/check-view.tsx +4 -4
  91. package/src/serve/hub-app/check-words.ts +1 -0
  92. package/src/serve/hub-app/checks-svc.ts +1 -2
  93. package/src/serve/hub-app/home.html +0 -1
  94. package/src/serve/hub-app/hub-types.ts +0 -2
  95. package/src/serve/hub-app/index.html +0 -1
  96. package/src/serve/hub-app/project-list.css +1 -2
  97. package/src/serve/hub-app/tree-build.ts +3 -2
  98. package/src/serve/hub-app/tree-svc.ts +2 -2
  99. package/src/serve/hub-port.ts +3 -3
  100. package/src/serve/hub-runtime.ts +3 -5
  101. package/src/serve/project-gutter.ts +5 -2
  102. package/src/serve/run-lanes.ts +7 -3
  103. package/src/serve/serve-hub.ts +1 -16
  104. package/src/serve/tracked-watch.ts +1 -1
  105. package/src/smoke/run-smoke.ts +13 -6
  106. package/src/start-repo.ts +15 -3
  107. package/src/suite/judge-suites.ts +81 -32
  108. package/src/suite/select.ts +2 -0
  109. package/src/suite/suite-cli.ts +27 -16
  110. package/src/suite/suite-cpu-preload.ts +7 -11
  111. package/src/suite/test-units.ts +7 -4
  112. package/src/ts/config.ts +12 -5
  113. package/src/ts/imports.ts +3 -1
  114. package/src/ts/typecheck-cache.ts +11 -0
  115. package/src/ts/typecheck-cli.ts +2 -1
  116. package/src/tui/build-steps-panel.ts +1 -1
  117. package/src/wiki/scan-md.ts +16 -9
  118. package/src/checks/SK009-commit-ai-tells.check.ts +0 -131
  119. package/src/checks/SK014-prose-ste.check.ts +0 -78
  120. package/src/cli/cmd_Prose.ts +0 -74
  121. package/src/prose/prose-view.ts +0 -90
  122. package/src/prose/run-prose.ts +0 -187
  123. package/src/prose/ste.ts +0 -552
  124. package/src/scaffold/files/vscode-tasks.json +0 -24
  125. package/src/serve/hub-app/prose-hits-st.ts +0 -125
  126. package/src/serve/hub-app/prose-hits-view.tsx +0 -176
  127. package/src/serve/hub-app/prose-hits.css +0 -21
@@ -1,74 +0,0 @@
1
- // shipkit prose — the STE checker as a command, so a writer can run it on one
2
- // file while drafting instead of waiting for the gate. Rule SK014 runs the
3
- // same module over the repo's declared scope.
4
- import { Command, Option } from 'clipanion'
5
- import { loadRepoConfig } from '../repo-config.ts'
6
- import { classSummary, proseLines, runProseCheck } from '../prose/run-prose.ts'
7
- import { CheckCtx } from '../core/check-ctx.ts'
8
-
9
- export class ProseCommand extends Command {
10
- static override paths = [['prose']]
11
-
12
- static override usage = Command.Usage({
13
- category: 'Health',
14
- description: 'STE prose check (identity/writing-voice.md): mechanical fixes, hard violations, soft warnings',
15
- examples: [
16
- ['every file this repo declares as prose', 'sk prose'],
17
- ['one file while drafting', 'sk prose wiki/build/shipkit.md'],
18
- ['apply every mechanical rewrite', 'sk prose --fix'],
19
- ['the procedural 20-word limit', 'sk prose --strict'],
20
- ['machine-readable', 'sk prose --json'],
21
- ],
22
- })
23
-
24
- // `targets`, not `paths`: clipanion's Command owns the static `paths`
25
- targets = Option.Rest()
26
- fix = Option.Boolean('--fix', false, { description: 'apply the mechanical rewrites (contractions, latin, one-to-one substitutions, empty words)' })
27
- json = Option.Boolean('--json', false, { description: 'machine-readable { files, byClass, hits }' })
28
- strict = Option.Boolean('--strict', false, { description: 'the 20-word procedural sentence limit instead of the 25-word descriptive one' })
29
- cap = Option.String('--cap', '25', { description: 'how many hit lines to print before the per-class totals' })
30
-
31
- async execute(): Promise<number> {
32
- const root = process.cwd()
33
- const config = await loadRepoConfig(root)
34
- const cfg = { ...(config?.prose ?? {}) }
35
- if (this.strict) cfg.strict = true
36
- const explicit = this.targets.length > 0
37
- if ((cfg.include ?? []).length === 0 && !explicit) {
38
- this.context.stdout.write(
39
- `skip: this repo declares no prose scope — add \`prose: { include: ['**/*.md'], exempt: [...] }\` to repo.config.ts (identity/writing-voice.md)\n`,
40
- )
41
- return 0
42
- }
43
- const r = runProseCheck({ c: CheckCtx.open({ root, config }), config: cfg, paths: explicit ? this.targets : undefined, fix: this.fix })
44
- if (this.json) {
45
- // every hit carries its file: a flat list without one cannot be
46
- // acted on, and the grouping is already in byFile
47
- const hits = r.byFile.flatMap((f) => f.hits.map((h) => ({ file: f.file, ...h })))
48
- this.context.stdout.write(`${JSON.stringify({ files: r.files, byTier: r.byTier, byClass: r.byClass, fixed: r.fixed, hits }, null, 2)}\n`)
49
- return r.byTier.fix + r.byTier.hard > 0 ? 1 : 0
50
- }
51
- const w = (l: string) => this.context.stdout.write(`${l}\n`)
52
- // a declared scope that resolves to nothing reads as `0 file(s)
53
- // STE-clean`, the same green a clean repo gets, over a check that
54
- // inspected no file at all (rule SK014 warns on the same fact)
55
- if (!explicit && r.files === 0) {
56
- w(`WARN prose scope matches no file: ${(cfg.include ?? []).join(', ')} — declared and inspecting nothing`)
57
- w(' fix: fix the globs in repo.config.ts prose.include (or its exempt list), or drop the key to opt out')
58
- return 0
59
- }
60
- if (r.fixed.length > 0) w(`ok: rewrote ${r.fixed.length} file(s)`)
61
- for (const l of proseLines(r, { cap: Number(this.cap) || 25 })) w(l)
62
- const blocking = r.byTier.fix + r.byTier.hard
63
- const baseline = cfg.baseline
64
- const overBaseline = baseline != null && r.byTier.soft > baseline
65
- if (blocking === 0 && !overBaseline) {
66
- w(`ok: ${r.files} file(s) STE-clean${r.byTier.soft > 0 ? `, ${r.byTier.soft} soft warning(s)${baseline != null ? ` (baseline ${baseline})` : ''}` : ''}`)
67
- return 0
68
- }
69
- if (blocking > 0)
70
- w(`FAIL: ${blocking} STE violation(s) in ${r.byFile.length} file(s) — ${classSummary(r)}${r.fixable > 0 ? `\n fix: shipkit prose --fix clears ${r.fixable} of them` : ''}`)
71
- if (overBaseline) w(`FAIL: ${r.byTier.soft} soft hits, above this repo's baseline of ${baseline} — lower the count, or move the baseline down in repo.config.ts`)
72
- return 1
73
- }
74
- }
@@ -1,90 +0,0 @@
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
- }
@@ -1,187 +0,0 @@
1
- // `shipkit prose` — the file-walking half of the STE checker. The judgement
2
- // lives in ste.ts; this module resolves WHICH files a repo puts in scope,
3
- // prints the wire lines, and applies the mechanical fixes.
4
- //
5
- // Scope is repo knowledge, never a hardcoded path list: a repo declares
6
- // `prose: { include, exempt }` in repo.config.ts. A repo that declares
7
- // nothing is not checked, and the rule says so instead of failing a repo
8
- // that never opted in.
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'>
18
-
19
- export type ProseConfig = {
20
- /** globs (repo-relative) that ARE house prose. Absent = the repo is not checked. */
21
- include?: string[]
22
- /** globs never checked: journals, archives, upstream snapshots, generated files */
23
- exempt?: string[]
24
- /** classes this repo does not check, with the reason living in repo.config.ts */
25
- allow?: ProseClass[]
26
- /** the 20-word procedural limit instead of the 25-word descriptive one */
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
33
- /** soft-tier ratchet: the count this repo is allowed to carry. The gate
34
- * fails ABOVE it, so the number can only go down. Absent is the default,
35
- * and it means soft hits warn in `shipkit prose` and never reach a gate
36
- * or an attention surface. A four-digit count nobody can act on is worse
37
- * than no number at all. */
38
- baseline?: number
39
- }
40
-
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
- }
48
-
49
- export type ProseReport = {
50
- files: number
51
- hits: ProseHit[]
52
- byFile: FileReport[]
53
- byTier: Record<ProseTier, number>
54
- byClass: Record<string, number>
55
- fixable: number
56
- /** files rewritten by a --fix run */
57
- fixed: string[]
58
- scoped: boolean // did this repo declare any include globs?
59
- }
60
-
61
- const DEFAULT_EXEMPT = ['**/node_modules/**', '**/CHANGELOG.md', '**/dist/**', '**/.git/**']
62
-
63
- /** a generated file is not house prose, and rewriting it loses the change on
64
- * the next render */
65
- const isGenerated = (text: string): boolean => text.slice(0, 400).includes('<!-- generated')
66
-
67
- /** an explicitly named path, resolved against the repo and REFUSED when it
68
- * lands outside: `--fix` writes, and a checker that rewrites files outside
69
- * the repo it was pointed at is a much worse tool than one that says no. */
70
- export function explicitPath(root: string, p: string): string {
71
- const abs = isAbsolute(p) ? p : resolve(root, p)
72
- const rel = relative(root, abs)
73
- if (rel === '' || rel.startsWith('..')) throw new Error(`FAIL ${p} is outside ${root} — shipkit prose only reads and writes inside the repo`)
74
- return rel
75
- }
76
-
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[] {
99
- const include = cfg.include ?? []
100
- if (include.length === 0) return []
101
- const exempt = [...DEFAULT_EXEMPT, ...(cfg.exempt ?? [])].map((g) => new Bun.Glob(g))
102
- const seen = new Set<string>()
103
- for (const pattern of include)
104
- for (const rel of d.list(pattern)) {
105
- if (!hiddenAllowed(pattern, rel)) continue
106
- if (exempt.some((g) => g.match(rel))) continue
107
- seen.add(rel)
108
- }
109
- return [...seen].sort()
110
- }
111
-
112
- export function runProseCheck(o: { c: ProseDisk; config: ProseConfig; paths?: string[]; fix?: boolean; keepText?: boolean }): ProseReport {
113
- const cfg = o.config
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)
116
- const explicit = o.paths != null && o.paths.length > 0
117
- const opts = { allow: cfg.allow, strict: cfg.strict, words: cfg.words }
118
- const byFile: FileReport[] = []
119
- const hits: ProseHit[] = []
120
- const fixed: string[] = []
121
- for (const rel of files) {
122
- let text: string | null
123
- try {
124
- text = d.read(rel)
125
- } catch (e) {
126
- text = null
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`)
134
- continue
135
- }
136
- if (isGenerated(text)) continue
137
- if (o.fix === true) {
138
- const out = fixProse(text, opts)
139
- if (out.applied > 0) {
140
- d.write(rel, out.text)
141
- fixed.push(rel)
142
- text = out.text
143
- }
144
- }
145
- const fh = checkProse(text, opts)
146
- if (fh.length > 0) byFile.push({ file: rel, hits: fh, ...(o.keepText === true ? { text } : {}) })
147
- hits.push(...fh)
148
- }
149
- const byTier: Record<ProseTier, number> = { fix: 0, hard: 0, soft: 0 }
150
- const byClass: Record<string, number> = {}
151
- for (const h of hits) {
152
- byTier[h.tier]++
153
- byClass[h.cls] = (byClass[h.cls] ?? 0) + 1
154
- }
155
- return {
156
- files: files.length,
157
- hits,
158
- byFile,
159
- byTier,
160
- byClass,
161
- fixable: hits.filter((h) => h.fix != null).length,
162
- fixed,
163
- scoped: (cfg.include ?? []).length > 0,
164
- }
165
- }
166
-
167
- /** one line per hit, capped: a first STE pass on a real repo finds hundreds,
168
- * and a wall of them hides the mechanical ones that `--fix` clears in a
169
- * second. The per-class totals below the cap keep the report complete. */
170
- export function proseLines(r: ProseReport, o: { cap?: number; tiers?: ProseTier[] } = {}): string[] {
171
- const cap = o.cap ?? 25
172
- const tiers = new Set(o.tiers ?? (['fix', 'hard', 'soft'] as ProseTier[]))
173
- const shown = r.hits.filter((h) => tiers.has(h.tier))
174
- const out: string[] = []
175
- const fileOf = new Map<ProseHit, string>()
176
- for (const f of r.byFile) for (const h of f.hits) fileOf.set(h, f.file)
177
- for (const h of shown.slice(0, cap))
178
- out.push(`${fileOf.get(h) ?? '?'}:${h.line} [${h.cls}] ${JSON.stringify(h.text.trim())} — ${h.why}${h.fix != null ? ` (--fix writes ${JSON.stringify(h.fix.trim())})` : ''}`)
179
- if (shown.length > cap) out.push(`… and ${shown.length - cap} more`)
180
- return out
181
- }
182
-
183
- export const classSummary = (r: ProseReport): string =>
184
- Object.entries(r.byClass)
185
- .sort((a, b) => b[1] - a[1])
186
- .map(([c, n]) => `${c} ${n}${tierOf(c as ProseClass) === 'soft' ? '' : '!'}`)
187
- .join(', ')