rvlib-shipkit 0.35.8 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +6 -0
- package/package.json +1 -1
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Fleet.ts +116 -0
- package/src/cli/cmd_Prose.ts +65 -0
- package/src/cli/cmd_Smoke.ts +42 -0
- package/src/cli/cmd_Start.ts +1 -1
- package/src/cli/cmd_Units.ts +82 -0
- package/src/cli.ts +8 -0
- package/src/core/check-suite.ts +28 -5
- package/src/core/run-check.ts +12 -1
- package/src/core/run-ci.ts +12 -2
- package/src/core/write-status.ts +5 -1
- package/src/fleet/scan-fleet.ts +142 -0
- package/src/git-bootstrap.ts +11 -5
- package/src/hooks/leak-guard.ts +313 -38
- package/src/hooks/run-hooks.ts +55 -7
- package/src/ledger/gate-units.ts +80 -27
- package/src/ledger/store.ts +7 -2
- package/src/ledger/unit-table.ts +105 -0
- package/src/prose/run-prose.ts +146 -0
- package/src/prose/ste.ts +436 -0
- package/src/repo-config.ts +42 -1
- package/src/rules/SK014-prose-ste.ts +50 -0
- package/src/rules/rule-registry.ts +2 -0
- package/src/rules/untrack-generated.ts +4 -1
- package/src/smoke/run-smoke.ts +403 -0
package/src/hooks/run-hooks.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from
|
|
|
7
7
|
import { dirname, join } from 'path'
|
|
8
8
|
import { fileURLToPath } from 'url'
|
|
9
9
|
import { commitMessageOffenses } from './commit-message.ts'
|
|
10
|
-
import { globalKeywordFile, maskPattern, runLeakGuard } from './leak-guard.ts'
|
|
10
|
+
import { globalAllowFile, globalKeywordFile, maskPattern, patternFingerprint, runLeakGuard, type LeakAllowEntry, type LeakHit, type LeakWaiver } from './leak-guard.ts'
|
|
11
11
|
import { runRootIdentity } from './root-identity.ts'
|
|
12
12
|
import { spawnWithTimeout } from '../core/spawn-timeout.ts'
|
|
13
13
|
import type { RepoConfig } from '../repo-config.ts'
|
|
@@ -21,6 +21,14 @@ export type HooksConfig = {
|
|
|
21
21
|
commitMsg?: { id: string; cmd: string[] }[]
|
|
22
22
|
/** keyword file for the leak guard (default '.rv-private/banned-keywords.txt') */
|
|
23
23
|
keywordFile?: string
|
|
24
|
+
/** known-good leak-guard matches this repo waives: a lockfile whose
|
|
25
|
+
* dependency names or integrity hashes collide with a keyword is the
|
|
26
|
+
* motivating case. Each entry carries a reason, and a waiver that fires
|
|
27
|
+
* PRINTS, so nothing passes silently. This file is tracked, so an entry
|
|
28
|
+
* names a pattern by fingerprint (`fp:…`, printed beside every hit) and
|
|
29
|
+
* never by value. The machine-global twin is ~/.rv-private/leak-allow.json.
|
|
30
|
+
* hooks/leak-guard.ts */
|
|
31
|
+
leakAllow?: LeakAllowEntry[]
|
|
24
32
|
/** staged-path prefixes that may never enter git (local state like
|
|
25
33
|
* '.comfy-ts/'). gitignore is advisory and editable; this gate is not. */
|
|
26
34
|
blockedPaths?: string[]
|
|
@@ -148,6 +156,29 @@ function seedKeywordFile(root: string, keywordFile?: string): string | null {
|
|
|
148
156
|
|
|
149
157
|
export type GateOutcome = { id: string; ok: boolean; lines: string[] }
|
|
150
158
|
|
|
159
|
+
/** a hit line: the masked pattern plus its FINGERPRINT, which is the token a
|
|
160
|
+
* waiver is written against, so nobody has to type the private word to
|
|
161
|
+
* exempt it. The file rides along for a content hit; a staged-PATH hit adds
|
|
162
|
+
* nothing, because there the path IS what matched. */
|
|
163
|
+
const hitLine = (x: LeakHit): string =>
|
|
164
|
+
` ${x.where}${x.where === 'staged content' && x.file != null ? ` ${x.file}` : ''}: ${maskPattern(x.pattern)} [${patternFingerprint(x.pattern)}]`
|
|
165
|
+
|
|
166
|
+
/** what the allowlist let through, one line per waiver. An allowlist that
|
|
167
|
+
* fires invisibly is a silent weakening, so a waived match is as loud as a
|
|
168
|
+
* blocked one and carries the reason its author wrote. */
|
|
169
|
+
function waivedLines(waived: LeakWaiver[]): string[] {
|
|
170
|
+
const rows = new Map<string, { pattern: string; reason: string; source: string; targets: Set<string> }>()
|
|
171
|
+
for (const w of waived) {
|
|
172
|
+
const key = `${w.pattern} ${w.source}`
|
|
173
|
+
const row = rows.get(key) ?? { pattern: w.pattern, reason: w.reason, source: w.source, targets: new Set<string>() }
|
|
174
|
+
row.targets.add(w.file ?? w.where)
|
|
175
|
+
rows.set(key, row)
|
|
176
|
+
}
|
|
177
|
+
return [...rows.values()].map(
|
|
178
|
+
(r) => ` waived ${maskPattern(r.pattern)} [${patternFingerprint(r.pattern)}] in ${r.targets.size} target(s) — ${r.reason} (${r.source})`,
|
|
179
|
+
)
|
|
180
|
+
}
|
|
181
|
+
|
|
151
182
|
/** the pre-commit gate: built-in leak guard, then the repo's own commands.
|
|
152
183
|
* A commit takes ~1s (build/shipkit.md): the gate times itself, and going
|
|
153
184
|
* over budget WARNs naming the fix, so drift back to slow commits is loud. */
|
|
@@ -156,16 +187,27 @@ export async function runPreCommit(root: string, config: RepoConfig): Promise<Ga
|
|
|
156
187
|
const h = config.hooks ?? {}
|
|
157
188
|
const out: GateOutcome[] = []
|
|
158
189
|
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
159
|
-
const r = runLeakGuard(root, { keywordFile: h.keywordFile })
|
|
190
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile, allow: h.leakAllow })
|
|
160
191
|
if (r.status === 'skipped') out.push({ id: 'leak-guard', ok: true, lines: [`skip: leak-guard (${r.reason})`] })
|
|
161
|
-
else if (r.status === 'clean') out.push({
|
|
192
|
+
else if (r.status === 'clean') out.push({
|
|
193
|
+
id: 'leak-guard',
|
|
194
|
+
ok: true,
|
|
195
|
+
lines: [
|
|
196
|
+
`ok: leak-guard clean (${r.patterns} pattern(s)${r.waived.length === 0 ? '' : `, ${r.waived.length} waived`})`,
|
|
197
|
+
...waivedLines(r.waived),
|
|
198
|
+
...r.notes.map((n) => ` WARN ${n}`),
|
|
199
|
+
],
|
|
200
|
+
})
|
|
162
201
|
else out.push({
|
|
163
202
|
id: 'leak-guard',
|
|
164
203
|
ok: false,
|
|
165
204
|
lines: [
|
|
166
205
|
`FAIL: leak-guard matched ${r.hits.length} banned pattern(s) — this commit would leak private content`,
|
|
167
|
-
...r.hits.map(
|
|
206
|
+
...r.hits.map(hitLine),
|
|
207
|
+
...waivedLines(r.waived),
|
|
208
|
+
...r.notes.map((n) => ` WARN ${n}`),
|
|
168
209
|
` fix: remove the content, or edit the keyword file if the pattern is wrong`,
|
|
210
|
+
` a known-good match: repo.config.ts hooks.leakAllow += { reason, paths, patterns: ['<the fp: above>'] }`,
|
|
169
211
|
],
|
|
170
212
|
})
|
|
171
213
|
}
|
|
@@ -242,9 +284,11 @@ export async function runCommitMsg(root: string, config: RepoConfig, messageFile
|
|
|
242
284
|
// the leak guard also reads the MESSAGE, a private name pasted into a
|
|
243
285
|
// commit body is as public as one pasted into a file
|
|
244
286
|
if (!(h.disable ?? []).includes('leak-guard')) {
|
|
245
|
-
const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, text: [{ label: 'commit message', content: message }] })
|
|
287
|
+
const r = runLeakGuard(root, { keywordFile: h.keywordFile, staged: false, allow: h.leakAllow, text: [{ label: 'commit message', content: message }] })
|
|
246
288
|
if (r.status === 'hit')
|
|
247
|
-
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map(
|
|
289
|
+
out.push({ id: 'leak-guard', ok: false, lines: [`FAIL: leak-guard matched in the commit message`, ...r.hits.map(hitLine), ...waivedLines(r.waived)] })
|
|
290
|
+
else if (r.status === 'clean' && r.waived.length > 0)
|
|
291
|
+
out.push({ id: 'leak-guard', ok: true, lines: [`ok: leak-guard clean in the commit message (${r.waived.length} waived)`, ...waivedLines(r.waived)] })
|
|
248
292
|
}
|
|
249
293
|
for (const c of h.commitMsg ?? []) {
|
|
250
294
|
const r = await spawnWithTimeout([...c.cmd, messageFile], { cwd: root, timeoutMs: 60_000 })
|
|
@@ -265,8 +309,12 @@ export function listGates(config: RepoConfig, root = process.cwd()): string[] {
|
|
|
265
309
|
// a gate listed as active but unable to fire is worse than no listing:
|
|
266
310
|
// this surface exists so nobody has to guess
|
|
267
311
|
const leakState = off.has('leak-guard') ? ' (DISABLED by config)' : existsSync(join(root, kw)) || globalKeywordFile() != null ? '' : ` (INACTIVE, ${kw} absent and no ~/.rv-private list)`
|
|
312
|
+
// a waiver nobody can enumerate is a hole nobody can audit: the count of
|
|
313
|
+
// declared exemptions belongs on the same surface as the gate itself
|
|
314
|
+
const waivers = (h.leakAllow ?? []).length + (globalAllowFile() != null ? 1 : 0)
|
|
315
|
+
const leakAllowState = waivers === 0 ? '' : `, ${(h.leakAllow ?? []).length} repo waiver(s)${globalAllowFile() == null ? '' : ' + ~/.rv-private/leak-allow.json'}`
|
|
268
316
|
return [
|
|
269
|
-
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.rv-private/banned-keywords.txt`,
|
|
317
|
+
`pre-commit leak-guard${leakState} — staged added lines + paths vs ${kw} + ~/.rv-private/banned-keywords.txt${leakAllowState}`,
|
|
270
318
|
...((h.blockedPaths ?? []).length > 0 ? [`pre-commit blocked-paths — nothing under ${h.blockedPaths!.join(', ')} enters git`] : []),
|
|
271
319
|
`pre-commit root-identity${off.has('root-identity') ? ' (DISABLED by config)' : ''} — the repo's own name in package.json + repo.config.ts, and scratch files`,
|
|
272
320
|
...(h.preCommit ?? []).map((c) => `pre-commit ${c.id} — ${c.cmd.join(' ')}`),
|
package/src/ledger/gate-units.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
package/src/ledger/store.ts
CHANGED
|
@@ -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(', ')
|