rvlib-shipkit 0.35.8 → 0.36.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.
@@ -0,0 +1,142 @@
1
+ // The fleet: every repo on this disk that depends on the toolkit, and how far
2
+ // behind each one is.
3
+ //
4
+ // Why a scan and not a registry: `local-projects.md` only holds repos whose
5
+ // `docsBase` points at a wiki, which is 4 of the 10 that actually install the
6
+ // libs. A repo that never reported is exactly the repo most likely to be
7
+ // stale, so a registry is the wrong source for this question.
8
+ //
9
+ // This module DISCOVERS and REPORTS. It never updates: rule SK005 owns the
10
+ // update, with the lease, the dirty-tree guard and the frozen guard that took
11
+ // three bugs to get right. `--update` runs each repo's own `shipkit check`,
12
+ // so the fix travels through that one code path.
13
+ import { existsSync, readdirSync, readFileSync, statSync } from 'fs'
14
+ import { join } from 'path'
15
+
16
+ export type Pkg = { name?: string; version?: string; dependencies?: Record<string, string>; devDependencies?: Record<string, string> }
17
+
18
+ export type FleetDep = {
19
+ name: string
20
+ /** the range in package.json, or `link:…` for a source-linked dep */
21
+ pin: string
22
+ /** what node_modules actually carries, null when nothing is installed */
23
+ installed: string | null
24
+ /** npm latest, null when the registry was not consulted or not reachable */
25
+ latest: string | null
26
+ }
27
+
28
+ /** a jump that changes the MAJOR, which is a porting job rather than a bump.
29
+ *
30
+ * Deliberately NOT the semver rule. On a `0.x` package npm treats every
31
+ * minor as breaking, and by that rule every repo here is breaking, including
32
+ * the `0.24 → 0.35` shipkit jumps that rule SK005 already performs by itself
33
+ * every day. A flag that fires on everything is a flag nobody reads. What
34
+ * survives is the case a human has to own: `rvlib-pb-mobx 0.2.0 → 2.0.0`. */
35
+ export function isBreaking(installed: string, latest: string): boolean {
36
+ const major = (v: string) => Number(v.split('-')[0]!.split('.')[0]) || 0
37
+ return major(installed) !== major(latest)
38
+ }
39
+
40
+ export type FleetRepo = {
41
+ name: string
42
+ dir: string
43
+ deps: FleetDep[]
44
+ /** a repo pointing at a checkout rather than a published version. Never
45
+ * updated: the source IS the version, and that is a deliberate choice. */
46
+ linked: boolean
47
+ /** whether this repo can run the update itself (it has a shipkit binary) */
48
+ hasShipkit: boolean
49
+ behind: FleetDep[]
50
+ /** the subset of `behind` whose jump crosses a breaking boundary */
51
+ breaking: FleetDep[]
52
+ }
53
+
54
+ const readPkg = (dir: string): Pkg | null => {
55
+ const p = join(dir, 'package.json')
56
+ if (!existsSync(p)) return null
57
+ try {
58
+ return JSON.parse(readFileSync(p, 'utf8')) as Pkg
59
+ } catch {
60
+ return null
61
+ }
62
+ }
63
+
64
+ const installedVersion = (dir: string, name: string): string | null => readPkg(join(dir, 'node_modules', name))?.version ?? null
65
+
66
+ /** semver-ish compare, enough to answer "is this one behind that one". A
67
+ * prerelease tag sorts before its release, which is the safe direction: it
68
+ * reports behind rather than pretending parity. */
69
+ export function isBehind(installed: string, latest: string): boolean {
70
+ const part = (v: string) => v.split('-')[0]!.split('.').map((n) => Number(n) || 0)
71
+ const [a, b] = [part(installed), part(latest)]
72
+ for (let i = 0; i < 3; i++) {
73
+ if ((a[i] ?? 0) < (b[i] ?? 0)) return true
74
+ if ((a[i] ?? 0) > (b[i] ?? 0)) return false
75
+ }
76
+ return installed.includes('-') && !latest.includes('-')
77
+ }
78
+
79
+ export function scanFleet(root: string, o: { prefix?: string } = {}): FleetRepo[] {
80
+ const prefix = o.prefix ?? 'rvlib-'
81
+ const out: FleetRepo[] = []
82
+ let entries: string[]
83
+ try {
84
+ entries = readdirSync(root)
85
+ } catch {
86
+ return out
87
+ }
88
+ for (const e of entries.sort()) {
89
+ if (e.startsWith('.')) continue
90
+ const dir = join(root, e)
91
+ try {
92
+ if (!statSync(dir).isDirectory()) continue
93
+ } catch {
94
+ continue
95
+ }
96
+ const pkg = readPkg(dir)
97
+ if (pkg == null) continue
98
+ const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) }
99
+ const names = Object.keys(all).filter((n) => n.startsWith(prefix))
100
+ if (names.length === 0) continue
101
+ const deps: FleetDep[] = names.sort().map((name) => ({ name, pin: all[name]!, installed: installedVersion(dir, name), latest: null }))
102
+ out.push({
103
+ name: e,
104
+ dir,
105
+ deps,
106
+ linked: deps.some((d) => d.pin.startsWith('link:') || d.pin.startsWith('file:')),
107
+ hasShipkit: existsSync(join(dir, 'node_modules', 'rvlib-shipkit', 'src', 'cli.ts')),
108
+ behind: [],
109
+ breaking: [],
110
+ })
111
+ }
112
+ return out
113
+ }
114
+
115
+ /** npm latest per PACKAGE, fetched once and shared across every repo that
116
+ * depends on it. Ten repos on one lib is one request, not ten. */
117
+ export async function resolveLatest(repos: FleetRepo[], fetchLatest: (name: string) => Promise<string | null>): Promise<Map<string, string | null>> {
118
+ const names = new Set<string>()
119
+ for (const r of repos) for (const d of r.deps) if (!d.pin.startsWith('link:') && !d.pin.startsWith('file:')) names.add(d.name)
120
+ const latest = new Map<string, string | null>()
121
+ await Promise.all(
122
+ [...names].map(async (n) => {
123
+ try {
124
+ latest.set(n, await fetchLatest(n))
125
+ } catch {
126
+ latest.set(n, null)
127
+ }
128
+ }),
129
+ )
130
+ for (const r of repos) {
131
+ for (const d of r.deps) d.latest = latest.get(d.name) ?? null
132
+ r.behind = r.deps.filter((d) => d.installed != null && d.latest != null && isBehind(d.installed, d.latest))
133
+ r.breaking = r.behind.filter((d) => isBreaking(d.installed!, d.latest!))
134
+ }
135
+ return latest
136
+ }
137
+
138
+ export async function npmLatest(name: string): Promise<string | null> {
139
+ const res = await fetch(`https://registry.npmjs.org/${name}/latest`, { signal: AbortSignal.timeout(10_000) })
140
+ if (!res.ok) return null
141
+ return ((await res.json()) as Pkg).version ?? null
142
+ }
@@ -101,7 +101,7 @@ const samePath = (a: string, b: string): boolean => {
101
101
  }
