rvlib-shipkit 1.3.0 → 1.4.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 (80) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/package.json +1 -1
  3. package/src/asks/asks-queue.ts +4 -2
  4. package/src/assets/kits/identity-kit.ts +15 -13
  5. package/src/assets/run-assets.ts +12 -2
  6. package/src/check.ts +6 -4
  7. package/src/checks/SK001-package-json-shape.check.ts +20 -2
  8. package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
  9. package/src/checks/SK014-prose-ste.check.ts +16 -6
  10. package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
  11. package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
  12. package/src/checks/SK028-readme.check.ts +6 -2
  13. package/src/cli/cmd_CrossRepoActivity.ts +4 -1
  14. package/src/collections/collection-types.ts +15 -0
  15. package/src/collections/providers.ts +6 -2
  16. package/src/contracts/apply-contracts.ts +9 -8
  17. package/src/contracts/contract-types.ts +8 -2
  18. package/src/contracts/contracts-engine.ts +34 -13
  19. package/src/contracts/manifests/identity.contract.ts +6 -5
  20. package/src/contracts/manifests/social.contract.ts +4 -2
  21. package/src/contracts/manifests/steam.contract.ts +7 -3
  22. package/src/contracts/web-surface.ts +59 -0
  23. package/src/core/budget.ts +19 -9
  24. package/src/core/check-runner.ts +26 -5
  25. package/src/core/discover-checks.ts +8 -5
  26. package/src/core/layout.ts +2 -0
  27. package/src/core/motherhome.ts +88 -66
  28. package/src/core/run-check.ts +2 -5
  29. package/src/core/run-ci.ts +3 -3
  30. package/src/core/wire.ts +38 -9
  31. package/src/home/activity-shape.ts +4 -1
  32. package/src/home/activity.ts +22 -15
  33. package/src/home/gui-app/ShipkitGui.swift +144 -25
  34. package/src/home/gui-app.ts +93 -30
  35. package/src/home/home-launchd.ts +10 -2
  36. package/src/home/home-routes.ts +2 -1
  37. package/src/home/home-start.ts +33 -5
  38. package/src/home/machine-stores.ts +4 -2
  39. package/src/home/open-app.ts +23 -10
  40. package/src/home/pm-config.ts +15 -11
  41. package/src/home/pm-inputs.ts +76 -0
  42. package/src/home/pm.ts +3 -3
  43. package/src/home/process-rows.ts +23 -6
  44. package/src/home/resources.ts +67 -15
  45. package/src/home/run-daemon.ts +6 -1
  46. package/src/home/run-pm.ts +29 -5
  47. package/src/home/widgets.ts +32 -3
  48. package/src/hooks/commit-message.ts +2 -3
  49. package/src/hooks/run-hooks.ts +12 -2
  50. package/src/ledger/gate-checks.ts +3 -3
  51. package/src/ledger/store.ts +11 -0
  52. package/src/ledger/world-cache.ts +17 -15
  53. package/src/repo-config.ts +6 -1
  54. package/src/serve/hub-app/activity-view.tsx +3 -3
  55. package/src/serve/hub-app/checks-svc.ts +2 -1
  56. package/src/serve/hub-app/col-tables-view.tsx +124 -0
  57. package/src/serve/hub-app/col-tables.ts +115 -0
  58. package/src/serve/hub-app/cols-svc.ts +28 -0
  59. package/src/serve/hub-app/dock.css +1 -1
  60. package/src/serve/hub-app/home-st.ts +1 -1
  61. package/src/serve/hub-app/home-strip.css +32 -17
  62. package/src/serve/hub-app/home-strip.tsx +99 -89
  63. package/src/serve/hub-app/hub-app.tsx +7 -1
  64. package/src/serve/hub-app/hub-types.ts +6 -3
  65. package/src/serve/hub-app/hub.css +24 -2
  66. package/src/serve/hub-app/icons.tsx +4 -0
  67. package/src/serve/hub-app/keys.ts +6 -6
  68. package/src/serve/hub-app/machine-st.ts +26 -37
  69. package/src/serve/hub-app/palette-st.ts +5 -5
  70. package/src/serve/hub-app/palette-view.tsx +1 -1
  71. package/src/serve/hub-app/project-list.tsx +3 -3
  72. package/src/serve/hub-app/repo-st.ts +1 -1
  73. package/src/serve/hub-app/runs-tabs.ts +1 -1
  74. package/src/serve/hub-app/timeline-view.tsx +14 -11
  75. package/src/serve/project-gutter.ts +1 -2
  76. package/src/serve/warm-cell.ts +49 -16
  77. package/src/suite/judge-suites.ts +42 -17
  78. package/src/suite/suite-cli.ts +5 -8
  79. package/src/wiki/lint-md.ts +2 -0
  80. package/src/wiki/scan-md.ts +37 -10
