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.
- package/CHANGELOG.md +37 -0
- package/package.json +1 -1
- package/src/asks/asks-queue.ts +4 -2
- package/src/assets/kits/identity-kit.ts +15 -13
- package/src/assets/run-assets.ts +12 -2
- package/src/check.ts +6 -4
- package/src/checks/SK001-package-json-shape.check.ts +20 -2
- package/src/checks/SK009-commit-ai-tells.check.ts +90 -72
- package/src/checks/SK014-prose-ste.check.ts +16 -6
- package/src/checks/SK016-legacy-check-shapes.check.ts +11 -5
- package/src/checks/SK027-agent-wiki-links.check.ts +30 -8
- package/src/checks/SK028-readme.check.ts +6 -2
- package/src/cli/cmd_CrossRepoActivity.ts +4 -1
- package/src/collections/collection-types.ts +15 -0
- package/src/collections/providers.ts +6 -2
- package/src/contracts/apply-contracts.ts +9 -8
- package/src/contracts/contract-types.ts +8 -2
- package/src/contracts/contracts-engine.ts +34 -13
- package/src/contracts/manifests/identity.contract.ts +6 -5
- package/src/contracts/manifests/social.contract.ts +4 -2
- package/src/contracts/manifests/steam.contract.ts +7 -3
- package/src/contracts/web-surface.ts +59 -0
- package/src/core/budget.ts +19 -9
- package/src/core/check-runner.ts +26 -5
- package/src/core/discover-checks.ts +8 -5
- package/src/core/layout.ts +2 -0
- package/src/core/motherhome.ts +88 -66
- package/src/core/run-check.ts +2 -5
- package/src/core/run-ci.ts +3 -3
- package/src/core/wire.ts +38 -9
- package/src/home/activity-shape.ts +4 -1
- package/src/home/activity.ts +22 -15
- package/src/home/gui-app/ShipkitGui.swift +144 -25
- package/src/home/gui-app.ts +93 -30
- package/src/home/home-launchd.ts +10 -2
- package/src/home/home-routes.ts +2 -1
- package/src/home/home-start.ts +33 -5
- package/src/home/machine-stores.ts +4 -2
- package/src/home/open-app.ts +23 -10
- package/src/home/pm-config.ts +15 -11
- package/src/home/pm-inputs.ts +76 -0
- package/src/home/pm.ts +3 -3
- package/src/home/process-rows.ts +23 -6
- package/src/home/resources.ts +67 -15
- package/src/home/run-daemon.ts +6 -1
- package/src/home/run-pm.ts +29 -5
- package/src/home/widgets.ts +32 -3
- package/src/hooks/commit-message.ts +2 -3
- package/src/hooks/run-hooks.ts +12 -2
- package/src/ledger/gate-checks.ts +3 -3
- package/src/ledger/store.ts +11 -0
- package/src/ledger/world-cache.ts +17 -15
- package/src/repo-config.ts +6 -1
- package/src/serve/hub-app/activity-view.tsx +3 -3
- package/src/serve/hub-app/checks-svc.ts +2 -1
- package/src/serve/hub-app/col-tables-view.tsx +124 -0
- package/src/serve/hub-app/col-tables.ts +115 -0
- package/src/serve/hub-app/cols-svc.ts +28 -0
- package/src/serve/hub-app/dock.css +1 -1
- package/src/serve/hub-app/home-st.ts +1 -1
- package/src/serve/hub-app/home-strip.css +32 -17
- package/src/serve/hub-app/home-strip.tsx +99 -89
- package/src/serve/hub-app/hub-app.tsx +7 -1
- package/src/serve/hub-app/hub-types.ts +6 -3
- package/src/serve/hub-app/hub.css +24 -2
- package/src/serve/hub-app/icons.tsx +4 -0
- package/src/serve/hub-app/keys.ts +6 -6
- package/src/serve/hub-app/machine-st.ts +26 -37
- package/src/serve/hub-app/palette-st.ts +5 -5
- package/src/serve/hub-app/palette-view.tsx +1 -1
- package/src/serve/hub-app/project-list.tsx +3 -3
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-app/runs-tabs.ts +1 -1
- package/src/serve/hub-app/timeline-view.tsx +14 -11
- package/src/serve/project-gutter.ts +1 -2
- package/src/serve/warm-cell.ts +49 -16
- package/src/suite/judge-suites.ts +42 -17
- package/src/suite/suite-cli.ts +5 -8
- package/src/wiki/lint-md.ts +2 -0
- 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
|
+
"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",
|
package/src/asks/asks-queue.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
11
|
-
// (1200×630),
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
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 (
|
|
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('
|
|
99
|
+
await write('logo.png', logoPng)
|
|
98
100
|
// favicon = logo master resized to 512² (browsers derive the rest)
|
|
99
|
-
await write('
|
|
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('
|
|
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
|
}
|
package/src/assets/run-assets.ts
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
377
|
-
*
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
const refs =
|
|
46
|
-
const
|
|
47
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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,
|
|
11
|
-
//
|
|
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 (
|
|
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.
|
|
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
|
|
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
|
|
83
|
-
|
|
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),
|