102
102
  }
103
103
 
104
- type RepoView = { nameWithOwner?: string; isPrivate?: boolean; url?: string; sshUrl?: string }
104
+ type RepoView = { nameWithOwner?: string; isPrivate?: boolean; sshUrl?: string }
105
105
 
106
106
  /** phase one: this dir IS a git work tree root. false means the caller must
107
107
  * not publish anything (a subdirectory of another repo, or git refused), and
@@ -194,7 +194,7 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
194
194
  const login = firstGhLine(user.out)
195
195
  if (!NAME_RE.test(login)) return void p(`WARN no remote wired: gh did not answer with a login (${login})`)
196
196
  const target = `${login}/${name}`
197
- const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,url,sshUrl']
197
+ const viewArgs = ['repo', 'view', target, '--json', 'nameWithOwner,isPrivate,sshUrl']
198
198
 
199
199
  let view = await gh(viewArgs)
200
200
  let created = false
@@ -217,9 +217,15 @@ export async function publishRepo(root: string, opts: PublishOpts = {}): Promise
217
217
  }
218
218
 
219
219
  const info = carveGhJson<RepoView>(view.out)
220
- const proto = await gh(['config', 'get', 'git_protocol'])
221
- const url = (firstGhLine(proto.out) === 'ssh' ? info?.sshUrl : info?.url) ?? info?.url ?? info?.sshUrl
222
- if (url == null) return void p(`WARN no remote wired: gh answer unreadable (${firstGhLine(view.out)})`)
220
+ // ALWAYS the ssh url gh gave, never the https one and never `gh config
221
+ // get git_protocol`: an https origin re-asks for a credential on every
222
+ // push, and a machine answering `https` there wires a remote that reports
223
+ // success and fails at the first push. An answer carrying no sshUrl is
224
+ // unreadable, not an invitation to build one: this module knows no host,
225
+ // and a guessed `github.com` remote against an enterprise gh is the same
226
+ // silent failure one host over
227
+ const url = info?.sshUrl
228
+ if (url == null) return void p(`WARN no remote wired: gh answered without an ssh url (${firstGhLine(view.out)})`)
223
229
 
224
230
  const add = await git(['remote', 'add', 'origin', url])
225
231
  if (add.code !== 0) return void p(`WARN no remote wired: git remote add failed (${firstGhLine(add.out)})`)
@@ -40,44 +40,97 @@ export function tieredTasks(c: RepoConfig, root: string, tier: TestTier): TaskDe
40
40
  * built from the unmodified list would let a quick run replay into a full one
41
41
  * under one identity. */