package/CHANGELOG.md CHANGED
@@ -1,3 +1,40 @@
1
+ ## 1.4.0
2
+
3
+ - **Fix: `shipkit gui` wears the house mark from any directory.** The app took its icon from cwd, so a run outside every house repo rebuilt `Shipkit.app` with no icon. The house now comes from cwd, else from the installed pm agent's `WorkingDirectory` (`installedHouseRoot`, `guiRoot`), and never from cwd itself.
4
+
5
+ - **Fix: a busy machine never fails a suite as a wait.** The wait verdict judges the wall against `waitAllowance`, twice the budget divided by `1 - share²`, which has no end once the load reaches the core count; `fairWall` is gone. The fair share model failed four suites at 11 to 19s wall with about 1s of CPU at load 20.5 on 8 cores. The FAIL line names the load a wait was judged at instead of "on an idle machine", and `overWords` moved to `judge-suites.ts`.
6
+ - `Answers.busyTimeout()` names the busy timeout sqlite holds on the open handle, so a test asserts a read waits only `READ_BUSY_MS` on a writer instead of timing it.
7
+ - **Fix: a save in a repo nobody looks at costs the daemon nothing.** A hub's warm answers (rows, runs page, wiki graphs) recompute in the background only while a page read them in the last 30s (`WARM_WATCH_MS`), at most once per ten times their last compute (`WARM_SPACING`); the first reader after that waits on one fresh compute. Measured on a live daemon, one file of a large repo touched every 2s: 83.5% cpu before, 5.0% after.
8
+ - **Fix: widgets scan and render only while someone reads them.** No read of `/api/home/widgets.json` for 30s (`WIDGET_WATCH_MS`), and the daemon walks no repo and renders no widget; the first read after that waits on a scan and a render. `Widgets.look(repos)` is the reader's call.
9
+ - **Fix: pm reads the house side again only when it moved.** The `pm-config.ts` child answers with the paths it read and their stamp, and pm stats them every 15s (about 4ms) instead of spawning a child that imports every unit file (about 0.6s of cpu). A dir counts by its subdirs and unit files, so a temp file coming and going in a repo root moves nothing. `repoUnitFiles(root, dirs)` collects the dirs its walk read.
10
+ - **Fix: an ask probe runs with the env the process holds now.** `shipkit asks check` and SK026 spawn each probe with `process.env` named, since bun's default is the env the process started with, blind to a later write such as a caller pointing `HOME` elsewhere.
11
+ - **Fix: the line SK009 asks a foreign commit-msg hook to add works on a clone without `node_modules`.** It reads `if [ -f <cli> ]; then <shipkit> hooks commit-msg "$1" || exit 1; else echo "… attribution gate skipped" >&2; fi`, so a fresh clone skips the gate with a note instead of failing every commit. The bare form and the multi line guarded form both still count as chained.
12
+ - **Fix: the runs page list never moves in.** The `reading the run record…` line sits inside the list box, where the list will be, so the list no longer drops 8px when it arrives (a refresh of the runs tab measured CLS 0.0205, now 0.0019).
13
+ - **The checks page and the asks page are tables.** One line per item on the activity table style instead of a card grid, under group headers that count their lines. A check line: status dot, id, title, age of its last result, duration against its budget, the first finding line (cut, every line on hover), and how it is planned (`on change`, `every 7d`, `once`, `every run`, `health pass`, `every release`, `off`); groups failing, warning, never ran, passing, off, most recent first. An ask line: kind, title, age, what it blocks, `auto close` or `manual`; open asks by what they block then oldest first, done ones after. Passing, off and done fold by default, a header click folds or opens, and the choice is kept per viewer (`hub:table-folds`). The filter input and the `a`, `n`, `r` keys narrow the lines. An ask resource now carries `ask: AskFacts` (kind, created, closed, blocks, probe), and `Ask` gains `closed`.
14
+ - **Every url on the overview opens three ways.** Before the url: open in Chrome, and open as the process's own app (a spinner while it builds). The url itself opens the default browser. The open pick in the url header is gone, and `MachineSt.openProcess` takes the way as its second argument.
15
+ - **The shipkit app finds and inspects.** Cmd-F opens the page's find bar (cmd-G, shift-cmd-G, Esc), alt-cmd-I the Web Inspector (Edit › Find, View › Developer Tools). The zoom keys stay.
16
+ - **Every global process with a url can open as its own app.** One Swift source serves the shipkit window and each process's app: the bundle's Info.plist names it and its start page. The app is built on first use into the declaring repo's `.shipkit/wrapper/<id>.app` (bundle id `dev.shipkit.app.<id>`, its image icon, else the repo's mark), rebuilt only when the source, the icon or the url changed. pm's resource route takes `chrome` (Chrome, else the default browser) beside `open` and `app`, and reports `appBusy` while an app builds.
17
+ - **Fix: no empty band at the bottom of every page.** The daemon or pm not answering is a toast floating over the bottom right corner of the page column, above the dock (`MachineToasts`, replacing `MachineBar`), drawn only while it lasts and taking no room.
18
+ - **The overview's process switches are small icons beside the label.** Play (stop while it runs) and restart sit in their own narrow columns right after the process name, every play under every play, each with a tooltip; open is an open external icon before the url, which stays a link. The new window button and the open choice sit in the url's header. New icons `play`, `stop`, `restart`, `external`.
19
+ - **The runs page opens on its tabs.** The record chart no longer stands over the tabs: it shows only in the `runs` tab, under its list. `test files to fix`, `slowest checks` and `longest runs` draw no chart.
20
+ - **Smaller monitor cards.** The `.wbar` meter is 40px, a meter's value cell takes the class `wv` (11px, tabular figures), and a widget card's padding and line height are tighter.
21
+ - **SK028 names both ways out.** Each finding asks for the screenshot, or for the rule turned off with its reason by the exact key: `when: { SK028: 'off:<why this repo shows no screenshot>' }` in repo.config.ts, which refuses an `off` with no reason.
22
+ - **SK016 says the real refusal condition and offers `--fix` only when it can fix.** The line reads `still read, and refused by the first shipkit release after \`shipkit fleet\` shows no repo below 0.40 and none still carrying a check script`, instead of `read through 0.40, refused after`. Its pointer says `shipkit check --fix SK016 rewrites N of them` while something is mechanical, and that the port is by hand when nothing is. A fix pointer that says `by hand` no longer gets the `· autofix:` suffix, the TODO placeholder warning included.
23
+ - **Fix: the overview never moves when the daemon or pm stops answering.** A process's note or a refused take over is a warn mark after its state words, the words on hover, instead of a line of its own. The daemon, the web or pm not answering reads `no answer` in its own row (row status `silent`, `CoreOpts.silent`), and while pm is silent every global process keeps its line with its state `unknown`. A widget error is a mark on its card's title. The table waits for pm's first answer and the list's order, so a cold load adds no row after its first paint. `bun run home:shift` now also refuses the daemon's and pm's answers in the browser and requires the table's height unchanged.
24
+ - **SK001 asks for `lint` and `fmt` only once the repo has a source file**, like `test` already waits for a test file. A source file is a code file outside installs, build output, dot dirs and `repo.config.ts`, with more in it than an empty `export {}`, or any test file.
25
+ - **Fix: the suite budget verdict is stable.** One continuous rule replaces the step at load == cores: the load is read before the run (never after this run's own parallel phase) and enters as a share of the cores; a suite is charged the smaller of its CPU and its wall, so bun's GC and JIT threads and side by side children are never billed as the suite's time, against a CPU allowance that grows with the load share up to 1.5 times the budget; a wait fails when the wall at a fair share of the machine passes twice the budget. A check's own budget warning (`passed in 12.0s, over its 10s budget`) is no longer printed while the load is at or past the core count, where the wall is the machine's. `SHIPKIT_MACHINE_LOAD=<load1>/<cores>` pins the reading for tests.
26
+ - **Fix: a `never:` check gets the subprocess budget.** A check that declares `never` reads the world like a `ttl` one, so its default budget is 10s instead of the 2s of an in process body: SK026, which runs each ask's own shell probe, no longer warns at 7s.
27
+ - **SK014 soft hits are a note.** With no `prose.baseline`, the soft classes print as one note, `N soft hit(s) need a human read (sentence 900, passive 400, modal 120, …)`, naming every soft class with its count, instead of a warning that named only two of them. Within a declared baseline it stays a note, above it the check fails with the same breakdown.
28
+ - **⌘J opens the picker and ⌘K the overview.** The lines on top of the project list read jump to… ⌘J, overview ⌘K, activity ⌘L, planned ⌘', and the picker lists home, activity and planned in that order.
29
+ - **SK009 checks the hook, not the history.** The commit-msg hook is what refuses AI attribution, so SK009 now checks in every repo that it is armed: `core.hooksPath` at `.shipkit/hooks`, or a foreign dir whose commit-msg chains the shipkit gate, and the finding prints the exact line to add (`<shipkit> hooks commit-msg "$1" || exit 1`). It no longer scans `git log`: what is pushed is accepted as it is. A declared-public repo still needs the `.claude/settings.json` silencer and a clean ref name; a telling branch nobody pushed warns as `local only` instead of failing. `hooks.disable: ['commit-message']` no longer lifts the attribution rule: it runs as its own `ai-attribution` gate, never disabled.
30
+ - **Fix: a gate never writes or commits in another repo.** `shipkit ci` and `check` used to rewrite the docsBase wiki's `local-projects.md` and commit it there ("chore(machine): refresh local-projects row"), so a narrowed gate in one repo landed commits in the house repo. They now file the row in the machine db only. The daemon's hourly sweep (`startSweeps` takes an `also`) runs `landMotherhome`, which writes every filed row into its page and lands it as one scoped commit, at most once a day per page (`LAND_GAP_MS`), with the same churn rule (data change or 7 days) and the same dead path pruning.
31
+ - **SK002 asks each contract only where it means something.** A public surface is a declared venue, `deploy.sh`, a GitHub Pages workflow, a wrangler config, or `visibility: 'public'` (unless an npm package is the repo's only surface). Before one exists the identity contract is silent instead of a warning, and after, it wants favicon, OG image and logo at the dir the deploy serves (a Pages artifact path, a wrangler output dir), overridden by the new `contracts.webRoot`, `public` otherwise; `shipkit assets identity` writes there too. An undeclared `.shipkit/social/` ships only once a post says `status: posted`: a folder of drafts is a note. A `discord` social channel no longer requires the discord server contract. Steam asks for `vdf/` only once `.shipkit/steam/steam_appid.txt` records the appid, and names that blocker before. Contract lines read `steam: …` instead of a double space. New detector `fileMatches(glob, pattern)`.
32
+ - **Fix: a remembered world answer says how old it is, never a clock time frozen into its text.** A `ttl` check's first line carried `(ran at 21:47:51, kept until 22:47:51)` in its stored text, which the next day still read as an expiry to come. The times now ride as data on the line (`Line.stamp`: `at`, `until`, `cached`), and every surface renders them against its own clock: `(ran just now, kept 1h more)`, `(cached, ran 3h ago, kept 57m more)`, `(ran 1d ago, expired 1d ago)`. `formatLine` takes an optional `now`, and `lineText`, `stampWords` and `spanShort` are exported from the wire module. An answer stored by an older build drops its baked times on replay.
33
+ - **Fix: every status surface shows every finding of a check, not the first one.** A failed build step that speaks the wire (the suite runner) keeps the FAIL lines its report closes on, and its headline counts them: `test — FAIL: 3 test(s) failed (+1 more below)`, the budget failure listed under it instead of hidden. The Activity page's hover list and `shipkit activity` list every fail and warn line of each check, the worst weight first (`StatusCheck.lines`), so SK009 shows its 17 attributed commits beside its ref name, and `tts` both of its warnings.
34
+ - **Fix: a non-blocking failure reads orange everywhere, as the gate prints it.** The run record filed a failure of a `blocking: false` check as `fail`, so Activity, the rail's dot and the check feed painted it red while the gate printed WARN and exited 0. The record now files it as `warn`, the status the gate reported.
35
+ - **Fix: SK027 no longer flags a working link.** A link that leaves `agent/` but stays in the repo (`../tools/readme.md`) is checked on disk and fails only when the file is really missing. A link to a folder (`[docs](external-docs/)`, or a backticked `agent/external-docs/`) reaches every page under it. An orphan page still fails. The dangling hint no longer tells anyone to backtick a working link. `scanMd` takes a `boundary`, the dir a ref may leave the scan root for.
36
+ - **SK027 says how to fix an orphan.** The finding asks for a markdown link from the page that should mention it, and names that page when one already names the file (its path, its file name, or a glob like `*_Compte_rendu_*`): `add a markdown link to it from agent/brief.md, which already names it`. Otherwise it points at `agent/index.md or the page of its topic`, and deleting comes second, for a dead page only. An `@./page.md` include now counts as a link.
37
+
1
38
  ## 1.3.0
2
39
 
3
40
  - **`shipkit ci` prints what the reader acts on, and nothing else.** A green run is one line per check that ran and one verdict line: `ok: test 920 tests (1.4s)` then `ok: 1 check green`, where it used to print nine. A test check carries its count on its own line. The whole gate folds its tier and cache counts into the verdict (`ok: 31 checks green; quick tier; 25 cached, 5 ran, 1 never cached`). A fix pass prints only when it rewrote files, and names them. A green check prints one line, a warned one only its warnings and their `fix:` pointers, a failure prints whole. `--only` says nothing about checks outside its selection (floor gaps, `off` checks, parked checks). `--verbose` restores every line the default leaves out: the clean fix pass, the machine pool, the tier, the cache declarations, the parked checks, the old summary and the cache counts. STATUS.md keeps them all, a release or bump gate still prints them all, and `--json` is unchanged.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rvlib-shipkit",
3
- "version": "1.3.0",
3
+ "version": "1.4.0",
4
4
  "funding": "https://github.com/sponsors/rvion",
5
5
  "description": "All-in-one repo companion: ci gate (task DAG), auto-discovered health checks, release-contract fulfillness, asks queue, md-wiki tools, asset atelier, live tui",
6
6
  "license": "MIT",
@@ -9,7 +9,7 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFile
9
9
  import { atomicWrite, mutateLease, withMutateLease } from '../core/locked-write.ts'
10
10
  import { join } from 'path'
11
11
 
12
- export type Ask = { file: string; status: string; kind: string; created: string; blocks: string; check: string | null; title: string }
12
+ export type Ask = { file: string; status: string; kind: string; created: string; closed: string | null; blocks: string; check: string | null; title: string }
13
13
  export type CreateOpts = { kind?: string; blocks?: string; check?: string; why?: string }
14
14
 
15
15
  /** filename slug: kebab, truncated at a WORD boundary (never mid-word — a
@@ -31,6 +31,7 @@ const parse = (dir: string, file: string): Ask => {
31
31
  status: fm('status') ?? 'open',
32
32
  kind: fm('kind') ?? 'action',
33
33
  created: fm('created') ?? '?',
34
+ closed: fm('closed'),
34
35
  blocks: fm('blocks') ?? '-',
35
36
  check: fm('check'),
36
37
  title: md.match(/^# (.+)$/m)?.[1] ?? file,
@@ -73,7 +74,8 @@ function checkAsksBody(dir: string, print: (line: string) => void): { closed: nu
73
74
  for (const a of listAsks(dir)) {
74
75
  if (a.status === 'done') { renameSync(join(dir, a.file), join(done, a.file)); print(`archived ${a.file} → done/`); continue }
75
76
  if (a.check == null) { print(`still ${a.status.padEnd(8)} ${a.file} (manual — no probe)`); continue }
76
- const p = Bun.spawnSync(['bash', '-lc', a.check])
77
+ // env named: bun's default is the env the process started with, blind to a later process.env write
78
+ const p = Bun.spawnSync(['bash', '-lc', a.check], { env: process.env })
77
79
  if (p.exitCode === 0) {
78
80
  const md = readFileSync(join(dir, a.file), 'utf8').replace(/^status:.*$/m, `status: done\nclosed: ${new Date().toISOString().slice(0, 10)}`)
79
81
  atomicWrite(join(dir, a.file), md)
@@ -7,9 +7,9 @@
7
7
  // hook, so picking a logo roll re-renders the whole identity set from the
8
8
  // picked png. Headless `identity` uses the current pick — no pick yet is a
9
9
  // loud FAIL, never a placeholder.
10
- // Outputs are COMMITTED: public/favicon.png (512² master), public/og.png
11
- // (1200×630), public/logo.png. Brand data is per-repo and never defaults
12
- // across repos.
10
+ // Outputs are COMMITTED at the web root (public/ unless the deploy says
11
+ // otherwise): favicon.png (512² master), og.png (1200×630), logo.png. Brand
12
+ // data is per-repo and never defaults across repos.
13
13
  import { createCanvas, loadImage, type Image, type SKRSContext2D } from '@napi-rs/canvas'
14
14
  import { mkdirSync } from 'fs'
15
15
  import { dirname, join } from 'path'
@@ -81,11 +81,13 @@ const defaultOg = (ctx: SKRSContext2D, brand: Brand, size: { w: number; h: numbe
81
81
  }
82
82
 
83
83
  /** THE identity writer — any mark png (canvas draw or picked comfy roll) →
84
- * public/logo.png (1024² master) + public/favicon.png (512² resize) +
85
- * public/og.png (default card: bg + mark left + name/tagline right). */
86
- export async function renderIdentityFromMark(brand: Brand, root: string, markPng: Buffer): Promise<string[]> {
84
+ * <web>/logo.png (1024² master) + <web>/favicon.png (512² resize) +
85
+ * <web>/og.png (default card: bg + mark left + name/tagline right), where
86
+ * <web> is the dir the site serves (contracts/web-surface.ts, default public) */
87
+ export async function renderIdentityFromMark(brand: Brand, root: string, markPng: Buffer, webRoot = 'public'): Promise<string[]> {
87
88
  const out: string[] = []
88
- const write = async (rel: string, buf: Buffer): Promise<void> => {
89
+ const write = async (name: string, buf: Buffer): Promise<void> => {
90
+ const rel = webRoot === '.' ? name : `${webRoot}/${name}`
89
91
  const path = join(root, rel)
90
92
  mkdirSync(dirname(path), { recursive: true })
91
93
  await Bun.write(path, buf)
@@ -94,26 +96,26 @@ export async function renderIdentityFromMark(brand: Brand, root: string, markPng
94
96
  const logoPng = await sharp(markPng)
95
97
  .resize(LOGO_SIZE, LOGO_SIZE, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
96
98
  .png().toBuffer()
97
- await write('public/logo.png', logoPng)
99
+ await write('logo.png', logoPng)
98
100
  // favicon = logo master resized to 512² (browsers derive the rest)
99
- await write('public/favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
101
+ await write('favicon.png', await sharp(logoPng).resize(FAVICON_SIZE, FAVICON_SIZE).png().toBuffer())
100
102
  const og = createCanvas(OG_W, OG_H)
101
103
  const ctx = og.getContext('2d')
102
104
  if (brand.drawOg != null) brand.drawOg(ctx, brand, { w: OG_W, h: OG_H })
103
105
  else defaultOg(ctx, brand, { w: OG_W, h: OG_H }, await loadImage(logoPng))
104
- await write('public/og.png', og.toBuffer('image/png'))
106
+ await write('og.png', og.toBuffer('image/png'))
105
107
  return out
106
108
  }
107
109
 
108
110
  /** Headless render of the full identity set. Canvas marks draw themselves;
109
111
  * comfy marks need `markFile` = the PICKED roll (the caller resolves the
110
112
  * pick — run-assets does) and FAIL loud without one. */
111
- export async function renderIdentity(brand: Brand, root: string, opts?: { markFile?: string }): Promise<string[]> {
113
+ export async function renderIdentity(brand: Brand, root: string, opts?: { markFile?: string; webRoot?: string }): Promise<string[]> {
112
114
  if (brand.mark.backend === 'canvas') {
113
115
  const logo = createCanvas(LOGO_SIZE, LOGO_SIZE)
114
116
  await brand.mark.draw(logo.getContext('2d'), { id: 'identity-logo', prompt: '' })
115
- return renderIdentityFromMark(brand, root, logo.toBuffer('image/png'))
117
+ return renderIdentityFromMark(brand, root, logo.toBuffer('image/png'), opts?.webRoot)
116
118
  }
117
119
  if (opts?.markFile == null) throw new Error(`brand '${brand.name}' uses a comfy mark and no roll is picked — ${NO_PICK_FIX}`)
118
- return renderIdentityFromMark(brand, root, await sharp(opts.markFile).png().toBuffer())
120
+ return renderIdentityFromMark(brand, root, await sharp(opts.markFile).png().toBuffer(), opts.webRoot)
119
121
  }
@@ -8,6 +8,9 @@ import { comfyResolver, generateRoll, type ComfyResolver } from './gen/generate-
8
8
  import { parentIdsOf, resolveParentContext, topoSort, type ParentContext } from './gen/derived.ts'
9
9
  import { allAssets, findAsset, loadKit, type ComfyGen, type ResolvedKit } from './kit-config.ts'
10
10
  import { NO_PICK_FIX, renderIdentity, type Brand } from './kits/identity-kit.ts'
11
+ import { webDeploys, webRootOf } from '../contracts/web-surface.ts'
12
+ import { CheckCtx } from '../core/check-ctx.ts'
13
+ import { defineRepo, loadRepoConfig } from '../repo-config.ts'
11
14
  import { assetDir, listRolls, readPick, readPromptOverlay } from './roll-workspace.ts'
12
15
 
13
16
  export type Print = (line: string) => void
@@ -111,6 +114,12 @@ const brandHashPath = (root: string): string => join(root, '.shipkit/identity/br
111
114
  const brandHash = async (root: string): Promise<string> =>
112
115
  new Bun.CryptoHasher('sha256').update(await Bun.file(join(root, '.shipkit/identity/brand.ts')).text()).digest('hex').slice(0, 16)
113
116
 
117
+ /** the dir the identity outputs go to: the same answer SK002 reads */
118
+ async function repoWebRoot(root: string): Promise<string> {
119
+ const config = existsSync(join(root, 'repo.config.ts')) ? await loadRepoConfig(root) : defineRepo({ name: 'repo' })
120
+ return webRootOf(config.contracts?.webRoot, webDeploys(CheckCtx.open({ root, config })))
121
+ }
122
+
114
123
  export type IdentityRender = { written: string[] } | { blocked: string }
115
124
 
116
125
  export async function renderIdentityNow(root: string): Promise<IdentityRender> {
@@ -133,7 +142,7 @@ export async function renderIdentityNow(root: string): Promise<IdentityRender> {
133
142
  markFile = join(dir, pick.pick)
134
143
  if (!existsSync(markFile)) return { blocked: `picked roll ${pick.pick} vanished from ${dir} — re-pick in the studio` }
135
144
  }
136
- const written = await renderIdentity(brand, root, { markFile })
145
+ const written = await renderIdentity(brand, root, { markFile, webRoot: await repoWebRoot(root) })
137
146
  await Bun.write(brandHashPath(root), (await brandHash(root)) + '\n')
138
147
  return { written }
139
148
  }
@@ -142,7 +151,8 @@ export async function renderIdentityNow(root: string): Promise<IdentityRender> {
142
151
  * Returns what happened; 'blocked' = only a human pick can clear it. */
143
152
  export async function healIdentity(root: string): Promise<{ state: 'fresh' | 'rendered' | 'blocked' | 'absent'; detail?: string }> {
144
153
  if (!existsSync(join(root, '.shipkit/identity/brand.ts'))) return { state: 'absent' }
145
- const outputsMissing = ['public/favicon.png', 'public/og.png', 'public/logo.png'].some((p) => !existsSync(join(root, p)))
154
+ const web = await repoWebRoot(root)
155
+ const outputsMissing = ['favicon.png', 'og.png', 'logo.png'].some((p) => !existsSync(join(root, web, p)))
146
156
  const stored = existsSync(brandHashPath(root)) ? (await Bun.file(brandHashPath(root)).text()).trim() : null
147
157
  if (!outputsMissing && stored === (await brandHash(root))) return { state: 'fresh' }
148
158
  const r = await renderIdentityNow(root)
package/src/check.ts CHANGED
@@ -160,7 +160,8 @@ export type CheckDef = {
160
160
  * passes over it prints a WARN and reads yellow, and never blocks.
161
161
  * Judged on the wall time of a run that measured, at the quick tier.
162
162
  * Default 2000 for an in process body, 10000 for a build step, a `cmd`
163
- * or a world probe (core/budget.ts). The repo's `budgets` wins over it. */
163
+ * or a world probe (one that declares `ttl` or `never`, core/budget.ts).
164
+ * The repo's `budgets` wins over it. */
164
165
  budgetMs?: number
165
166
  /** a label several checks share, so `--skip <group>` parks all of them
166
167
  * and `--only <group>` runs all of them. */
@@ -373,12 +374,13 @@ export async function runCheckBody(
373
374
  }
374
375
 
375
376
  /** tag every severity line with the check id; a fix pointer of a fixable check
376
- * advertises the autofix. Idempotent, since an already-tagged line passes
377
- * back through here on a cached suite replay. */
377
+ * advertises the autofix, unless it names its own `--fix` or says the repair
378
+ * is `by hand`. Idempotent, since an already-tagged line passes back through
379
+ * here on a cached suite replay. */
378
380
  export function tagCheckLines(u: { id: string; fix?: unknown }, lines: Line[]): Line[] {
379
381
  return lines.map((l) => {
380
382
  if (l.kind === 'fix-warn' || l.kind === 'fix-fail')
381
- return u.fix == null || l.text.includes('--fix ')
383
+ return u.fix == null || l.text.includes('--fix ') || /\bby hand\b/.test(l.text)
382
384
  ? l
383
385
  : { ...l, text: `${l.text} · autofix: shipkit check --fix ${u.id}` }
384
386
  if (l.kind === 'note') return l
@@ -107,8 +107,11 @@ export function evaluatePackageJson(t: CheckCtx): void {
107
107
 
108
108
  // `test` is asked for only once the repo HAS tests: the fixer deliberately
109
109
  // refuses to scaffold it before that (a fixer never manufactures a
110
- // failure), so nagging for it would be a WARN no machine may clear
111
- const baseline = BASELINE.filter((n) => n !== 'test' || hasTestFiles(testFiles(t)))
110
+ // failure), so nagging for it would be a WARN no machine may clear. For
111
+ // the same reason `lint` and `fmt` wait for a source file to read
112
+ const tests = hasTestFiles(testFiles(t))
113
+ const source = tests || hasSourceFiles(t)
114
+ const baseline = BASELINE.filter((n) => (n === 'test' ? tests : source))
112
115
  const missingBaseline = baseline.filter((n) => !has(n))
113
116
  if (missingBaseline.length > 0)
114
117
  t.warn(`package.json: missing baseline script(s): ${missingBaseline.join(', ')}`)
@@ -173,6 +176,21 @@ export function hasTestFiles(paths: readonly string[]): boolean {
173
176
  })
174
177
  }
175
178
 
179
+ const SOURCE_GLOB = '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}'
180
+ /** an empty module: nothing but `export {}`, semicolons and whitespace */
181
+ const EMPTY_MODULE = /^(?:\s|;|export\s*\{\s*\})*$/
182
+
183
+ /** a code file with something in it, outside installs, build output, dot dirs
184
+ * and the repo config: what lint and fmt would read. Stops at the first */
185
+ function hasSourceFiles(c: CheckCtx): boolean {
186
+ for (const f of c.list(SOURCE_GLOB)) {
187
+ const parts = f.split('/')
188
+ if (f === 'repo.config.ts' || !parts.slice(0, -1).every((d) => !TEST_SKIP.has(d) && !d.startsWith('.'))) continue
189
+ if (!EMPTY_MODULE.test(c.read(f) ?? '')) return true
190
+ }
191
+ return false
192
+ }
193
+
176
194
  export const packageJsonShape = check({
177
195
  id: 'SK001',
178
196
  blocking: true,
@@ -1,23 +1,22 @@
1
- // SK009 — commit AI tells (identity/how-we-commit.md): a repo that will be
2
- // seen must not read as AI-driven. Prevention lives at the source (the
3
- // attribution setting + the standing from-commit-one ban, which binds every
4
- // repo because history follows a repo public and is never rewritten); this
5
- // rule is the declared-public tripwire: the FULL git log scanned for AI
6
- // attribution and dash punctuation, plus the tracked settings file that
7
- // silences attribution. Declared-private repos idle with an ok that RESTATES
8
- // the ban — a green line must never teach the wrong rule. The remedy for
9
- // pushed history is the owner's decision, never an automatic rewrite; only
10
- // an unpushed tip commit may be amended, and the fails say so.
1
+ // SK009 commit AI tells (identity/how-we-commit.md): the commit-msg hook is
2
+ // what refuses AI attribution, in every repo, so this check verifies the hook
3
+ // is ARMED: shipkit's own hooksPath, or a foreign one whose commit-msg chains
4
+ // the shipkit gate. It never scans history: what is pushed is accepted as it
5
+ // is, and only a new commit can still be stopped. A declared-public repo also
6
+ // needs the tracked attribution silencer, and its ref NAMES are checked (a
7
+ // FABLE-checkpoint tag outs the agent like a trailer); a branch nobody pushed
8
+ // is named "local only" and warns.
11
9
  import { createHash } from 'crypto'
12
10
  import { readFileSync } from 'fs'
13
- import { join } from 'path'
14
- import { hasAiAttribution, hasDashPunctuation } from '../hooks/commit-message.ts'
11
+ import { isAbsolute, join } from 'path'
15
12
  import { nestedIn } from '../core/nesting.ts'
16
13
  import { gitBin } from '../core/git-bin.ts'
17
14
  import { fileStamp } from '../core/file-stamp.ts'
18
15
  import { CONFIG_FILE } from '../core/check-pass.ts'
16
+ import { LAYOUT } from '../core/layout.ts'
19
17
  import { check, type CheckEnv } from '../check.ts'
20
18
  import { declaredVisibility } from '../core/repo-facts.ts'
19
+ import { hookBinInvocation } from '../hooks/run-hooks.ts'
21
20
 
22
21
  // the documented silencer: .claude/settings.json attribution.commit/.pr both
23
22
  // empty — the harness then never writes the trailer or the PR footer
@@ -30,81 +29,100 @@ const attributionSilenced = (root: string): boolean => {
30
29
  }
31
30
  }
32
31
 
33
- /** what the answer depends on: the declared visibility, and for a public
34
- * repo the commit HEAD names, every ref name and the silencer. So a commit,
35
- * a fetch or a new tag asks again, and nothing else does */
32
+ const readOrNull = (abs: string): string | null => {
33
+ try {
34
+ return readFileSync(abs, 'utf8')
35
+ } catch {
36
+ return null
37
+ }
38
+ }
39
+
40
+ /** a commit-msg body that runs the shipkit gate on the message: a live line
41
+ * (never a comment) whose failure is not swallowed by `|| true` */
42
+ const chainsShipkit = (body: string): boolean =>
43
+ body.split('\n').some((l) => !l.trim().startsWith('#') && /\bhooks\s+commit-msg\b/.test(l) && !/\|\|\s*(?:true|:)\b/.test(l))
44
+
45
+ /** the chain line to paste into a foreign hook: a clone with no install skips
46
+ * the gate with a note, rather than failing every commit */
47
+ export const guardedChain = (bin: string): string => {
48
+ const cli = bin.replace(/^bun\s+/, '')
49
+ return `if [ -f ${cli} ]; then ${bin} hooks commit-msg "$1" || exit 1; else echo "commit-msg: rvlib-shipkit not installed (run bun install), attribution gate skipped" >&2; fi`
50
+ }
51
+
52
+ // ref NAMES tell too. bare match only for names with no other meaning;
53
+ // vendor and codename words (cursor, openai, gemini, opus, fable, …) name
54
+ // legitimate branches and features, they only match in a checkpoint/session shape
55
+ const REF_TELL = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
56
+
57
+ const gitOut = (root: string, args: string[]): { code: number; out: string } => {
58
+ const p = Bun.spawnSync([gitBin(), ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
59
+ return { code: p.exitCode, out: p.stdout.toString() }
60
+ }
61
+
62
+ /** what the answer depends on: the declared visibility, the hooksPath and the
63
+ * commit-msg file it points at, and for a public repo every ref NAME and the
64
+ * silencer. A commit moves none of them */
36
65
  export function historyKey(env: CheckEnv): string {
37
- const declared = declaredVisibility(env.config)
38
66
  const nested = nestedIn(env.root)
39
67
  if (nested != null) return `nested in ${nested}`
40
- if (declared !== 'public') return `declared ${declared}`
41
- const git = (args: string[]): string => {
42
- const p = Bun.spawnSync([gitBin(), ...args], { cwd: env.root, stdout: 'pipe', stderr: 'pipe' })
43
- return p.exitCode === 0 ? p.stdout.toString() : `exit ${p.exitCode}`
44
- }
45
- const refs = git(['for-each-ref', '--format=%(refname) %(objectname)', 'refs/tags', 'refs/heads', 'refs/remotes'])
46
- const head = git(['rev-parse', 'HEAD'])
47
- const digest = createHash('sha256').update(head).update('\0').update(refs).digest('hex').slice(0, 16)
48
- return `public|refs=${digest}|settings=${fileStamp(join(env.root, '.claude', 'settings.json'))}`
68
+ const declared = declaredVisibility(env.config)
69
+ const hooksPath = gitOut(env.root, ['config', 'core.hooksPath'])
70
+ const dir = hooksPath.code === 0 ? hooksPath.out.trim() : ''
71
+ const hook = `hooks=${dir}|msg=${dir === '' ? 'none' : fileStamp(isAbsolute(dir) ? join(dir, 'commit-msg') : join(env.root, dir, 'commit-msg'))}`
72
+ if (declared !== 'public') return `declared ${declared}|${hook}`
73
+ const refs = gitOut(env.root, ['for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes']).out
74
+ const digest = createHash('sha256').update(refs).digest('hex').slice(0, 16)
75
+ return `public|${hook}|refs=${digest}|settings=${fileStamp(join(env.root, '.claude', 'settings.json'))}`
49
76
  }
50
77
 
51
78
  export const commitAiTells = check({
52
79
  id: 'SK009',
53
80
  blocking: true,
54
81
  title: 'commit ai tells',
55
- description: 'the history of a public repo carries no AI attribution and no dash punctuation',
82
+ description: 'the commit-msg hook that refuses AI attribution is armed, and no ref name tells',
56
83
  owner: 'identity/how-we-commit.md',
57
- // it scans the full `git log` and every ref name, which no tree id
58
- // describes, so the refs themselves are the key
84
+ // git config and ref names, which no tree id describes, so they are the key
59
85
  inputs: [CONFIG_FILE],
60
86
  cacheKey: historyKey,
61
87
  run: async (c) => {
62
88
  const nested = nestedIn(c.root)
63
- if (nested != null)
64
- return void c.lines.push({ kind: 'skip', text: `commit history not scanned, nested in ${nested}: that history belongs to the parent repo` })
65
- const declared = declaredVisibility(c.config)
66
- if (declared !== 'public')
67
- return c.ok(`declared ${declared} — the no-AI-attribution ban still applies from commit one (identity/how-we-commit.md); the full-history scan arms at visibility: 'public'`)
68
- // %x1f separates hash from body, %x1e terminates the record — commit
69
- // bodies are free text, so only unprintable separators are safe
70
- const log = await c.exec(['git', 'log', '--format=%h%x1f%B%x1e'], { cwd: c.root, timeoutMs: 30_000 })
71
- if (log.code !== 0) {
72
- const first = log.out.trim().split('\n').find((l) => l.trim() !== '')?.slice(0, 120) ?? `exit ${log.code}`
73
- return void c.lines.push({ kind: 'skip', text: `commit history unverifiable — git says: ${first}` })
89
+ if (nested != null) return void c.lines.push({ kind: 'skip', text: `commit gates not checked, nested in ${nested}: the parent repo owns them` })
90
+ const top = await c.exec(['git', 'rev-parse', '--show-toplevel'], { cwd: c.root })
91
+ if (top.code !== 0) return void c.lines.push({ kind: 'skip', text: 'not a git repo: no commit to guard yet' })
92
+
93
+ const hp = await c.exec(['git', 'config', 'core.hooksPath'], { cwd: c.root })
94
+ const dir = hp.code === 0 ? hp.out.trim() : ''
95
+ const chain = guardedChain(hookBinInvocation(c.root))
96
+ if (dir === '' && process.env['CI'] != null && process.env['CI'] !== '') {
97
+ c.lines.push({ kind: 'skip', text: 'commit-msg hook not armed in this CI checkout: a CI run makes no commit, and the fix pass arms hooks on a machine only' })
98
+ } else if (dir === '') {
99
+ c.fail('the commit-msg hook is not armed, so nothing refuses an AI attribution trailer: run `shipkit hooks install` (`shipkit ci` arms it too)')
100
+ } else {
101
+ const body = isAbsolute(dir) ? readOrNull(join(dir, 'commit-msg')) : c.read(`${dir}/commit-msg`)
102
+ const own = dir === LAYOUT.hooks
103
+ if (body == null)
104
+ c.fail(`core.hooksPath=${dir} has no ${dir}/commit-msg, so nothing refuses an AI attribution trailer: ${own ? 'run `shipkit hooks install`' : `add one that runs \`${chain}\``}`)
105
+ else if (!chainsShipkit(body))
106
+ c.fail(`core.hooksPath=${dir}: its commit-msg (${body.split('\n').find((l) => l.trim() !== '' && !l.startsWith('#'))?.trim().slice(0, 80) ?? 'empty'}) does not run the shipkit gate, so nothing refuses AI attribution: add \`${chain}\` above its last line`)
107
+ else c.ok(`the commit-msg hook refuses AI attribution (${own ? dir : `${dir}, chained to the shipkit gate`})`)
74
108
  }
75
- const commits = log.out
76
- .split('\x1e')
77
- .map((r) => r.trim())
78
- .filter((r) => r !== '')
79
- .map((r) => {
80
- const cut = r.indexOf('\x1f')
81
- return { hash: r.slice(0, cut), body: r.slice(cut + 1) }
82
- })
83
- if (commits.length === 0) return void c.lines.push({ kind: 'skip', text: 'commit history unverifiable — no commits yet' })
84
- const remedy = 'an unpushed TIP commit may be amended; anything deeper is the owner\'s decision (history is never rewritten as a side effect)'
85
- const sample = (hits: { hash: string }[]): string => hits.slice(0, 5).map((h) => h.hash).join(' ') + (hits.length > 5 ? ' …' : '')
86
- const attributed = commits.filter((m) => hasAiAttribution(m.body))
87
- const dashed = commits.filter((m) => hasDashPunctuation(m.body))
88
- // ref NAMES tell too (a FABLE-checkpoint-3 tag outs the agent as
89
- // loudly as a trailer). bare match only for names with no other
90
- // meaning; vendor and codename words (cursor, openai, gemini, opus,
91
- // fable, …) name legitimate branches and features, they only match in
92
- // a checkpoint/session shape
93
- const refTell = /\b(?:claude|chatgpt|copilot|aider|devin)\b|(?:^|[-_/])(?:fable|sonnet|haiku|opus|gemini|codex)[-_](?:checkpoint|session|ckpt)/i
94
- const refs = await c.exec(['git', 'for-each-ref', '--format=%(refname:short)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: c.root, timeoutMs: 30_000 })
95
- if (refs.code !== 0) c.lines.push({ kind: 'skip', text: `ref names unverifiable — git for-each-ref exit ${refs.code}` })
96
- const tellRefs = refs.code === 0 ? refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '' && refTell.test(r)) : []
97
- if (tellRefs.length > 0)
98
- c.fail(`${tellRefs.length} ref name(s) carry an AI tell in a declared-public repo (${tellRefs.slice(0, 5).join(' ')}${tellRefs.length > 5 ? ' …' : ''}) — rename or delete the refs before the flip; a pushed tag also needs the remote delete`)
99
- if (attributed.length > 0)
100
- c.fail(`${attributed.length}/${commits.length} commits carry AI attribution (a trailer naming an assistant / a session trailer or claude.ai url / "Generated with" / 🤖) in a declared-public repo — e.g. ${sample(attributed)}; ${remedy}`)
101
- if (dashed.length > 0)
102
- c.fail(`${dashed.length}/${commits.length} commit messages carry dash punctuation (em/en dash or inline " - ") in a declared-public repo — e.g. ${sample(dashed)}; ${remedy} (identity/writing-voice.md owns the punctuation rule)`)
103
- const silenced = attributionSilenced(c.root)
104
- if (!silenced)
105
- c.fail(`.claude/settings.json does not silence AI attribution — track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
106
- if (attributed.length === 0 && dashed.length === 0 && tellRefs.length === 0 && silenced)
107
- c.ok(`${commits.length} commits + ${refs.code === 0 ? refs.out.split('\n').filter((r) => r.trim() !== '').length : 0} ref names scanned, attribution silenced in .claude/settings.json — no AI tells`)
109
+
110
+ if (declaredVisibility(c.config) !== 'public') return
111
+ if (!attributionSilenced(c.root))
112
+ c.fail(`.claude/settings.json does not silence AI attribution: track it with {"attribution": {"commit": "", "pr": ""}} so no agent's harness ever writes the trailer or the PR footer`)
113
+ const refs = await c.exec(['git', 'for-each-ref', '--format=%(refname)', 'refs/tags', 'refs/heads', 'refs/remotes'], { cwd: c.root, timeoutMs: 30_000 })
114
+ if (refs.code !== 0) return void c.lines.push({ kind: 'skip', text: `ref names unverifiable: git for-each-ref exit ${refs.code}` })
115
+ const all = refs.out.split('\n').map((r) => r.trim()).filter((r) => r !== '')
116
+ const remote = new Set(all.filter((r) => r.startsWith('refs/remotes/')).map((r) => r.split('/').slice(3).join('/')))
117
+ const tells = all.filter((r) => REF_TELL.test(r.replace(/^refs\/(?:heads|tags|remotes)\//, '')))
118
+ const shown = (r: string): string => r.replace(/^refs\/(?:heads|tags|remotes)\//, '')
119
+ const local = tells.filter((r) => r.startsWith('refs/heads/') && !remote.has(shown(r)))
120
+ const out = tells.filter((r) => !local.includes(r) && !(r.startsWith('refs/heads/') && remote.has(shown(r))))
121
+ const list = (rs: string[], suffix = ''): string => rs.slice(0, 5).map((r) => `${shown(r)}${suffix}`).join(' ') + (rs.length > 5 ? ' …' : '')
122
+ if (out.length > 0)
123
+ c.fail(`${out.length} ref name(s) carry an AI tell in a declared-public repo (${list(out)}): rename or delete them, a pushed one on the remote too, then \`git fetch --prune\` so a remote ref deleted there leaves this clone`)
124
+ if (local.length > 0)
125
+ c.warn(`${local.length} branch name(s) carry an AI tell (${list(local, ' (local only)')}): never push them under this name, rename or delete them`)
108
126
  },
109
127
  })
110
128
 
@@ -7,16 +7,17 @@
7
7
  //
8
8
  // The tier split is the whole design. `fix` and `hard` hits BLOCK: they are
9
9
  // mechanical, and a machine reporting them is never guessing. `soft` hits
10
- // (sentence length, passive voice) need a human read, so they WARN, and only
11
- // a declared `baseline` makes them blocking, as a ratchet that can only go
12
- // down.
10
+ // (sentence length, passive voice, modals, semicolons, …) need a human read,
11
+ // so they are a note, never an attention item, and only a declared
12
+ // `baseline` makes them blocking, as a ratchet that can only go down.
13
13
  //
14
14
  // It carries NO `fix()` on purpose. `shipkit ci` runs `--fix-rules` by
15
15
  // default, so a rule with a fixer rewrites tracked files unattended, and
16
16
  // prose is not repo shape: a wrong package.json line is a config, a wrong
17
17
  // sentence is content. The rewrite lives behind an explicit `shipkit prose
18
18
  // --fix` that a human ran and can read the diff of.
19
- import { classSummary, proseLines, runProseCheck } from '../prose/run-prose.ts'
19
+ import { classSummary, proseLines, runProseCheck, type ProseReport } from '../prose/run-prose.ts'
20
+ import { tierOf, type ProseClass } from '../prose/ste.ts'
20
21
  import { check } from '../check.ts'
21
22
 
22
23
  const OWNER = 'identity/writing-voice.md'
@@ -53,12 +54,21 @@ export const proseSte = check({
53
54
  `${blocking} STE violation(s) across ${r.byFile.length}/${r.files} file(s) — ${classSummary(r)}`,
54
55
  r.fixable > 0 ? `shipkit prose --fix rewrites ${r.fixable} of them` : undefined,
55
56
  )
57
+ const soft = softSummary(r)
56
58
  if (cfg.baseline != null && r.byTier.soft > cfg.baseline)
57
- c.fail(`${r.byTier.soft} soft hits (sentence length, passive voice), above the declared baseline of ${cfg.baseline} — the ratchet only goes down`)
59
+ c.fail(`${r.byTier.soft} soft hits (${soft}), above the declared baseline of ${cfg.baseline}: the ratchet only goes down`)
58
60
  else if (r.byTier.soft > 0)
59
- c.warn(`${r.byTier.soft} soft hit(s): sentence length and passive voice need a human read${cfg.baseline != null ? ` (baseline ${cfg.baseline})` : ''}`)
61
+ c.note(`${r.byTier.soft} soft hit(s) need a human read (${soft})${cfg.baseline != null ? `, within the baseline of ${cfg.baseline}` : ''}: \`shipkit prose\` lists them`)
60
62
  if (!c.failed && blocking === 0) c.ok(`${r.files} file(s) STE-clean on the mechanical rules`)
61
63
  }
62
64
  })
63
65
 
66
+ /** every soft class with its count, most first */
67
+ const softSummary = (r: ProseReport): string =>
68
+ Object.entries(r.byClass)
69
+ .filter(([cls]) => tierOf(cls as ProseClass) === 'soft')
70
+ .sort((a, b) => b[1] - a[1])
71
+ .map(([cls, n]) => `${cls} ${n}`)
72
+ .join(', ')
73
+
64
74
  export default proseSte
@@ -1,8 +1,9 @@
1
1
  // SK016 — the shapes the check file replaced, and the machine that
2
2
  // rewrites them (build/shipkit/writing-a-check.md owns the shape itself).
3
3
  //
4
- // The window: every shape below is READ through 0.40, named once per run, and
5
- // refused at the first release after `shipkit fleet` shows no repo below 0.40.
4
+ // The window: every shape below is still READ, named once per run, and
5
+ // refused at the first release after `shipkit fleet` shows no repo below 0.40
6
+ // and none still carrying a check script.
6
7
  // So a repo on an old declaration keeps working, learns what to write instead
7
8
  // on every gate, and is carried across by one command, save what only a
8
9
  // reader can port: a check script's body, a rule, a task whose body is code.
@@ -60,7 +61,7 @@ export const legacyCheckShapes = check({
60
61
  if (s.placeholders.length > 0)
61
62
  c.warn(
62
63
  `${s.placeholders.length} check file(s) still open with the TODO description the migration wrote: ${s.placeholders.join(', ')}`,
63
- 'replace each TODO with one line stating what must be true',
64
+ 'by hand: replace each TODO with one line stating what must be true',
64
65
  )
65
66
  if (s.count === 0) return
66
67
  // one WARN per shape, never a bare line: the gate promises every line
@@ -78,9 +79,14 @@ export const legacyCheckShapes = check({
78
79
  }
79
80
  if (s.rules.length > 0) c.warn(`${s.rules.length} \`rules: []\` entr(ies): ${s.rules.join(', ')} — each one is a *.check.ts file whose \`run\` is its \`lint\``)
80
81
  if (s.ruleExempt.length > 0) c.warn(`ruleExempt names ${s.ruleExempt.join(', ')} — the spelling is \`when: { id: 'off:<why>' }\``)
82
+ // what the fixer rewrites without guessing: the exemptions, the tasks
83
+ // once all of them are data, checkDefaults once no script leans on it
84
+ const mechanical = s.ruleExempt.length + (s.tasks.every((x) => x.data) ? s.tasks.length : 0) + (s.checkDefaults && s.checkFiles.length === 0 ? 1 : 0)
81
85
  c.warn(
82
- `${s.count} declaration(s) in a shape the check file replaced — read through 0.40, refused after`,
83
- 'shipkit check --fix SK016 rewrites what is mechanical and names the rest',
86
+ `${s.count} declaration(s) in a shape the check file replaced: still read, and refused by the first shipkit release after \`shipkit fleet\` shows no repo below 0.40 and none still carrying a check script`,
87
+ mechanical > 0
88
+ ? `shipkit check --fix SK016 rewrites ${mechanical} of them and names the rest`
89
+ : `nothing left is mechanical, so the port is by hand: each line above becomes a *.check.ts file (${OWNER})`,
84
90
  )
85
91
  },
86
92
  fix: async (c) => await fixLegacyShapes(c),