42
42
  export function gateUnits(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitDecl[] {
43
- const out: UnitDecl[] = []
43
+ return gateInventory(root, c, tasks).map((r) => r.decl)
44
+ }
45
+
46
+ /** one gate unit with everything a reader asks about it, not only what the
47
+ * key layer asks. The cache half stays in `decl` so `UnitDecl` never grows
48
+ * fields the key layer must then decide to ignore. */
49
+ export type UnitRow = {
50
+ decl: UnitDecl
51
+ /** the display name an operator reads (a rule's title, else the id) */
52
+ title: string
53
+ /** does `shipkit ci` run it? false = PROACTIVE: `shipkit check` reports it,
54
+ * the gate never does, and it may sit red for months without anything
55
+ * being wrong with the change in front of you. */
56
+ gate: boolean
57
+ /** can a failure stop you? null = the unit never said AND the repo set no
58
+ * `checkDefaults.blocking`, which the check runner treats as fatal. */
59
+ blocking: boolean | null
60
+ /** where the answer is owned: a rule's wiki page, a check's file, a task's
61
+ * command. Null when the unit names none. */
62
+ owner: string | null
63
+ /** the group a task was filed under, for the few that declare one */
64
+ group: string | null
65
+ /** excluded from a default run unless named with `--with` */
66
+ optIn: boolean
67
+ }
68
+
69
+ /** every unit the gate knows, with its metadata. `gateUnits` is this list
70
+ * narrowed to the cache declaration, so the three registries are walked in
71
+ * exactly one place and a surface can never enumerate a different set than
72
+ * the one the key layer keyed. */
73
+ export function gateInventory(root: string, c: RepoConfig, tasks?: TaskDef[]): UnitRow[] {
74
+ const out: UnitRow[] = []
44
75
  for (const t of tasks ?? allTasks(c, root).tasks) {
45
76
  out.push({
46
- id: t.id,
47
- kind: 'task',
48
- inputs: t.inputs,
49
- ttl: t.ttl,
50
- never: t.never,
51
- argv: t.cmd,
52
- // the env a task SETS is part of what it runs, not something read
53
- // off the ambient environment, so it rides as an identity string
54
- extra: t.env == null ? undefined : JSON.stringify(Object.entries(t.env).sort()),
77
+ decl: {
78
+ id: t.id,
79
+ kind: 'task',
80
+ inputs: t.inputs,
81
+ ttl: t.ttl,
82
+ never: t.never,
83
+ argv: t.cmd,
84
+ extra: t.env == null ? undefined : JSON.stringify(Object.entries(t.env).sort()),
85
+ },
86
+ title: t.id,
87
+ // an optIn task is off the default run, which is the same shape as
88
+ // a check that opted out of the gate
89
+ gate: t.optIn !== true,
90
+ // a task IS the build: it has no non-blocking mode to declare
91
+ blocking: true,
92
+ owner: t.cmd == null ? null : t.cmd.join(' '),
93
+ group: t.group ?? null,
94
+ optIn: t.optIn === true,
55
95
  })
56
96
  }
57
97
  for (const r of [...RULES, ...(c.rules ?? [])]) {
58
98
  out.push({
59
- id: r.id,
60
- kind: 'rule',
61
- inputs: r.inputs,
62
- code: r.code,
63
- ttl: r.ttl,
64
- never: r.never,
65
- extra: r.cacheKey?.({ root, config: c }),
99
+ decl: {
100
+ id: r.id,
101
+ kind: 'rule',
102
+ inputs: r.inputs,
103
+ code: r.code,
104
+ ttl: r.ttl,
105
+ never: r.never,
106
+ extra: r.cacheKey?.({ root, config: c }),
107
+ },
108
+ title: r.title,
109
+ gate: true,
110
+ blocking: r.blocking,
111
+ owner: r.owner,
112
+ group: null,
113
+ optIn: false,
66
114
  })
67
115
  }
68
116
  for (const d of discoverChecks(root)) {
69
117
  const rel = relative(root, d.path).replace(/\\/g, '/')
70
118
  out.push({
71
- id: d.name,
72
- kind: 'check',
73
- inputs: d.inputs ?? undefined,
74
- ttl: d.ttl ?? undefined,
75
- never: d.never ?? undefined,
76
- // a check IS a file, so its own code needs no declaration: the
77
- // module graph walks from here through every helper it imports,
78
- // which is what makes fixing a shared helper move the check's key
79
- code: [rel],
80
- argv: ['bun', rel],
119
+ decl: {
120
+ id: d.name,
121
+ kind: 'check',
122
+ inputs: d.inputs ?? undefined,
123
+ ttl: d.ttl ?? undefined,
124
+ never: d.never ?? undefined,
125
+ code: [rel],
126
+ argv: ['bun', rel],
127
+ },
128
+ title: d.name,
129
+ gate: d.gate,
130
+ blocking: d.blocking ?? c.checkDefaults?.blocking ?? null,
131
+ owner: rel,
132
+ group: null,
133
+ optIn: false,
81
134
  })
82
135
  }
83
136
  return out
@@ -44,6 +44,11 @@ export type Stored<T> = {
44
44
  key: string
45
45
  /** when it was written, ms since epoch */
46
46
  at: number
47
+ /** how long THIS entry stays valid, when that is shorter than the unit's
48
+ * declared TTL. A measured negative writes one, so a red about the world
49
+ * clears soon after a human fixes the world, while a clean answer keeps
50
+ * for the full declared window. Absent means the declared TTL. */
51
+ keeps?: number
47
52
  payload: T
48
53
  }
49
54
 
@@ -87,10 +92,10 @@ export function readStored<T>(path: string, key: string): Stored<T> | null {
87
92
  } catch { return null }
88
93
  }
89
94
 
90
- export function writeStored<T>(path: string, key: string, payload: T, at: number): void {
95
+ export function writeStored<T>(path: string, key: string, payload: T, at: number, keeps?: number): void {
91
96
  try {
92
97
  mkdirSync(dirname(path), { recursive: true })
93
- atomicWrite(path, JSON.stringify({ v: STORE_VERSION, key, at, payload }))
98
+ atomicWrite(path, JSON.stringify({ v: STORE_VERSION, key, at, keeps, payload }))
94
99
  } catch {
95
100
  // a store that cannot be written is slow, never wrong: the answer was
96
101
  // already printed, and the next run measures again
@@ -0,0 +1,105 @@
1
+ // THE inventory render — every gate unit as one markdown row, so the question
2
+ // "what runs here, does it block me, and how often does it really run" has a
3
+ // surface that is read rather than reconstructed from three commands.
4
+ //
5
+ // Deliberately carries NO live run state: no timestamps, no key hashes, no
6
+ // last verdict. Everything here is what a unit DECLARED about itself, so two
7
+ // runs a week apart over an unchanged repo write identical bytes and the file
8
+ // never churns. It is still generated, gitignored AND untracked like STATUS.md
9
+ // (rules/untrack-generated.ts), so it is read on disk, never in git history.
10
+ // Live keys are `shipkit keys`, live verdicts are STATUS.md.
11
+ import { join } from 'path'
12
+ import { atomicWrite, withFileLock } from '../core/locked-write.ts'
13
+ import { classify } from './git-key.ts'
14
+ import type { UnitRow } from './gate-units.ts'
15
+
16
+ /** the sidecar every gate run refreshes beside STATUS.md */
17
+ export const UNIT_TABLE_FILE = 'STATUS.full.md'
18
+
19
+ /** a cell can never break the table it sits in */
20
+ const cell = (s: string): string => s.replace(/\|/g, '\\|').replace(/\n/g, ' ')
21
+
22
+ export type UnitTableInput = {
23
+ /** repo name, for the headline */
24
+ name: string
25
+ rows: UnitRow[]
26
+ }
27
+
28
+ /** what a unit's cache declaration means for how often it actually runs */
29
+ function cacheOf(r: UnitRow): { population: string; ttl: string; reads: string; why: string } {
30
+ const c = classify(r.decl)
31
+ const inputs = r.decl.inputs ?? []
32
+ if (c.error != null) return { population: 'BROKEN', ttl: '', reads: '', why: c.error }
33
+ if (c.population === 'content') return { population: 'content', ttl: '', reads: inputs.join(', '), why: '' }
34
+ // no `reads` for these two: the cache column already says "world"/"never",
35
+ // and restating it on every such row is a column of noise
36
+ if (c.population === 'world') return { population: 'world', ttl: r.decl.ttl ?? '', reads: '', why: '' }
37
+ if (c.decided === true) return { population: 'never', ttl: '', reads: '', why: r.decl.never ?? '' }
38
+ return { population: 'UNDECLARED', ttl: '', reads: '', why: 'nobody has declared what this reads, so it re-runs every single time' }
39
+ }
40
+
41
+ export function renderUnitTable(input: UnitTableInput): string {
42
+ const { name, rows } = input
43
+ const counts = { task: 0, rule: 0, check: 0 }
44
+ for (const r of rows) counts[r.decl.kind]++
45
+
46
+ const body: string[] = []
47
+ body.push(`<!-- generated by \`shipkit units\`, refreshed by every gate run, gitignored (SK004) -->`)
48
+ body.push(`# ${name} units — ${rows.length} total`)
49
+ body.push('')
50
+ body.push(`Everything \`shipkit ci\` and \`shipkit check\` know how to run: ${counts.task} task(s), ${counts.rule} rule(s), ${counts.check} discovered check(s).`)
51
+ body.push('')
52
+ body.push('- **runs in** — `gate` means `shipkit ci` runs it, so a red one says THIS change is broken. `proactive` means only `shipkit check` runs it: it reports the world (a box that is off, a cred nobody minted, a page nobody refreshed), it can sit red for months with nothing wrong, and it never stands between you and a commit.')
53
+ body.push('- **on failure** — `blocks` stops the run. `warns` prints and carries on. `UNDECLARED` is a defect, not a setting: the unit never said and the repo set no default, so the runner treats it as fatal by guess.')
54
+ body.push('- **cache** — `content` replays while the files it reads are untouched. `world` replays for its TTL, and a measured RED replays too, capped at one hour so the run after a fix sees the fix. `never` re-runs every time and somebody wrote down why. `UNDECLARED` re-runs every time and nobody has looked yet, which is the burn-down (`shipkit keys --burn-down`). `SHIPKIT_FRESH=1` busts all of it.')
55
+ const proactive = rows.filter((r) => !r.gate && !r.optIn).length
56
+ const warns = rows.filter((r) => r.blocking === false).length
57
+ const neverSaid = rows.filter((r) => r.blocking == null).length
58
+ const undeclared = rows.filter((r) => cacheOf(r).population === 'UNDECLARED')
59
+ body.push('')
60
+ // the most useful line in the file, so it sits ABOVE the 68 rows rather
61
+ // than under them
62
+ body.push(
63
+ `**${rows.length - proactive} in the gate, ${proactive} proactive · ${warns} warn instead of blocking${neverSaid > 0 ? `, ${neverSaid} never said` : ''} · ${undeclared.length} uncacheable with nobody having looked.**`,
64
+ )
65
+ body.push('')
66
+ body.push('| unit | kind | runs in | on failure | cache | ttl | reads | owner |')
67
+ body.push('|---|---|---|---|---|---|---|---|')
68
+ for (const r of rows) {
69
+ const c = cacheOf(r)
70
+ const blocking = r.blocking == null ? '**UNDECLARED**' : r.blocking ? 'blocks' : 'warns'
71
+ const runsIn = r.optIn ? 'opt-in' : r.gate ? 'gate' : 'proactive'
72
+ const population = c.population === 'UNDECLARED' ? '**UNDECLARED**' : c.population
73
+ body.push(
74
+ `| \`${cell(r.decl.id)}\` | ${r.decl.kind} | ${runsIn} | ${blocking} | ${population} | ${cell(c.ttl)} | ${cell(c.reads)} | ${r.owner == null ? '' : `\`${cell(r.owner)}\``} |`,
75
+ )
76
+ }
77
+ body.push('')
78
+
79
+ const decided = rows.filter((r) => cacheOf(r).population === 'never')
80
+ if (undeclared.length > 0) {
81
+ body.push(`## ${undeclared.length} unit(s) nothing can cache yet`)
82
+ body.push('')
83
+ body.push('Each of these runs on every single pass. Declaring `inputs` (it reads files), `ttl` (it reads the world) or `never` (nothing can describe it) is the fix, and the last one counts: a written reason is a decision, not a hole.')
84
+ body.push('')
85
+ for (const r of undeclared) body.push(`- \`${cell(r.decl.id)}\` (${r.decl.kind})${r.owner == null ? '' : ` — \`${cell(r.owner)}\``}`)
86
+ body.push('')
87
+ }
88
+ if (decided.length > 0) {
89
+ body.push(`## ${decided.length} unit(s) decided uncacheable`)
90
+ body.push('')
91
+ for (const r of decided) body.push(`- \`${cell(r.decl.id)}\` — ${cell(r.decl.never ?? '')}`)
92
+ body.push('')
93
+ }
94
+
95
+ return body.join('\n')
96
+ }
97
+
98
+ /** refresh the sidecar beside STATUS.md. Lock-guarded exactly like STATUS.md:
99
+ * several runners in one checkout is the normal case, and the loser skipping
100
+ * a render it would have written identically costs nothing. */
101
+ export function writeUnitTable(root: string, input: UnitTableInput): { written: boolean; warn?: string } {
102
+ const target = join(root, UNIT_TABLE_FILE)
103
+ const r = withFileLock(target, () => { atomicWrite(target, `${renderUnitTable(input).trimEnd()}\n`) })
104
+ return r.ok ? { written: true } : { written: false, warn: `${UNIT_TABLE_FILE} locked by another runner — skipped this write` }
105
+ }
@@ -0,0 +1,146 @@
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
+ 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'
13
+
14
+ export type ProseConfig = {
15
+ /** globs (repo-relative) that ARE house prose. Absent = the repo is not checked. */
16
+ include?: string[]
17
+ /** globs never checked: journals, archives, upstream snapshots, generated files */
18
+ exempt?: string[]
19
+ /** classes this repo does not check, with the reason living in repo.config.ts */
20
+ allow?: ProseClass[]
21
+ /** the 20-word procedural limit instead of the 25-word descriptive one */
22
+ strict?: boolean
23
+ /** soft-tier ratchet: the count this repo is allowed to carry. The gate
24
+ * fails ABOVE it, so the number can only go down. Absent is the default,
25
+ * and it means soft hits warn in `shipkit prose` and never reach a gate
26
+ * or an attention surface. A four-digit count nobody can act on is worse
27
+ * than no number at all. */
28
+ baseline?: number
29
+ }
30
+
31
+ export type FileReport = { file: string; hits: ProseHit[] }
32
+
33
+ export type ProseReport = {
34
+ files: number
35
+ hits: ProseHit[]
36
+ byFile: FileReport[]
37
+ byTier: Record<ProseTier, number>
38
+ byClass: Record<string, number>
39
+ fixable: number
40
+ /** files rewritten by a --fix run */
41
+ fixed: string[]
42
+ scoped: boolean // did this repo declare any include globs?
43
+ }
44
+
45
+ const DEFAULT_EXEMPT = ['**/node_modules/**', '**/CHANGELOG.md', '**/dist/**', '**/.git/**']
46
+
47
+ /** a generated file is not house prose, and rewriting it loses the change on
48
+ * the next render */
49
+ const isGenerated = (text: string): boolean => text.slice(0, 400).includes('<!-- generated')
50
+
51
+ /** an explicitly named path, resolved against the repo and REFUSED when it
52
+ * lands outside: `--fix` writes, and a checker that rewrites files outside
53
+ * the repo it was pointed at is a much worse tool than one that says no. */
54
+ export function explicitPath(root: string, p: string): string {
55
+ const abs = isAbsolute(p) ? p : resolve(root, p)
56
+ const rel = relative(root, abs)
57
+ if (rel === '' || rel.startsWith('..')) throw new Error(`FAIL ${p} is outside ${root} — shipkit prose only reads and writes inside the repo`)
58
+ return rel
59
+ }
60
+
61
+ export function proseFiles(root: string, cfg: ProseConfig): string[] {
62
+ const include = cfg.include ?? []
63
+ if (include.length === 0) return []
64
+ const exempt = [...DEFAULT_EXEMPT, ...(cfg.exempt ?? [])].map((g) => new Bun.Glob(g))
65
+ const seen = new Set<string>()
66
+ 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('/')
69
+ if (exempt.some((g) => g.match(rel))) continue
70
+ seen.add(rel)
71
+ }
72
+ return [...seen].sort()
73
+ }
74
+
75
+ export function runProseCheck(o: { root: string; config: ProseConfig; paths?: string[]; fix?: boolean }): ProseReport {
76
+ 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)
78
+ const explicit = o.paths != null && o.paths.length > 0
79
+ const opts = { allow: cfg.allow, strict: cfg.strict }
80
+ const byFile: FileReport[] = []
81
+ const hits: ProseHit[] = []
82
+ const fixed: string[] = []
83
+ for (const rel of files) {
84
+ const abs = join(o.root, rel)
85
+ let text: string
86
+ try {
87
+ text = readFileSync(abs, 'utf8')
88
+ } 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.
92
+ if (explicit) throw new Error(`FAIL cannot read ${rel} — ${String(e)}`)
93
+ continue
94
+ }
95
+ if (isGenerated(text)) continue
96
+ if (o.fix === true) {
97
+ const out = fixProse(text, opts)
98
+ if (out.applied > 0) {
99
+ atomicWrite(abs, out.text)
100
+ fixed.push(rel)
101
+ text = out.text
102
+ }
103
+ }
104
+ const fh = checkProse(text, opts)
105
+ if (fh.length > 0) byFile.push({ file: rel, hits: fh })
106
+ hits.push(...fh)
107
+ }
108
+ const byTier: Record<ProseTier, number> = { fix: 0, hard: 0, soft: 0 }
109
+ const byClass: Record<string, number> = {}
110
+ for (const h of hits) {
111
+ byTier[h.tier]++
112
+ byClass[h.cls] = (byClass[h.cls] ?? 0) + 1
113
+ }
114
+ return {
115
+ files: files.length,
116
+ hits,
117
+ byFile,
118
+ byTier,
119
+ byClass,
120
+ fixable: hits.filter((h) => h.fix != null).length,
121
+ fixed,
122
+ scoped: (cfg.include ?? []).length > 0,
123
+ }
124
+ }
125
+
126
+ /** one line per hit, capped: a first STE pass on a real repo finds hundreds,
127
+ * and a wall of them hides the mechanical ones that `--fix` clears in a
128
+ * second. The per-class totals below the cap keep the report complete. */
129
+ export function proseLines(r: ProseReport, o: { cap?: number; tiers?: ProseTier[] } = {}): string[] {
130
+ const cap = o.cap ?? 25
131
+ const tiers = new Set(o.tiers ?? (['fix', 'hard', 'soft'] as ProseTier[]))
132
+ const shown = r.hits.filter((h) => tiers.has(h.tier))
133
+ const out: string[] = []
134
+ const fileOf = new Map<ProseHit, string>()
135
+ for (const f of r.byFile) for (const h of f.hits) fileOf.set(h, f.file)
136
+ for (const h of shown.slice(0, cap))
137
+ 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())})` : ''}`)
138
+ if (shown.length > cap) out.push(`… and ${shown.length - cap} more`)
139
+ return out
140
+ }
141
+
142
+ export const classSummary = (r: ProseReport): string =>
143
+ Object.entries(r.byClass)
144
+ .sort((a, b) => b[1] - a[1])
145
+ .map(([c, n]) => `${c} ${n}${tierOf(c as ProseClass) === 'soft' ? '' : '!'}`)
146
+ .join(', ')